From 251b70ce83f72c5237b6bbf62e96e485adb0856b Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 11 Sep 2026 11:43:04 +0200 Subject: [PATCH] feat(merge): Stash-Option fuer uncommittete Aenderungen im Ziel-Tree 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 --- src/ClaudeDo.Data/Git/GitService.cs | 14 ++++++-- src/ClaudeDo.Localization/locales/de.json | 7 ++-- src/ClaudeDo.Localization/locales/en.json | 7 ++-- .../Services/Interfaces/IWorkerClient.cs | 6 ++-- src/ClaudeDo.Ui/Services/WorkerClient.cs | 9 ++--- .../Conflicts/ConflictResolverViewModel.cs | 2 +- .../Islands/DetailsIslandViewModel.cs | 16 ++++++++- .../ViewModels/Modals/MergeModalViewModel.cs | 22 ++++++++++-- .../Modals/WorktreesOverviewModalViewModel.cs | 3 +- .../Views/Modals/MergeModalView.axaml | 5 +++ .../External/ExternalMcpService.cs | 4 ++- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 10 +++--- .../Lifecycle/TaskMergeService.cs | 25 +++++++++----- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 4 +-- .../DetailsIslandConflictSeamTests.cs | 2 +- .../ViewModels/DetailsIslandPlanningTests.cs | 2 +- .../DetailsIslandReviewActionsTests.cs | 4 +-- .../ViewModels/MergeModalViewModelTests.cs | 29 ++++++++++++++-- .../TasksIslandApproveReviewTests.cs | 2 +- .../Services/TaskMergeServiceTests.cs | 34 ++++++++++++++++++- 20 files changed, 164 insertions(+), 43 deletions(-) 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}}" /> +