feat(merge): Stash-Option fuer uncommittete Aenderungen im Ziel-Tree
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s

Ein dirty Ziel-Arbeitsverzeichnis gibt jetzt den eigenen Status
dirty_target zurueck statt blocked. Die UI bietet daraufhin einen
Retry an, der per git merge --autostash stasht, merged und die
Aenderungen danach wiederherstellt - opt-in pro Merge:

- Approve & Merge: Bestaetigungsdialog
- Merge-Modal: Button "Aenderungen stashen und mergen"
- MCP (merge_task/review_task) wirft weiterhin, kein autostash dort
This commit is contained in:
mika kuns
2026-09-11 11:43:28 +02:00
parent 8e2d37e63e
commit 251b70ce83
20 changed files with 164 additions and 43 deletions
@@ -110,9 +110,11 @@ public interface IWorkerClient : INotifyPropertyChanged
/// and clears BlockedByTaskId in the same update) — call once per row (parent and each
/// queued child) rather than cascading server-side.</summary>
Task DequeueTaskAsync(string taskId);
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
/// <param name="autostash">Retry flag for status "dirty_target": stash the target working
/// tree's uncommitted changes before merging and restore them afterwards (git --autostash).</param>
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false);
Task RejectReviewToQueueAsync(string taskId, string feedback);
Task RejectReviewToIdleAsync(string taskId);
Task CancelReviewAsync(string taskId);
+5 -4
View File
@@ -353,10 +353,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await InvokeTimedAsync("ResetTask", () => _hub.InvokeAsync("ResetTask", taskId));
}
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
public async Task<MergeResultDto> MergeTaskAsync(
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
{
return await InvokeTimedAsync<MergeResultDto>("MergeTask", () => _hub.InvokeAsync<MergeResultDto>(
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage));
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage, autostash));
}
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
@@ -553,10 +554,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task DequeueTaskAsync(string taskId)
=> InvokeTimedAsync("DequeueTask", () => _hub.InvokeAsync("DequeueTask", taskId));
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false)
{
LastApproveTarget = targetBranch;
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch));
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch, autostash));
}
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
@@ -145,7 +145,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
{
// Any non-conflict outcome here means nothing was left mid-merge for this dialog
// to resolve -- surface why instead of quietly closing with no explanation.
if (start.Status is "blocked" or "untracked_collision")
if (start.Status is "blocked" or "untracked_collision" or "dirty_target")
Error = start.ErrorMessage;
return false;
}
@@ -1275,7 +1275,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
if (!hasChildren && result?.Status == "conflict")
// Nothing landed -- the target working dir has uncommitted changes (often another
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
// restores the changes itself once the merge is through.
if (result?.Status == "dirty_target" && ConfirmAsync != null
&& await ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
{
ApproveOp.Report(Loc.T("ops.merge.merging"));
result = await _worker.ApproveReviewAsync(
taskId, Merge.SelectedMergeTarget ?? "", autostash: true);
}
if (result?.Status == "dirty_target" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
else if (!hasChildren && result?.Status == "conflict")
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
// The merge itself already landed; the verify command failed, so the task stayed
// out of Done. Surface that instead of silently looking like nothing happened.
@@ -31,6 +31,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
[ObservableProperty] private string? _warningMessage;
[ObservableProperty] private string? _successMessage;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _hasConflict;
/// The merge was refused because the target working dir is dirty — offer the stash retry
/// instead of dead-ending on the error text.
[ObservableProperty] private bool _showStashRetry;
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
public Action? CloseAction { get; set; }
@@ -84,13 +88,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
[RelayCommand(CanExecute = nameof(CanSubmit))]
private async Task SubmitAsync()
private Task SubmitAsync() => RunMergeAsync(autostash: false);
/// Retry after "dirty_target": git stashes the target working tree's uncommitted changes,
/// merges, and restores them afterwards.
[RelayCommand(CanExecute = nameof(CanSubmit))]
private Task StashAndMergeAsync() => RunMergeAsync(autostash: true);
private async Task RunMergeAsync(bool autostash)
{
if (string.IsNullOrWhiteSpace(SelectedBranch)) return;
IsBusy = true;
ErrorMessage = null;
WarningMessage = null;
SuccessMessage = null;
ShowStashRetry = false;
// Subscribed only for the duration of the call: the worker's broadcast reaches every
// client, and a transient VM left on that event would outlive its window.
ProgressMessage = Loc.T("vm.merge.progressMerging");
@@ -98,7 +110,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
try
{
var result = await _worker.MergeTaskAsync(
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage);
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage, autostash);
switch (result.Status)
{
@@ -130,6 +142,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
// point of the gate.
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed");
break;
case "dirty_target":
// Nothing landed -- the target working dir has uncommitted changes. Offer the
// stash retry; git restores the changes after the merge.
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.dirtyTarget");
ShowStashRetry = true;
break;
case "untracked_collision":
// Nothing landed -- the merge was refused before touching the repo. Show the
// real message (it names the colliding path and size); the locale string is
@@ -415,7 +415,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
private bool CanMergeAll() => !BatchMergeStatus.IsRunning && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
private Task MergeAll() => MergeSelectedAsync(
(taskId, target, removeWorktree, message) => _worker.MergeTaskAsync(taskId, target, removeWorktree, message));
[RelayCommand]
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
@@ -70,6 +70,11 @@
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="btn" HorizontalAlignment="Left"
Content="{loc:Tr modals.merge.stashAndMerge}"
Command="{Binding StashAndMergeCommand}"
IsVisible="{Binding ShowStashRetry}" />
<Border Classes="danger-box"
IsVisible="{Binding HasConflict}">
<StackPanel Spacing="4">