fix(merge): conventional merge-commit default and live verify progress

The merge commit message was hand-rolled per caller ("Merge task: <title>",
"Merge <branch>", "Merge subtask") and ignored the task's commit type. Every
caller now passes a blank message and TaskMergeService fills in
CommitMessageBuilder.BuildMerge -> {commitType}(list-slug): merge <title> plus the
ClaudeDo-Task trailer; the merge modal prefills it from GetMergeTargets.

A merge whose list has a verify command holds the MergeTask call for minutes (5m46s
on this repo), during which the modal only disabled its button - no spinner, no
message, so a landed merge looked like a dead app. TaskMergeService now broadcasts
MergeProgress(taskId, phase, elapsedSeconds) for the merging and verifying phases
(re-reported every 30s) plus a WorkerLog line when verify starts; the modal shows a
spinner and the localized phase.
This commit is contained in:
mika kuns
2026-08-11 19:14:36 +02:00
parent fc9df7f9ac
commit cad0582b37
22 changed files with 405 additions and 31 deletions
+22 -1
View File
@@ -1,7 +1,8 @@
# Review, merge & conflict resolution # Review, merge & conflict resolution
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. > **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. > 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` > 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. > 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 just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently. `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 <title>` + `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 ## Post-merge verify gate
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification). 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 `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 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 **Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
`list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo, `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 so a verify run can't be interrupted by a second merge landing in the same working dir
+1 -1
View File
@@ -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}" }, "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." }, "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}" }, "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}" }, "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}" }, "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}" }, "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}" },
+1 -1
View File
@@ -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}" }, "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." }, "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}" }, "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}" }, "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}" }, "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}" }, "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}" },
@@ -34,6 +34,11 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string>? PrepLineEvent; event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent; 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>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent; event Action<string, string>? PlanningSubtaskMergedEvent;
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven /// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
+9 -1
View File
@@ -64,6 +64,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<UsageSnapshotDto>? UsageUpdatedEvent; public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
public event Action<string, string, int>? MergeProgressEvent;
public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent; 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))); 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) => _hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
{ {
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(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 BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty); public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount); 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 MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
@@ -21,6 +21,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
[ObservableProperty] private string _commitMessage = ""; [ObservableProperty] private string _commitMessage = "";
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy; [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? _errorMessage;
[ObservableProperty] private string? _warningMessage; [ObservableProperty] private string? _warningMessage;
[ObservableProperty] private string? _successMessage; [ObservableProperty] private string? _successMessage;
@@ -58,6 +64,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
ErrorMessage = Loc.T("vm.merge.workerOfflineBranches"); ErrorMessage = Loc.T("vm.merge.workerOfflineBranches");
return; 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); foreach (var b in targets.LocalBranches) Branches.Add(b);
SelectedBranch = Branches.Contains(targets.DefaultBranch) SelectedBranch = Branches.Contains(targets.DefaultBranch)
? targets.DefaultBranch ? targets.DefaultBranch
@@ -81,6 +91,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
ErrorMessage = null; ErrorMessage = null;
WarningMessage = null; WarningMessage = null;
SuccessMessage = 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 try
{ {
var result = await _worker.MergeTaskAsync( var result = await _worker.MergeTaskAsync(
@@ -133,10 +147,28 @@ public sealed partial class MergeModalViewModel : ViewModelBase
} }
finally finally
{ {
_worker.MergeProgressEvent -= OnMergeProgress;
ProgressMessage = null;
IsBusy = false; 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] [RelayCommand]
private void Cancel() => CloseAction?.Invoke(); private void Cancel() => CloseAction?.Invoke();
} }
@@ -440,8 +440,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
MergeResultDto result; MergeResultDto result;
try try
{ {
result = await mergeFn(row.TaskId, target!, false, // Blank message: the worker builds the conventional default per task.
Loc.T("vm.merge.commitMessage", row.TaskTitle)); result = await mergeFn(row.TaskId, target!, false, "");
} }
catch catch
{ {
@@ -22,6 +22,15 @@
<ctl:ModalShell.Footer> <ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8" <StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"> 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 Classes="btn" Content="{loc:Tr modals.merge.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{loc:Tr modals.merge.merge}" Classes="primary" <Button Content="{loc:Tr modals.merge.merge}" Classes="primary"
Command="{Binding SubmitCommand}" Command="{Binding SubmitCommand}"
+2 -1
View File
@@ -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` - **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. - **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 - **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 - **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 - **AgentFileService** — manages `~/.todo-app/agents/*.md`; list/refresh via SignalR
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs - **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
@@ -168,6 +168,7 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
- `PrepStarted` - `PrepStarted`
- `PrepLine` - `PrepLine`
- `PrepFinished` - `PrepFinished`
- `MergeProgress`
- `PlanningMergeStarted` - `PlanningMergeStarted`
- `PlanningSubtaskMerged` - `PlanningSubtaskMerged`
- `PlanningMergeConflict` - `PlanningMergeConflict`
+2 -2
View File
@@ -997,9 +997,9 @@ public sealed class ExternalMcpService
return new MergeTaskResultDto(false, null, []); 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( 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) if (result.Status == TaskMergeService.StatusMerged)
{ {
@@ -46,6 +46,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) => public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
_hub.Clients.All.SendAsync("WorkerLog", message, level, 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) => public Task PlanningMergeStarted(string planningTaskId, string targetBranch) =>
_hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch); _hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch);
+7 -4
View File
@@ -96,7 +96,8 @@ public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
public record MergePreviewDto( public record MergePreviewDto(
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount,
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null); 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 MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); 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) string taskId, string targetBranch, bool removeWorktree, string commitMessage)
=> HubGuard(async () => => 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( var r = await _mergeService.MergeAsync(
taskId, taskId,
targetBranch ?? "", targetBranch ?? "",
removeWorktree, removeWorktree,
string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage, commitMessage,
CancellationToken.None); CancellationToken.None);
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
}); });
@@ -601,7 +604,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
=> HubGuard(async () => => HubGuard(async () =>
{ {
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None); 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) public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
@@ -615,7 +618,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
=> HubGuard(async () => => HubGuard(async () =>
{ {
var r = await _mergeService.MergeAsync( var r = await _mergeService.MergeAsync(
taskId, targetBranch ?? "", removeWorktree: false, "Merge task", taskId, targetBranch ?? "", removeWorktree: false, commitMessage: "",
leaveConflictsInTree: true, CancellationToken.None); leaveConflictsInTree: true, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked) if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "merge blocked"); throw new HubException(r.ErrorMessage ?? "merge blocked");
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State; using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ModelContextProtocol; using ModelContextProtocol;
@@ -18,7 +19,8 @@ public sealed record MergeResult(
public sealed record MergeTargets( public sealed record MergeTargets(
string DefaultBranch, string DefaultBranch,
IReadOnlyList<string> LocalBranches); IReadOnlyList<string> LocalBranches,
string DefaultCommitMessage);
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for // 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 // 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 StatusReverted = "reverted";
public const string StatusConflictAborted = "conflict_aborted"; 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 PreviewClean = "clean";
public const string PreviewConflict = "conflict"; public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable"; public const string PreviewUnavailable = "unavailable";
@@ -132,7 +138,8 @@ public sealed class TaskMergeService
/// </summary> /// </summary>
private async Task<MergeResult?> RunVerifyGateAsync( private async Task<MergeResult?> RunVerifyGateAsync(
string? verifyCommand, string workingDir, CancellationToken ct, string? verifyCommand, string workingDir, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null) IProgress<ProgressNotificationValue>? progress = null,
Action<TimeSpan>? onTick = null)
{ {
if (string.IsNullOrWhiteSpace(verifyCommand)) return null; if (string.IsNullOrWhiteSpace(verifyCommand)) return null;
@@ -140,7 +147,7 @@ public sealed class TaskMergeService
try try
{ {
result = await RunReportingProgressAsync( 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) catch (Exception ex)
{ {
@@ -160,20 +167,23 @@ public sealed class TaskMergeService
/// <summary> /// <summary>
/// Awaits <paramref name="work"/> while reporting MCP progress every /// Awaits <paramref name="work"/> while reporting MCP progress every
/// <see cref="ProgressReportInterval"/> so a caller waiting on a long verify run doesn't hit /// <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"/> /// the MCP client's own idle-silence abort. <paramref name="onTick"/> rides the same cadence
/// is null (every non-MCP caller, e.g. the Hub). /// for non-MCP callers (the Hub, which turns it into a MergeProgress broadcast). No-op
/// passthrough when both are null.
/// </summary> /// </summary>
private static async Task<T> RunReportingProgressAsync<T>( 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(); var sw = System.Diagnostics.Stopwatch.StartNew();
while (true) while (true)
{ {
var finished = await Task.WhenAny(work, Task.Delay(ProgressReportInterval)); var finished = await Task.WhenAny(work, Task.Delay(ProgressReportInterval));
if (finished == work) return await work; 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)) if (string.IsNullOrWhiteSpace(list.WorkingDir))
return Blocked("list has no working directory"); 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); var gate = GetMergeGate(list.WorkingDir);
await gate.WaitAsync(ct); await gate.WaitAsync(ct);
try try
@@ -402,7 +416,10 @@ public sealed class TaskMergeService
if (collision is not null) return collision; if (collision is not null) return collision;
var oldTargetTip = await _git.RevParseHeadAsync(list.WorkingDir, ct); 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) if (exitCode != 0)
{ {
List<string> files; List<string> files;
@@ -457,7 +474,20 @@ public sealed class TaskMergeService
await MarkWorktreeMergedAsync(taskId, mergeSha, ct); await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, 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) if (verifyFailure is not null)
{ {
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage); _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) 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)) 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 current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
var branches = await _git.ListLocalBranchesAsync(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) public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
=> PreviewAsync(taskId, targetBranch, runVerify: false, ct); => PreviewAsync(taskId, targetBranch, runVerify: false, ct);
@@ -861,10 +897,14 @@ public sealed class TaskMergeService
if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir)) if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir))
{ {
var verifyGate = GetMergeGate(list.WorkingDir!); var verifyGate = GetMergeGate(list.WorkingDir!);
await _broadcaster.MergeProgress(taskId, PhaseVerifying, 0);
await verifyGate.WaitAsync(ct); await verifyGate.WaitAsync(ct);
try 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; if (failed is not null) return failed;
} }
finally { verifyGate.Release(); } finally { verifyGate.Release(); }
@@ -886,7 +926,7 @@ public sealed class TaskMergeService
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge. // MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
// Remove the worktree on approve (matching the unit-merge path) so merged // 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. // 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) => private static MergeResult Blocked(string reason) =>
@@ -227,7 +227,7 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
subtaskId, subtaskId,
state.TargetBranch, state.TargetBranch,
removeWorktree: true, removeWorktree: true,
commitMessage: "Merge subtask", commitMessage: "", // blank -> TaskMergeService builds the conventional default
leaveConflictsInTree: true, leaveConflictsInTree: true,
ct); ct);
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Runner; namespace ClaudeDo.Worker.Runner;
@@ -28,6 +29,23 @@ public static class CommitMessageBuilder
return sb.ToString(); 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) public static string ToSlug(string name)
{ {
var lower = name.ToLowerInvariant(); var lower = name.ToLowerInvariant();
@@ -31,6 +31,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<bool>? PrepFinishedEvent; public event Action<bool>? PrepFinishedEvent;
public event Action<string>? RefineStartedEvent; public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent; 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>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent; 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 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) public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, 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 RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId); public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId);
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId); public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
@@ -129,7 +129,7 @@ public class DetailsIslandPlanningTests : IDisposable
var fake = new FakeWorkerClient 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); var vm = BuildVm(fake);
@@ -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);
}
}
@@ -18,7 +18,7 @@ public class WorktreesOverviewReconcileTickTests
public override Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId) public override Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
=> Task.FromResult(Worktrees.ToList()); => Task.FromResult(Worktrees.ToList());
public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) 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 private sealed class NoopMergeCoordinator : IMergeCoordinator
@@ -65,6 +65,39 @@ public class CommitMessageBuilderTests
Assert.Equal("ClaudeDo-Task: id-456", lines[4]); 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] [Fact]
public void Description_TruncatedTo400() public void Description_TruncatedTo400()
{ {
@@ -283,6 +283,80 @@ public class TaskMergeServiceTests : IDisposable
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated"); 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] [Fact]
public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches() public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches()
{ {
@@ -113,6 +113,7 @@ sealed class FakeWorkerClient : IWorkerClient
public event Action<bool>? PrepFinishedEvent; public event Action<bool>? PrepFinishedEvent;
public event Action<string>? RefineStartedEvent; public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent; 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>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent; public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;