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.
This commit is contained in:
Mika Kuns
2026-09-16 15:43:39 +02:00
committed by mika kuns
parent 86cc309ba9
commit 09bcd696dd
12 changed files with 92 additions and 6 deletions
+2 -1
View File
@@ -92,7 +92,8 @@ public record MergePreviewDto(
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null);
public record MergeTargetsDto(
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage);
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
bool TargetDirty = false);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
+2 -1
View File
@@ -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." },
+2 -1
View File
@@ -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." },
@@ -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;
}
}
}
@@ -28,6 +28,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
[ObservableProperty] private ObservableCollection<string> _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);
@@ -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)
{
@@ -327,6 +327,10 @@
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
<TextBlock Classes="meta" Text="{loc:Tr details.workConsole.targetDirty}"
TextWrapping="Wrap"
Foreground="{DynamicResource AmberBrush}"
IsVisible="{Binding Merge.TargetDirty}" />
</StackPanel>
<StackPanel Spacing="0">
@@ -70,6 +70,11 @@
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Text="{Binding WarningMessage}"
Foreground="{DynamicResource AmberBrush}"
TextWrapping="Wrap"
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="btn" HorizontalAlignment="Left"
Content="{loc:Tr modals.merge.stashAndMerge}"
Command="{Binding StashAndMergeCommand}"
+1 -1
View File
@@ -499,7 +499,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
=> HubGuard(async () =>
{
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty);
});
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
@@ -20,7 +20,8 @@ public sealed record MergeResult(
public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches,
string DefaultCommitMessage);
string DefaultCommitMessage,
bool TargetDirty = false);
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
@@ -875,7 +876,8 @@ public sealed class TaskMergeService
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
return new MergeTargets(current, branches, defaultMessage);
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
return new MergeTargets(current, branches, defaultMessage, dirty);
}
/// The commit message a merge uses when the caller passes none. Built here rather than in the
@@ -69,6 +69,30 @@ public class MergeModalViewModelTests
Assert.NotEqual("", vm.CommitMessage);
}
[Fact]
public async Task Initialize_flags_a_dirty_target_up_front()
{
var (vm, worker) = Build();
worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: true);
await vm.InitializeAsync("task-1", "do the thing");
Assert.True(vm.ShowStashRetry);
Assert.False(string.IsNullOrWhiteSpace(vm.WarningMessage));
}
[Fact]
public async Task Initialize_does_not_flag_a_clean_target()
{
var (vm, worker) = Build();
worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: false);
await vm.InitializeAsync("task-1", "do the thing");
Assert.False(vm.ShowStashRetry);
Assert.Null(vm.WarningMessage);
}
[Fact]
public async Task Submit_shows_a_phase_while_the_merge_is_pending_and_clears_it_after()
{
@@ -374,6 +374,38 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
}
[Fact]
public async Task GetTargetsAsync_UncommittedChangeInTarget_ReportsTargetDirty()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.True(targets.TargetDirty);
}
[Fact]
public async Task GetTargetsAsync_OnlyUntrackedFileInTarget_DoesNotReportTargetDirty()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.False(targets.TargetDirty);
}
[Fact]
public async Task MergeAsync_DirtyWorkingTree_ReturnsBlocked()
{