diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index f9d91991..20ad9248 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -661,7 +661,7 @@ "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "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." }, "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": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, + "merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, "conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {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}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index d82e237a..5cc91a0d 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -661,7 +661,7 @@ "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "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." }, "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": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, + "merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, "conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {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}" }, diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs index 351499ac..7197a7fa 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs @@ -110,6 +110,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase case "blocked": ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? ""); break; + case "verify_failed": + // The merge landed; only the Done transition was withheld. Deliberately not + // treated as success -- no auto-close, because the failure text is the whole + // point of the gate. + ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed"); + break; default: ErrorMessage = Loc.T("vm.merge.unknownStatus", result.Status); break; diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index d88a0895..ef419616 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -12,7 +12,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.ViewModels.Modals; -public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed } +public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, VerifyFailed, Failed } public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase { @@ -402,6 +402,14 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase case "blocked": row.MergeOutcome = BatchMergeOutcome.Blocked; break; + case "verify_failed": + // The merge landed (so the worktree really is merged), but the list's + // verify command failed and the task was kept out of Done. Reporting + // this as a plain Failed would claim the merge didn't happen. + row.MergeOutcome = BatchMergeOutcome.VerifyFailed; + row.State = WorktreeState.Merged; + row.IsChecked = false; + break; default: row.MergeOutcome = BatchMergeOutcome.Failed; break; diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index f9932240..e98c1c05 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -529,13 +529,30 @@ public sealed class TaskMergeService public async Task ApproveAndMergeAsync( string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct) { - var (task, list, wt, _) = await LoadMergeContextAsync(taskId, ct); + var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct); if (task.Status != TaskStatus.WaitingForReview) return Blocked("task is not waiting for review"); if (wt is null || wt.State != WorktreeState.Active) { + // There is nothing left to merge -- a sandbox run, or a list-handler task that + // committed straight into the list's working dir. The verify command still has to + // pass before the task may reach Done: skipping it here would exempt exactly the + // runs that land the most on the target branch at once. Same working dir and same + // per-repo gate as the merge path, so a concurrent merge can't land mid-verify. + if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir)) + { + var verifyGate = GetMergeGate(list.WorkingDir!); + await verifyGate.WaitAsync(ct); + try + { + var failed = await RunVerifyGateAsync(verifyCommand, list.WorkingDir!, ct); + if (failed is not null) return failed; + } + finally { verifyGate.Release(); } + } + var done = await _state.ApproveReviewAsync(taskId, ct); return done.Ok ? new MergeResult(StatusMerged, Array.Empty(), null) diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index ecdcc08a..2abefead 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -714,6 +714,46 @@ public class TaskMergeServiceTests : IDisposable Assert.Equal(TaskStatus.Done, updated!.Status); } + // A list-handler task owns no worktree -- it commits straight into the list's working dir -- + // so the approve path short-circuits past the merge. The verify gate must still apply, or the + // run that lands the most on the target branch would be the one run nothing checks. + [Fact] + public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyFails_StaysOutOfDone() + { + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview); + await SeedVerifyCommand(db, list.Id, "dotnet test"); + var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "2 failed") }; + var (svc, _) = BuildService(db, fakeVerify); + + var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status); + Assert.Contains("2 failed", result.ErrorMessage ?? ""); + Assert.Equal("/tmp", fakeVerify.CapturedWorkingDir); + using var ctx = db.CreateContext(); + var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.WaitingForReview, updated!.Status); + } + + [Fact] + public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyPasses_MarksDone() + { + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview); + await SeedVerifyCommand(db, list.Id, "dotnet test"); + var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; + var (svc, _) = BuildService(db, fakeVerify); + + var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + Assert.Equal("dotnet test", fakeVerify.CapturedCommand); + using var ctx = db.CreateContext(); + var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Done, updated!.Status); + } + [Fact] public async Task ApproveAndMergeAsync_NoVerifyCommandConfigured_NeverInvokesRunnerAndMarksDone() {