diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs
index 613724d4..3e6f1f07 100644
--- a/src/ClaudeDo.Data/Git/GitService.cs
+++ b/src/ClaudeDo.Data/Git/GitService.cs
@@ -424,14 +424,22 @@ public sealed class GitService
return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
}
+ /// Stash the target working tree's uncommitted changes before the
+ /// merge and restore them after it. Git owns the bookkeeping: on a conflict the stash stays
+ /// pending and is re-applied by `merge --continue`/`--abort`, so a conflicted merge left in
+ /// the tree doesn't lose it. A restore that itself conflicts leaves the stash in
+ /// `git stash list` rather than dropping it.
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
- string repoDir, string sourceBranch, string message, CancellationToken ct = default)
+ string repoDir, string sourceBranch, string message, CancellationToken ct = default,
+ bool autostash = false)
{
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
// clean merges are unaffected.
- var (exitCode, _, stderr) = await RunGitAsync(repoDir,
- ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
+ string[] args = autostash
+ ? ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "--autostash", "-m", message, sourceBranch]
+ : ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch];
+ var (exitCode, _, stderr) = await RunGitAsync(repoDir, args, ct);
return (exitCode, stderr);
}
diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 25713d8d..7352a2b4 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -481,7 +481,8 @@
"targetBranch": "Ziel-Branch",
"removeWorktree": "Worktree nach dem Mergen entfernen",
"commitMessage": "Commit-Nachricht",
- "conflictedFiles": "Konfliktdateien:"
+ "conflictedFiles": "Konfliktdateien:",
+ "stashAndMerge": "Änderungen stashen und mergen"
},
"diff": {
"title": "DIFF",
@@ -850,7 +851,7 @@
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
- "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
+ "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
@@ -870,7 +871,7 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
- "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
+ "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "stashAndMergeConfirm": "Das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.\nStashen, mergen und danach wiederherstellen?", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "smartNotes": "Notizen", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index 715c1160..48b391d6 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -481,7 +481,8 @@
"targetBranch": "Target branch",
"removeWorktree": "Remove worktree after merge",
"commitMessage": "Commit message",
- "conflictedFiles": "Conflicted files:"
+ "conflictedFiles": "Conflicted files:",
+ "stashAndMerge": "Stash changes and merge"
},
"diff": {
"title": "DIFF",
@@ -850,7 +851,7 @@
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
- "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
+ "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
@@ -870,7 +871,7 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
- "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
+ "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "stashAndMergeConfirm": "The target working directory has uncommitted changes.\nStash them, merge, and restore them afterwards?", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "smartNotes": "Notes", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" },
diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
index 64e7308e..04ade81f 100644
--- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs
@@ -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.
Task DequeueTaskAsync(string taskId);
- Task ApproveReviewAsync(string taskId, string targetBranch);
+ /// Retry flag for status "dirty_target": stash the target working
+ /// tree's uncommitted changes before merging and restore them afterwards (git --autostash).
+ Task ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false);
Task PreviewMergeAsync(string taskId, string targetBranch);
- Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
+ Task 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);
diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs
index 14deb8ee..979255c3 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -353,10 +353,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await InvokeTimedAsync("ResetTask", () => _hub.InvokeAsync("ResetTask", taskId));
}
- public async Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
+ public async Task MergeTaskAsync(
+ string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
{
return await InvokeTimedAsync("MergeTask", () => _hub.InvokeAsync(
- "MergeTask", taskId, targetBranch, removeWorktree, commitMessage));
+ "MergeTask", taskId, targetBranch, removeWorktree, commitMessage, autostash));
}
public Task 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 ApproveReviewAsync(string taskId, string targetBranch)
+ public async Task ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false)
{
LastApproveTarget = targetBranch;
- return await InvokeTimedAsync("ApproveReview", () => _hub.InvokeAsync("ApproveReview", taskId, targetBranch));
+ return await InvokeTimedAsync("ApproveReview", () => _hub.InvokeAsync("ApproveReview", taskId, targetBranch, autostash));
}
public Task PreviewMergeAsync(string taskId, string targetBranch)
diff --git a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
index 39098c9c..8995ab85 100644
--- a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs
@@ -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;
}
diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
index 409c614f..9b40b0e1 100644
--- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
@@ -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.
diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
index bcb708e9..e39d4ace 100644
--- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
@@ -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 _conflictFiles = Array.Empty();
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
diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs
index 7185143c..04b25ada 100644
--- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs
@@ -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)
diff --git a/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml
index c83e1bda..a7ef1164 100644
--- a/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml
+++ b/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml
@@ -70,6 +70,11 @@
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
+
+
diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs
index 9d9bb886..282eee25 100644
--- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs
+++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs
@@ -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;
diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
index a5028c38..01a41be6 100644
--- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs
+++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs
@@ -478,7 +478,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
}
public Task 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 ApproveReview(string taskId, string targetBranch)
+ public Task 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(), 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);
diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs
index 257af556..7d53f590 100644
--- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs
+++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs
@@ -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? 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(),
+ "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 files;
@@ -973,13 +980,15 @@ public sealed class TaskMergeService
}
}
- public Task ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
- => ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);
+ public Task ApproveAndMergeAsync(
+ string taskId, string targetBranch, CancellationToken ct, bool autostash = false)
+ => ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct, autostash: autostash);
public async Task ApproveAndMergeAsync(
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct,
IProgress? 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);
}
///
diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
index 4f22a459..cbf97e32 100644
--- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
+++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs
@@ -109,9 +109,9 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task SetTaskDoneAsync(string taskId) => Task.CompletedTask;
public virtual Task UnsetTaskDoneAsync(string taskId) => Task.CompletedTask;
public virtual Task DequeueTaskAsync(string taskId) => Task.CompletedTask;
- public virtual Task ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult(null);
+ public virtual Task ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Task.FromResult(null);
public virtual Task PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult(null);
- public virtual Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null));
+ public virtual Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty(), null));
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
index e6a311e0..3db6b102 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
@@ -41,7 +41,7 @@ public class DetailsIslandConflictSeamTests : IDisposable
private sealed class ConflictApproveWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
- public override Task ApproveReviewAsync(string taskId, string targetBranch) =>
+ public override Task ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
Task.FromResult(new MergeResultDto("conflict", new[] { "a.cs" }, null));
}
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs
index 8ae2860e..aca9f1b5 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs
@@ -55,7 +55,7 @@ public class DetailsIslandPlanningTests : IDisposable
private sealed class ThrowingReviewWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
- public override Task ApproveReviewAsync(string taskId, string targetBranch) =>
+ public override Task ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
Task.FromException(new InvalidOperationException("Task is not waiting for review; cannot approve."));
}
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs
index f4dd2a42..28365936 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs
@@ -141,7 +141,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
{
public override bool IsConnected => true;
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
- public override Task ApproveReviewAsync(string taskId, string targetBranch) =>
+ public override Task ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
throw new Exception(ExceptionMessage);
}
@@ -167,7 +167,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
{
public override bool IsConnected => true;
public TaskCompletionSource Gate { get; } = new();
- public override Task ApproveReviewAsync(string taskId, string targetBranch) => Gate.Task;
+ public override Task ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Gate.Task;
}
[Fact]
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MergeModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeModalViewModelTests.cs
index 577f7e7f..a8e2c3e9 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/MergeModalViewModelTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeModalViewModelTests.cs
@@ -21,17 +21,22 @@ public class MergeModalViewModelTests
public TaskCompletionSource MergeGate { get; } = new();
public bool BlockMerge { get; set; }
public string? CapturedCommitMessage { get; private set; }
+ public bool CapturedAutostash { get; private set; }
+
+ /// Result for a call with autostash=false; the autostash retry always merges.
+ public MergeResultDto CleanResult { get; set; } = new("merged", new List(), null);
public override Task GetMergeTargetsAsync(string taskId) =>
Task.FromResult(Targets);
public override Task MergeTaskAsync(
- string taskId, string targetBranch, bool removeWorktree, string commitMessage)
+ string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
{
CapturedCommitMessage = commitMessage;
+ CapturedAutostash = autostash;
return BlockMerge
? MergeGate.Task
- : Task.FromResult(new MergeResultDto("merged", new List(), null));
+ : Task.FromResult(autostash ? new MergeResultDto("merged", new List(), null) : CleanResult);
}
}
@@ -90,6 +95,26 @@ public class MergeModalViewModelTests
Assert.True(vm.Merged);
}
+ [Fact]
+ public async Task Dirty_target_offers_the_stash_retry_and_merges_with_autostash()
+ {
+ var (vm, worker) = Build();
+ await vm.InitializeAsync("task-1", "do the thing");
+ worker.CleanResult = new MergeResultDto("dirty_target", new List(), "target working tree has uncommitted changes");
+
+ await vm.SubmitCommand.ExecuteAsync(null);
+
+ Assert.True(vm.ShowStashRetry);
+ Assert.False(vm.Merged);
+ Assert.False(worker.CapturedAutostash);
+
+ await vm.StashAndMergeCommand.ExecuteAsync(null);
+
+ Assert.True(worker.CapturedAutostash);
+ Assert.True(vm.Merged);
+ Assert.False(vm.ShowStashRetry);
+ }
+
[Fact]
public async Task Progress_for_another_task_is_ignored()
{
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandApproveReviewTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandApproveReviewTests.cs
index 5c5c7146..263086e5 100644
--- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandApproveReviewTests.cs
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandApproveReviewTests.cs
@@ -37,7 +37,7 @@ public class TasksIslandApproveReviewTests : IDisposable
private sealed class ThrowingWorkerClient : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
- public override Task ApproveReviewAsync(string taskId, string targetBranch) =>
+ public override Task ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
throw new Exception(ExceptionMessage);
}
diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
index 103e69aa..c116a8c6 100644
--- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
@@ -392,10 +392,42 @@ public class TaskMergeServiceTests : IDisposable
var (svc, _) = BuildService(db);
var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);
- Assert.Equal("blocked", result.Status);
+ Assert.Equal(TaskMergeService.StatusDirtyTarget, result.Status);
Assert.Contains("uncommitted", result.ErrorMessage ?? "");
}
+ [Fact]
+ public async Task MergeAsync_DirtyWorkingTree_WithAutostash_MergesAndRestoresTheChanges()
+ {
+ if (!GitRepoFixture.IsGitAvailable()) return;
+ var repo = NewRepo();
+ var db = NewDb();
+ var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
+
+ var wtMgr = BuildWorktreeManager(db);
+ var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
+ _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
+
+ // The branch touches a different file than the dirty one, so the stash restores cleanly.
+ File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "from the branch\n");
+ GitRepoFixture.RunGit(wtCtx.WorktreePath, "add", "-A");
+ GitRepoFixture.RunGit(wtCtx.WorktreePath, "commit", "-m", "feat: add feature.txt");
+
+ File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
+
+ var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
+ var (svc, _) = BuildService(db);
+ var result = await svc.MergeAsync(
+ task.Id, currentBranch, false, "Merge", leaveConflictsInTree: false, CancellationToken.None,
+ autostash: true);
+
+ Assert.True(result.Status == TaskMergeService.StatusMerged, $"{result.Status}: {result.ErrorMessage}");
+ // The branch landed...
+ Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feature.txt")));
+ // ...and the stashed work is back in the working tree, still uncommitted.
+ Assert.Equal("dirty\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")).Replace("\r\n", "\n"));
+ }
+
[Fact]
public async Task MergeAsync_UntrackedFileInTargetWorkingTree_DoesNotBlock()
{