From 09bcd696ddcf21c120ccbed5e8e07467de21bfd9 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 16 Sep 2026 12:56:35 +0200 Subject: [PATCH] feat(ui): dirty Ziel-Verzeichnis beim Oeffnen des Merge-Dialogs melden statt nach dem Fehlschlag MergeTargetsDto/MergeTargets bekommen TargetDirty (via HasChangesAsync, includeUntracked: false). Das Merge-Modal zeigt Warnung + Stash-Retry sofort in InitializeAsync statt erst nach dem dirty_target-Fehlschlag; der REVIEW-Block in der Detail-Pane zeigt denselben Hinweis neben Approve. Der dirty_target-Rueckgabepfad bleibt als Absicherung bestehen. Behebt Befund F6 aus docs/test-session-2026-09-16.md. --- src/ClaudeDo.Data/Wire.cs | 3 +- src/ClaudeDo.Localization/locales/de.json | 3 +- src/ClaudeDo.Localization/locales/en.json | 3 +- .../Islands/DetailsIslandViewModel.cs | 3 ++ .../Islands/MergeSectionViewModel.cs | 5 +++ .../ViewModels/Modals/MergeModalViewModel.cs | 8 +++++ .../Views/Islands/Detail/WorkConsole.axaml | 4 +++ .../Views/Modals/MergeModalView.axaml | 5 +++ src/ClaudeDo.Worker/Hub/WorkerHub.cs | 2 +- .../Lifecycle/TaskMergeService.cs | 6 ++-- .../ViewModels/MergeModalViewModelTests.cs | 24 ++++++++++++++ .../Services/TaskMergeServiceTests.cs | 32 +++++++++++++++++++ 12 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index c387d9e0..9d649a9e 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -92,7 +92,8 @@ public record MergePreviewDto( int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null); public record MergeTargetsDto( - string DefaultBranch, IReadOnlyList LocalBranches, string DefaultCommitMessage); + string DefaultBranch, IReadOnlyList LocalBranches, string DefaultCommitMessage, + bool TargetDirty = false); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index f928db3c..662bf3b2 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -328,6 +328,7 @@ "worktree": "Worktree", "reviewCombinedDiff": "Kombinierten Diff prüfen", "openDiffHint": "Diff öffnen, um Mergen freizuschalten", + "targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.", "approveMerge": "Genehmigen & Mergen", "park": "Parken", "parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten", @@ -859,7 +860,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.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "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.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "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." }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index d8293800..8230010c 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -328,6 +328,7 @@ "worktree": "Worktree", "reviewCombinedDiff": "Review Combined Diff", "openDiffHint": "Open the diff to enable merge", + "targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.", "approveMerge": "Approve & Merge", "park": "Park", "parkTip": "Set aside — back to Idle, keeps the worktree", @@ -859,7 +860,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.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "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.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "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." }, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 32fb5885..56d2aca8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -726,6 +726,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Merge.MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; + Merge.TargetDirty = targets.TargetDirty; } } await Merge.RefreshMergePreviewAsync(); @@ -776,6 +777,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; + Merge.TargetDirty = targets.TargetDirty; } } } @@ -822,6 +824,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b); Merge.SelectedMergeTarget = targets.DefaultBranch; + Merge.TargetDirty = targets.TargetDirty; } } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs index da08761a..048f5cde 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs @@ -28,6 +28,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase [ObservableProperty] private ObservableCollection _mergeTargetBranches = new(); [ObservableProperty] private string? _selectedMergeTarget; + // Mirrors MergeTargetsDto.TargetDirty — surfaced next to Approve so a dirty target working + // dir is visible before anyone clicks, not just after the merge modal refuses it. + [ObservableProperty] private bool _targetDirty; + // No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path, // not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button. public OperationStatus PreviewOp { get; } = new(); @@ -141,6 +145,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false; + TargetDirty = false; SyncWorktree(null, null, null, null, null); SyncTaskContext(null, null, false); SyncChildOutcomes(false, 0); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs index e39d4ace..2d27b8d9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs @@ -76,6 +76,14 @@ public sealed partial class MergeModalViewModel : ViewModelBase SelectedBranch = Branches.Contains(targets.DefaultBranch) ? targets.DefaultBranch : Branches.FirstOrDefault(); + + // Known dirty before anyone clicks Merge — offer the stash retry right away instead + // of making the user hit the "dirty_target" refusal first. + if (targets.TargetDirty) + { + WarningMessage = Loc.T("vm.merge.targetDirty"); + ShowStashRetry = true; + } } catch (Exception ex) { diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml index e1361c95..91918844 100644 --- a/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml @@ -327,6 +327,10 @@ + diff --git a/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml index a7ef1164..e90f6e3f 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}}" /> + +