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
+3 -1
View File
@@ -708,7 +708,9 @@ public sealed class ExternalMcpService
else
{
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
if (r.Status == TaskMergeService.StatusBlocked)
// dirty_target is a refusal like blocked -- only the UI turns it into a stash
// retry; an MCP session must not read it as an approve that went through.
if (r.Status is TaskMergeService.StatusBlocked or TaskMergeService.StatusDirtyTarget)
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
mergeStatus = r.Status;
mergeConflicts = r.ConflictFiles;
+6 -4
View File
@@ -478,7 +478,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
}
public Task<MergeResultDto> MergeTask(
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
=> HubGuard(async () =>
{
// A blank message is handed through deliberately — TaskMergeService builds the
@@ -488,7 +488,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
targetBranch ?? "",
removeWorktree,
commitMessage,
CancellationToken.None);
leaveConflictsInTree: false,
CancellationToken.None,
autostash: autostash);
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
@@ -683,7 +685,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
if (!result.Ok) throw new HubException(result.Reason ?? "dequeue failed");
}
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch, bool autostash = false)
=> HubGuard(async () =>
{
bool hasChildren;
@@ -698,7 +700,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return new MergeResultDto(unitResult.Status, Array.Empty<string>(), unitResult.Reason);
}
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "approve failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
@@ -70,6 +70,10 @@ public sealed class TaskMergeService
public const string StatusMergedVerifyPending = "merged_verify_pending";
public const string StatusVerifyPassed = "verify_passed";
public const string StatusUntrackedCollision = "untracked_collision";
// Nothing landed -- the target working dir has uncommitted changes. Its own status rather
// than a Blocked reason string so callers can offer the autostash retry (MergeAsync's
// autostash: true) without matching on prose.
public const string StatusDirtyTarget = "dirty_target";
public const string StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted";
@@ -375,7 +379,8 @@ public sealed class TaskMergeService
bool leaveConflictsInTree,
CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null,
bool skipVerify = false)
bool skipVerify = false,
bool autostash = false)
{
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
@@ -400,8 +405,10 @@ public sealed class TaskMergeService
return Blocked("working directory is not a git repository");
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
// autostash hands the dirty tree to git itself (merge --autostash) instead of refusing.
if (!autostash && await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
return new MergeResult(StatusDirtyTarget, Array.Empty<string>(),
"target working tree has uncommitted changes");
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
@@ -417,7 +424,7 @@ public sealed class TaskMergeService
var message = string.IsNullOrWhiteSpace(commitMessage)
? DefaultMergeMessage(task, list)
: commitMessage;
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct);
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct, autostash);
if (exitCode != 0)
{
List<string> files;
@@ -973,13 +980,15 @@ public sealed class TaskMergeService
}
}
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);
public Task<MergeResult> ApproveAndMergeAsync(
string taskId, string targetBranch, CancellationToken ct, bool autostash = false)
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct, autostash: autostash);
public async Task<MergeResult> ApproveAndMergeAsync(
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null,
bool skipVerify = false)
bool skipVerify = false,
bool autostash = false)
{
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
@@ -1070,7 +1079,7 @@ public sealed class TaskMergeService
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
// Remove the worktree on approve (matching the unit-merge path) so merged
// worktrees don't pile up; the merge commit on the target branch is the record.
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify);
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify, autostash);
}
/// <summary>