Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18394dd6b2 | ||
|
|
251b70ce83 | ||
|
|
8e2d37e63e |
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## v2.15.0 — 2026-09-11
|
||||
|
||||
### Features
|
||||
|
||||
- Stash-Option fuer uncommittete Aenderungen im Ziel-Tree (251b70ce)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.14.0 (8e2d37e6)
|
||||
|
||||
## v2.14.0 — 2026-09-10
|
||||
|
||||
### Features
|
||||
|
||||
- Listen und Ordner in einer gemischten Sidebar-Reihenfolge (bfb1c652)
|
||||
- confirm before closing a running ConPTY session (eb623714)
|
||||
- worker-menu Shutdown ends worker + app after confirm (1bdf2027)
|
||||
- chain a normal parent's children on WaitingForChildren (d2648f96)
|
||||
- localize WorkConsole, DescriptionStepsCard and the ROADBLOCK label (8e6f0d16)
|
||||
- surface depends_on blocking in status chip, chain label, and detail pane (4cf601e4)
|
||||
- Worker-Menü "Update Claude CLI" führt claude update aus (dbee4a0d)
|
||||
- triage model picker (opus/fable) in the merge helper start modal (1177463a)
|
||||
- collapsible folders for the list sidebar (25c9a619)
|
||||
- list_folders table plus lists.folder_id (13e860f0)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Import broadcastet TaskUpdated fuer neue Tasks (1770b677)
|
||||
- ConnectedClientTracker in den externen MCP-Container teilen (3d09323e)
|
||||
- de.json-Konsistenz-Pass und restliche harte EN-Strings (d6696c33)
|
||||
- force English git output for locale-fragile stderr matches (5ecf6f75)
|
||||
- make diff-stat parsing locale-safe and dedupe the two parsers (29ea18c4)
|
||||
- report failed paste/drop in the interactive terminal (0cec0a77)
|
||||
- surface worker connect/restart failures instead of no-opting (b4b62254)
|
||||
- validate PowerUse window, explain effort and Prime Claude in Settings (951c2fe9)
|
||||
- start_list_handler ehrlich machen, wenn keine App verbunden ist (451c0184)
|
||||
- warn and block save when a list's working dir isn't a git repo (3348736e)
|
||||
- show a banner when a ConPTY session dies after startup (256bea5f)
|
||||
- merge duplicate top-level "checks" block in en/de.json (741f66fe)
|
||||
- translate max_turns failure text to English (0fcfe7c5)
|
||||
- route WeekReportService permission-mode through PermissionModeResolver (1c768529)
|
||||
- report startup port conflicts instead of crashing silently (c9d4823f)
|
||||
|
||||
### Documentation
|
||||
|
||||
- record list folders in the Data and Ui CLAUDE.md (5477ae53)
|
||||
- update for v2.13.6 (ec1e60be)
|
||||
|
||||
## v2.13.6 — 2026-09-07
|
||||
|
||||
### Features
|
||||
|
||||
@@ -424,14 +424,22 @@ public sealed class GitService
|
||||
return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
|
||||
}
|
||||
|
||||
/// <param name="autostash">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.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), 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;
|
||||
|
||||
@@ -41,7 +41,7 @@ public class DetailsIslandConflictSeamTests : IDisposable
|
||||
private sealed class ConflictApproveWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[] { "a.cs" }, null));
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
private sealed class ThrowingReviewWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Task.FromException<MergeResultDto?>(new InvalidOperationException("Task is not waiting for review; cannot approve."));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> 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<MergeResultDto?> Gate { get; } = new();
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Gate.Task;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Gate.Task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -21,17 +21,22 @@ public class MergeModalViewModelTests
|
||||
public TaskCompletionSource<MergeResultDto> 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<string>(), null);
|
||||
|
||||
public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) =>
|
||||
Task.FromResult(Targets);
|
||||
|
||||
public override Task<MergeResultDto> 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<string>(), null));
|
||||
: Task.FromResult(autostash ? new MergeResultDto("merged", new List<string>(), 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<string>(), "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()
|
||||
{
|
||||
|
||||
@@ -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<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user