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` 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? PrepLineEvent;
event Action? PrepFinishedEvent;
+ /// (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.
+ event Action? MergeProgressEvent;
+
event Action? PlanningMergeStartedEvent;
event Action? PlanningSubtaskMergedEvent;
/// (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? UsageUpdatedEvent;
+ public event Action? MergeProgressEvent;
+
public event Action? PlanningMergeStartedEvent;
public event Action? PlanningSubtaskMergedEvent;
public event Action, bool>? PlanningMergeConflictEvent;
@@ -172,6 +174,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc)));
});
+ _hub.On("MergeProgress", (taskId, phase, elapsedSeconds) =>
+ {
+ Dispatcher.UIThread.Post(() => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds));
+ });
+
_hub.On("PlanningMergeStarted", (planningTaskId, targetBranch) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
@@ -694,7 +701,8 @@ public record MergeResultDto(string Status, IReadOnlyList ConflictFiles,
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
public record MergePreviewDto(string Status, IReadOnlyList ConflictFiles, int ChangedFileCount);
-public record MergeTargetsDto(string DefaultBranch, IReadOnlyList LocalBranches);
+public record MergeTargetsDto(
+ string DefaultBranch, IReadOnlyList LocalBranches, string DefaultCommitMessage);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList 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 @@
+
+
+
+
+
private async Task RunVerifyGateAsync(
string? verifyCommand, string workingDir, CancellationToken ct,
- IProgress? progress = null)
+ IProgress? progress = null,
+ Action? 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
///
/// Awaits while reporting MCP progress every
/// so a caller waiting on a long verify run doesn't hit
- /// the MCP client's own idle-silence abort. No-op passthrough when
- /// is null (every non-MCP caller, e.g. the Hub).
+ /// the MCP client's own idle-silence abort. rides the same cadence
+ /// for non-MCP callers (the Hub, which turns it into a MergeProgress broadcast). No-op
+ /// passthrough when both are null.
///
private static async Task RunReportingProgressAsync(
- Task work, IProgress? progress, string message)
+ Task work, IProgress? progress, string message,
+ Action? 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 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 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());
+ return new MergeTargets("", Array.Empty(), 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 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();
}
+ ///
+ /// Message for the merge commit that lands a task branch. Same Conventional-Commits header
+ /// shape as the task's own commits () with an explicit `merge ` verb, so
+ /// merge commits stay parseable *and* greppable, plus the task-id trailer. A blank commit
+ /// type falls back to ; a list name that slugs
+ /// to nothing drops the scope rather than emitting an empty `()`.
+ ///
+ 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? PrepFinishedEvent;
public event Action? RefineStartedEvent;
public event Action? RefineFinishedEvent;
+ public event Action? MergeProgressEvent;
public event Action? PlanningMergeStartedEvent;
public event Action? PlanningSubtaskMergedEvent;
public event Action, bool>? PlanningMergeConflictEvent;
@@ -55,6 +56,7 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList 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;
+
+///
+/// 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.
+///
+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 MergeGate { get; } = new();
+ public bool BlockMerge { get; set; }
+ public string? CapturedCommitMessage { get; private set; }
+
+ public override Task GetMergeTargetsAsync(string taskId) =>
+ Task.FromResult(Targets);
+
+ public override Task MergeTaskAsync(
+ string taskId, string targetBranch, bool removeWorktree, string commitMessage)
+ {
+ CapturedCommitMessage = commitMessage;
+ return BlockMerge
+ ? MergeGate.Task
+ : Task.FromResult(new MergeResultDto("merged", new List(), 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(), 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(), 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> GetWorktreesOverviewAsync(string? listId)
=> Task.FromResult(Worktrees.ToList());
public override Task GetMergeTargetsAsync(string taskId)
- => Task.FromResult(new MergeTargetsDto("main", new[] { "main" }));
+ => Task.FromResult(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? PrepFinishedEvent;
public event Action? RefineStartedEvent;
public event Action? RefineFinishedEvent;
+ public event Action? MergeProgressEvent;
public event Action? PlanningMergeStartedEvent;
public event Action? PlanningSubtaskMergedEvent;
public event Action, bool>? PlanningMergeConflictEvent;