diff --git a/docs/explore-notes/review-merge.md b/docs/explore-notes/review-merge.md index df7994e3..d5287389 100644 --- a/docs/explore-notes/review-merge.md +++ b/docs/explore-notes/review-merge.md @@ -1,7 +1,8 @@ # Review, merge & conflict resolution > **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. -> Last verified against `6a2a19c` (2026-08-10), which added the preview-time verify build. +> Last verified against `fc9df7f` (2026-08-11), which added the conventional merge-commit default +> and the `MergeProgress` broadcast. > Planning mode renders per file and `DiffLinesView` is retired. > Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External` > Stable structure only (no line numbers). See docs/explore-notes/README.md. @@ -82,6 +83,17 @@ gating `CanMergeAll`. The worker-side guard remains the actual correctness fix; just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises `ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently. +## Merge commit message + +`MergeAsync` takes a `commitMessage`, but **every caller passes it blank** and lets +`TaskMergeService.DefaultMergeMessage` build `CommitMessageBuilder.BuildMerge(task.CommitType, +list.Name, task.Title, task.Id)` → `fix(my-list): merge ` + `ClaudeDo-Task:` trailer. Same +`type(scope)` shape as the task's own worktree commits, so merges stay Conventional-Commits-clean. +The only non-blank caller is the user editing the field in the merge modal, which is prefilled from +`GetMergeTargets().DefaultCommitMessage` (the UI can't build it — it knows neither the commit type +nor the list name). Callers used to hand-roll `"Merge task: {title}"` / `"Merge {branch}"` / +`"Merge subtask"`; don't add a fourth. + ## Post-merge verify gate A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification). @@ -110,6 +122,15 @@ but `ApproveAndMergeAsync` still runs the verify command (same per-repo gate, wo `list.WorkingDir`) before the task may reach `Done`. Without that, the run that lands the most on the target branch at once would be the one run nothing checks. +**The gate is what makes a merge look broken.** A verify command that builds and tests a solution +occupies the whole `MergeTask` hub call — measured at ~5m 46s on this repo. `TaskMergeService` +therefore broadcasts `MergeProgress(taskId, phase, elapsedSeconds)`: `PhaseMerging` before the +per-repo gate wait, `PhaseVerifying` when the verify starts and again on every +`ProgressReportInterval` tick (30 s, shared with the MCP progress reports), plus one +`WorkerLog` Info line so the footer strip shows it too. The merge modal renders that as a spinner +plus phase text; without it the only feedback was the disabled Submit button, which reads as a +dead app for minutes while the merge has in fact already landed. + **Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by `list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo, so a verify run can't be interrupted by a second merge landing in the same working dir diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index c12f1075..fa49eb11 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -673,7 +673,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}", "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}" }, "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}", "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.", "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.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "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 50d8b47b..ad9cdd65 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -673,7 +673,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}", "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}" }, "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}", "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.", "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.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "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/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index a79cfa27..03afb38b 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -34,6 +34,11 @@ public interface IWorkerClient : INotifyPropertyChanged event Action<string>? PrepLineEvent; event Action<bool>? PrepFinishedEvent; + /// <summary>(taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge + /// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the + /// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s.</summary> + event Action<string, string, int>? MergeProgressEvent; + event Action<string, string>? PlanningMergeStartedEvent; event Action<string, string>? PlanningSubtaskMergedEvent; /// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index e7afa6da..e512b5dc 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -64,6 +64,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action<UsageSnapshotDto>? UsageUpdatedEvent; + public event Action<string, string, int>? MergeProgressEvent; + public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent; @@ -172,6 +174,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc))); }); + _hub.On<string, string, int>("MergeProgress", (taskId, phase, elapsedSeconds) => + { + Dispatcher.UIThread.Post(() => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds)); + }); + _hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) => { Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch)); @@ -694,7 +701,8 @@ public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount); public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty); public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount); -public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches); +public record MergeTargetsDto( + string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs index a81b5789..5af137ef 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs @@ -21,6 +21,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase [ObservableProperty] private string _commitMessage = ""; [ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy; + + /// What the pending merge is doing right now, fed by the worker's MergeProgress broadcast. + /// A merge whose list has a verify command can occupy this call for minutes — without this the + /// modal only greys the button out and looks dead. + [ObservableProperty] private string? _progressMessage; + [ObservableProperty] private string? _errorMessage; [ObservableProperty] private string? _warningMessage; [ObservableProperty] private string? _successMessage; @@ -58,6 +64,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase ErrorMessage = Loc.T("vm.merge.workerOfflineBranches"); return; } + // The worker owns the default message — only it knows the task's commit type and the + // list name the scope is slugged from. The locale string stays as the offline fallback. + if (!string.IsNullOrWhiteSpace(targets.DefaultCommitMessage)) + CommitMessage = targets.DefaultCommitMessage; foreach (var b in targets.LocalBranches) Branches.Add(b); SelectedBranch = Branches.Contains(targets.DefaultBranch) ? targets.DefaultBranch @@ -81,6 +91,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase ErrorMessage = null; WarningMessage = null; SuccessMessage = null; + // 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"); + _worker.MergeProgressEvent += OnMergeProgress; try { var result = await _worker.MergeTaskAsync( @@ -133,10 +147,28 @@ public sealed partial class MergeModalViewModel : ViewModelBase } finally { + _worker.MergeProgressEvent -= OnMergeProgress; + ProgressMessage = null; IsBusy = false; } } + private void OnMergeProgress(string taskId, string phase, int elapsedSeconds) + { + if (taskId != TaskId) return; + ProgressMessage = phase switch + { + MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)), + _ => Loc.T("vm.merge.progressMerging"), + }; + } + + /// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string. + private const string MergePhaseVerifying = "verifying"; + + private static string FormatElapsed(int seconds) => + TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss"); + [RelayCommand] private void Cancel() => CloseAction?.Invoke(); } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index 672a6edf..9d949859 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -440,8 +440,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase MergeResultDto result; try { - result = await mergeFn(row.TaskId, target!, false, - Loc.T("vm.merge.commitMessage", row.TaskTitle)); + // Blank message: the worker builds the conventional default per task. + result = await mergeFn(row.TaskId, target!, false, ""); } catch { diff --git a/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml index 76207855..74ed61d6 100644 --- a/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml @@ -22,6 +22,15 @@ <ctl:ModalShell.Footer> <StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" VerticalAlignment="Center"> + <!-- A merge into a list with a verify command holds this dialog for minutes; without a + live phase here the disabled button is the only feedback and reads as a dead app. --> + <StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center" + IsVisible="{Binding IsBusy}"> + <Ellipse Classes="spinner" VerticalAlignment="Center"/> + <TextBlock Classes="meta" VerticalAlignment="Center" + Text="{Binding ProgressMessage}" + IsVisible="{Binding ProgressMessage, Converter={x:Static ObjectConverters.IsNotNull}}"/> + </StackPanel> <Button Classes="btn" Content="{loc:Tr modals.merge.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/> <Button Content="{loc:Tr modals.merge.merge}" Classes="primary" Command="{Binding SubmitCommand}" diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 0d4fdd1e..5c3e6f06 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -120,7 +120,7 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low- - **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume` - **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser. - **WorktreeManager** — worktrees on `claudedo/{taskId[:8]}` branches; commits with semantic messages, updates DB with head commit + diff stats -- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId` +- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal. - **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history - **AgentFileService** — manages `~/.todo-app/agents/*.md`; list/refresh via SignalR - **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs @@ -168,6 +168,7 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag - `PrepStarted` - `PrepLine` - `PrepFinished` +- `MergeProgress` - `PlanningMergeStarted` - `PlanningSubtaskMerged` - `PlanningMergeConflict` diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 1f6a3d97..0e06797c 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -997,9 +997,9 @@ public sealed class ExternalMcpService return new MergeTaskResultDto(false, null, []); } - var commitMessage = $"Merge task branch for: {task.Title}"; + // Blank on purpose: TaskMergeService builds the conventional default message. var result = await _merge.MergeAsync( - taskId, targetBranch, removeWorktree: false, commitMessage, leaveConflictsInTree, cancellationToken, progress); + taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress); if (result.Status == TaskMergeService.StatusMerged) { diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index fe90bbf6..1084ba87 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -46,6 +46,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) => _hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc); + // Phase of an in-flight single-task merge (see TaskMergeService.Phase*), so a client waiting + // on the MergeTask call can show what it is waiting for instead of a frozen button. + public Task MergeProgress(string taskId, string phase, int elapsedSeconds) => + _hub.Clients.All.SendAsync("MergeProgress", taskId, phase, elapsedSeconds); + public Task PlanningMergeStarted(string planningTaskId, string targetBranch) => _hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 318abfd5..e0af7013 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -96,7 +96,8 @@ public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty); public record MergePreviewDto( string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null); -public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches); +public record MergeTargetsDto( + string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); @@ -588,11 +589,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub string taskId, string targetBranch, bool removeWorktree, string commitMessage) => HubGuard(async () => { + // A blank message is handed through deliberately — TaskMergeService builds the + // conventional default from the task's commit type and its list's name. var r = await _mergeService.MergeAsync( taskId, targetBranch ?? "", removeWorktree, - string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage, + commitMessage, CancellationToken.None); return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); }); @@ -601,7 +604,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); + return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage); }); public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch) @@ -615,7 +618,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub => HubGuard(async () => { var r = await _mergeService.MergeAsync( - taskId, targetBranch ?? "", removeWorktree: false, "Merge task", + taskId, targetBranch ?? "", removeWorktree: false, commitMessage: "", leaveConflictsInTree: true, CancellationToken.None); if (r.Status == TaskMergeService.StatusBlocked) throw new HubException(r.ErrorMessage ?? "merge blocked"); diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 2f530602..689964c9 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -4,6 +4,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.State; using Microsoft.EntityFrameworkCore; using ModelContextProtocol; @@ -18,7 +19,8 @@ public sealed record MergeResult( public sealed record MergeTargets( string DefaultBranch, - IReadOnlyList<string> LocalBranches); + IReadOnlyList<string> LocalBranches, + string DefaultCommitMessage); // 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 @@ -59,6 +61,10 @@ public sealed class TaskMergeService public const string StatusReverted = "reverted"; public const string StatusConflictAborted = "conflict_aborted"; + // Phase tokens for the MergeProgress broadcast — stable identifiers, localized by the UI. + public const string PhaseMerging = "merging"; + public const string PhaseVerifying = "verifying"; + public const string PreviewClean = "clean"; public const string PreviewConflict = "conflict"; public const string PreviewUnavailable = "unavailable"; @@ -132,7 +138,8 @@ public sealed class TaskMergeService /// </summary> private async Task<MergeResult?> RunVerifyGateAsync( string? verifyCommand, string workingDir, CancellationToken ct, - IProgress<ProgressNotificationValue>? progress = null) + IProgress<ProgressNotificationValue>? progress = null, + Action<TimeSpan>? onTick = null) { if (string.IsNullOrWhiteSpace(verifyCommand)) return null; @@ -140,7 +147,7 @@ public sealed class TaskMergeService try { result = await RunReportingProgressAsync( - _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), progress, "verify gate running"); + _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), progress, "verify gate running", onTick); } catch (Exception ex) { @@ -160,20 +167,23 @@ public sealed class TaskMergeService /// <summary> /// Awaits <paramref name="work"/> while reporting MCP progress every /// <see cref="ProgressReportInterval"/> so a caller waiting on a long verify run doesn't hit - /// the MCP client's own idle-silence abort. No-op passthrough when <paramref name="progress"/> - /// is null (every non-MCP caller, e.g. the Hub). + /// the MCP client's own idle-silence abort. <paramref name="onTick"/> rides the same cadence + /// for non-MCP callers (the Hub, which turns it into a MergeProgress broadcast). No-op + /// passthrough when both are null. /// </summary> private static async Task<T> RunReportingProgressAsync<T>( - Task<T> work, IProgress<ProgressNotificationValue>? progress, string message) + Task<T> work, IProgress<ProgressNotificationValue>? progress, string message, + Action<TimeSpan>? onTick = null) { - if (progress is null) return await work; + if (progress is null && onTick is null) return await work; var sw = System.Diagnostics.Stopwatch.StartNew(); while (true) { var finished = await Task.WhenAny(work, Task.Delay(ProgressReportInterval)); if (finished == work) return await work; - progress.Report(new ProgressNotificationValue { Progress = 0, Message = $"{message} ({sw.Elapsed:mm\\:ss})" }); + progress?.Report(new ProgressNotificationValue { Progress = 0, Message = $"{message} ({sw.Elapsed:mm\\:ss})" }); + onTick?.Invoke(sw.Elapsed); } } @@ -380,6 +390,10 @@ public sealed class TaskMergeService if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); + // Announced before the gate wait: another merge holding the repo is itself a reason the + // caller sees nothing happen, and a UI waiting on this call needs a phase to show at once. + await _broadcaster.MergeProgress(taskId, PhaseMerging, 0); + var gate = GetMergeGate(list.WorkingDir); await gate.WaitAsync(ct); try @@ -402,7 +416,10 @@ public sealed class TaskMergeService if (collision is not null) return collision; var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct); - var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct); + var message = string.IsNullOrWhiteSpace(commitMessage) + ? DefaultMergeMessage(task, list) + : commitMessage; + var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct); if (exitCode != 0) { List<string> files; @@ -457,7 +474,20 @@ public sealed class TaskMergeService await MarkWorktreeMergedAsync(taskId, mergeSha, ct); await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct); - var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct, progress); + // The merge itself is instant; the verify gate is what makes this call take minutes. + // Tell every client (the waiting modal and the footer log strip) that it started — + // silence here is what makes a working merge look like a dead button. + if (!string.IsNullOrWhiteSpace(verifyCommand)) + { + await _broadcaster.MergeProgress(taskId, PhaseVerifying, 0); + await _broadcaster.WorkerLog( + $"Verify command running after merging #{task.Number} \"{task.Title}\" into {targetBranch}", + WorkerLogLevel.Info, DateTime.UtcNow); + } + + var verifyFailure = await RunVerifyGateAsync( + verifyCommand, list.WorkingDir, ct, progress, + elapsed => _ = _broadcaster.MergeProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds)); if (verifyFailure is not null) { _logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage); @@ -732,16 +762,22 @@ public sealed class TaskMergeService public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct) { - var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct); + var (task, list, _, _) = await LoadMergeContextAsync(taskId, ct); + var defaultMessage = DefaultMergeMessage(task, list); if (string.IsNullOrWhiteSpace(list.WorkingDir)) - return new MergeTargets("", Array.Empty<string>()); + return new MergeTargets("", Array.Empty<string>(), defaultMessage); var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct); var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct); - return new MergeTargets(current, branches); + return new MergeTargets(current, branches, defaultMessage); } + /// The commit message a merge uses when the caller passes none. Built here rather than in the + /// UI/MCP callers because only this layer knows the task's commit type and its list's name. + private static string DefaultMergeMessage(TaskEntity task, ListEntity list) => + CommitMessageBuilder.BuildMerge(task.CommitType, list.Name, task.Title, task.Id); + public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct) => PreviewAsync(taskId, targetBranch, runVerify: false, ct); @@ -861,10 +897,14 @@ public sealed class TaskMergeService if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir)) { var verifyGate = GetMergeGate(list.WorkingDir!); + await _broadcaster.MergeProgress(taskId, PhaseVerifying, 0); await verifyGate.WaitAsync(ct); try { - var failed = await RunVerifyGateAsync(verifyCommand, list.WorkingDir!, ct, progress); + // Same reason as the post-merge gate: this holds the approve call for minutes. + var failed = await RunVerifyGateAsync( + verifyCommand, list.WorkingDir!, ct, progress, + elapsed => _ = _broadcaster.MergeProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds)); if (failed is not null) return failed; } finally { verifyGate.Release(); } @@ -886,7 +926,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, $"Merge {wt.BranchName}", leaveConflictsInTree, ct, progress); + return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress); } private static MergeResult Blocked(string reason) => diff --git a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs index dbafc844..fd0963ab 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs @@ -227,7 +227,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState subtaskId, state.TargetBranch, removeWorktree: true, - commitMessage: "Merge subtask", + commitMessage: "", // blank -> TaskMergeService builds the conventional default leaveConflictsInTree: true, ct); diff --git a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs index ca5318af..9e33cd5a 100644 --- a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.RegularExpressions; +using ClaudeDo.Data.Models; namespace ClaudeDo.Worker.Runner; @@ -28,6 +29,23 @@ public static class CommitMessageBuilder return sb.ToString(); } + /// <summary> + /// Message for the merge commit that lands a task branch. Same Conventional-Commits header + /// shape as the task's own commits (<see cref="Build"/>) with an explicit `merge ` verb, so + /// merge commits stay parseable *and* greppable, plus the task-id trailer. A blank commit + /// type falls back to <see cref="CommitTypeRegistry.DefaultType"/>; a list name that slugs + /// to nothing drops the scope rather than emitting an empty `()`. + /// </summary> + public static string BuildMerge(string commitType, string listName, string taskTitle, string taskId) + { + var type = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType.Trim(); + var slug = ToSlug(listName); + var scope = slug.Length == 0 ? "" : $"({slug})"; + var title = Truncate(taskTitle.Trim(), 60); + + return $"{type}{scope}: merge {title}\n\nClaudeDo-Task: {taskId}"; + } + public static string ToSlug(string name) { var lower = name.ToLowerInvariant(); diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 4ee0b9a5..35ae5a30 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -31,6 +31,7 @@ public abstract class StubWorkerClient : IWorkerClient public event Action<bool>? PrepFinishedEvent; public event Action<string>? RefineStartedEvent; public event Action<string, bool, string?>? RefineFinishedEvent; + public event Action<string, string, int>? MergeProgressEvent; public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent; @@ -55,6 +56,7 @@ public abstract class StubWorkerClient : IWorkerClient public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase); public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven) => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven); + public void RaiseMergeProgress(string taskId, string phase, int elapsedSeconds) => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds); public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch); public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId); public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs index be5999a2..f95542f1 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs @@ -129,7 +129,7 @@ public class DetailsIslandPlanningTests : IDisposable var fake = new FakeWorkerClient { - MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" }), + MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" }, "chore(list): merge t"), }; var vm = BuildVm(fake); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MergeModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeModalViewModelTests.cs new file mode 100644 index 00000000..04450bd8 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MergeModalViewModelTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +/// <summary> +/// Covers the two things that made a working merge look broken: the prefilled commit message +/// (must be the worker's conventional default, not a hand-rolled "Merge task: …") and the +/// progress feedback while the post-merge verify gate holds the call open for minutes. +/// </summary> +public class MergeModalViewModelTests +{ + private sealed class Worker : StubWorkerClient + { + public MergeTargetsDto? Targets { get; set; } = + new("main", new[] { "main", "dev" }, "fix(my-list): merge do the thing"); + + public TaskCompletionSource<MergeResultDto> MergeGate { get; } = new(); + public bool BlockMerge { get; set; } + public string? CapturedCommitMessage { get; private set; } + + public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => + Task.FromResult(Targets); + + public override Task<MergeResultDto> MergeTaskAsync( + string taskId, string targetBranch, bool removeWorktree, string commitMessage) + { + CapturedCommitMessage = commitMessage; + return BlockMerge + ? MergeGate.Task + : Task.FromResult(new MergeResultDto("merged", new List<string>(), null)); + } + } + + private static (MergeModalViewModel vm, Worker worker) Build() + { + var worker = new Worker(); + return (new MergeModalViewModel(worker, new MergeCoordinator()), worker); + } + + [Fact] + public async Task Initialize_prefills_the_workers_default_commit_message() + { + var (vm, _) = Build(); + + await vm.InitializeAsync("task-1", "do the thing"); + + Assert.Equal("fix(my-list): merge do the thing", vm.CommitMessage); + Assert.Equal("main", vm.SelectedBranch); + } + + [Fact] + public async Task Initialize_keeps_local_fallback_when_worker_sends_no_default() + { + var (vm, worker) = Build(); + worker.Targets = new MergeTargetsDto("main", new[] { "main" }, ""); + + await vm.InitializeAsync("task-1", "do the thing"); + + Assert.False(string.IsNullOrWhiteSpace(vm.CommitMessage)); + Assert.NotEqual("", vm.CommitMessage); + } + + [Fact] + public async Task Submit_shows_a_phase_while_the_merge_is_pending_and_clears_it_after() + { + var (vm, worker) = Build(); + await vm.InitializeAsync("task-1", "do the thing"); + worker.BlockMerge = true; + + var submit = vm.SubmitCommand.ExecuteAsync(null); + + Assert.True(vm.IsBusy); + var merging = vm.ProgressMessage; + Assert.False(string.IsNullOrWhiteSpace(merging)); + + // The verify gate starts — a distinct phase, so the user sees why this is taking minutes. + // (The elapsed time rides in a format argument, which the test localizer drops.) + worker.RaiseMergeProgress("task-1", "verifying", 90); + Assert.NotEqual(merging, vm.ProgressMessage); + + worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null)); + await submit; + + Assert.Null(vm.ProgressMessage); + Assert.False(vm.IsBusy); + Assert.True(vm.Merged); + } + + [Fact] + public async Task Progress_for_another_task_is_ignored() + { + var (vm, worker) = Build(); + await vm.InitializeAsync("task-1", "do the thing"); + worker.BlockMerge = true; + + var submit = vm.SubmitCommand.ExecuteAsync(null); + var merging = vm.ProgressMessage; + + worker.RaiseMergeProgress("some-other-task", "verifying", 42); + + Assert.Equal(merging, vm.ProgressMessage); + + worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null)); + await submit; + } + + [Fact] + public async Task Submit_unsubscribes_so_a_later_broadcast_cannot_revive_the_progress_line() + { + var (vm, worker) = Build(); + await vm.InitializeAsync("task-1", "do the thing"); + + await vm.SubmitCommand.ExecuteAsync(null); + worker.RaiseMergeProgress("task-1", "verifying", 10); + + Assert.Null(vm.ProgressMessage); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewReconcileTickTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewReconcileTickTests.cs index 9a94a789..37e33ce4 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewReconcileTickTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewReconcileTickTests.cs @@ -18,7 +18,7 @@ public class WorktreesOverviewReconcileTickTests public override Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId) => Task.FromResult(Worktrees.ToList()); public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) - => Task.FromResult<MergeTargetsDto?>(new MergeTargetsDto("main", new[] { "main" })); + => Task.FromResult<MergeTargetsDto?>(new MergeTargetsDto("main", new[] { "main" }, "chore(list): merge t")); } private sealed class NoopMergeCoordinator : IMergeCoordinator diff --git a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs index 8fabb70c..0e4b6529 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs @@ -65,6 +65,39 @@ public class CommitMessageBuilderTests Assert.Equal("ClaudeDo-Task: id-456", lines[4]); } + [Fact] + public void BuildMerge_ConventionalHeaderWithMergeVerbAndTrailer() + { + var msg = CommitMessageBuilder.BuildMerge("fix", "Lager App", "stop the crash", "id-789"); + var lines = msg.Split('\n'); + + Assert.Equal("fix(lager-app): merge stop the crash", lines[0]); + Assert.Equal("", lines[1]); + Assert.Equal("ClaudeDo-Task: id-789", lines[2]); + } + + [Fact] + public void BuildMerge_BlankCommitType_FallsBackToDefault() + { + var msg = CommitMessageBuilder.BuildMerge(" ", "My List", "do it", "id"); + Assert.StartsWith("chore(my-list): merge do it", msg); + } + + [Fact] + public void BuildMerge_ListNameWithoutSluggableChars_DropsScope() + { + var msg = CommitMessageBuilder.BuildMerge("feat", "!!!", "do it", "id"); + Assert.StartsWith("feat: merge do it", msg); + } + + [Fact] + public void BuildMerge_TitleTruncatedTo60() + { + var msg = CommitMessageBuilder.BuildMerge("feat", "My List", new string('x', 80), "id"); + var titlePart = msg.Split('\n')[0].Split(": merge ", 2)[1]; + Assert.Equal(60, titlePart.Length); + } + [Fact] public void Description_TruncatedTo400() { diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index e1c4ce3e..42f94d33 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -283,6 +283,80 @@ public class TaskMergeServiceTests : IDisposable Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated"); } + [Fact] + public async Task MergeAsync_BlankCommitMessage_UsesConventionalDefault() + { + 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)); + File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "feat\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, _) = BuildService(db); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false, + commitMessage: "", ct: CancellationToken.None); + + Assert.Equal("merged", result.Status); + var subject = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%s").Trim(); + // task.CommitType (not the list default) — same type the task's own commits used. + Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", subject); + var body = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%b"); + Assert.Contains($"ClaudeDo-Task: {task.Id}", body); + } + + [Fact] + public async Task MergeAsync_VerifyGate_BroadcastsMergingAndVerifyingPhases() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + var repo = NewRepo(); + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + await SeedVerifyCommand(db, list.Id, "echo ok"); + + var wtMgr = BuildWorktreeManager(db); + var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None); + _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath)); + File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "feat\n"); + await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None); + + var (svc, proxy) = BuildService(db, new FakeVerifyCommandRunner()); + var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir); + + var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false, + commitMessage: "", ct: CancellationToken.None); + + Assert.Equal("merged", result.Status); + Assert.Contains(proxy.Calls, c => c.Method == "MergeProgress" + && (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseMerging); + Assert.Contains(proxy.Calls, c => c.Method == "MergeProgress" + && (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseVerifying); + Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog" + && c.Args[0] is string s && s.Contains("Verify command running")); + } + + [Fact] + public async Task GetTargetsAsync_ReturnsDefaultCommitMessage() + { + if (!GitRepoFixture.IsGitAvailable()) return; + var repo = NewRepo(); + var db = NewDb(); + var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + + var (svc, _) = BuildService(db); + var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None); + + Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", targets.DefaultCommitMessage.Split('\n')[0]); + } + [Fact] public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches() { diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 0d94b351..cb207b5a 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -113,6 +113,7 @@ sealed class FakeWorkerClient : IWorkerClient public event Action<bool>? PrepFinishedEvent; public event Action<string>? RefineStartedEvent; public event Action<string, bool, string?>? RefineFinishedEvent; + public event Action<string, string, int>? MergeProgressEvent; public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;