9.8 KiB
Worktree merge into target branch — design
Date: 2026-04-22 Status: Approved (pending user review)
Problem
WorktreeState.Merged exists but nothing sets it. GitService.MergeFfOnlyAsync exists but is unused. DetailsIslandViewModel.ApproveMergeAsync is a stub (// TODO: call worker merge hub method when available). Users have no way to merge a task's worktree back into a target branch; the only post-task options today are Discard (via Reset) or leave it Active.
Goals
- Allow merging a task worktree's
claudedo/{id}branch into a chosen local branch of the list'sWorkingDir. - Preserve merge history via a real merge commit.
- Never leave the target branch in a broken state.
- Reuse existing patterns:
TaskResetService, maintenance sweeper, dialog factory.
Non-goals
- Remote push after merge (user does this manually).
- Pull/fetch before merge.
- Rebasing the task branch onto a moved target (done via Continue prompt or manually).
- Merging across repos or handling submodules.
- Automated UI tests (project has none).
Decisions
| Decision | Choice |
|---|---|
| Target branch | Default to current HEAD branch of WorkingDir; user may override via dropdown. |
| Merge strategy | Always git merge --no-ff -m <msg> claudedo/{id} — explicit merge commit. |
| Post-merge cleanup | Per-merge checkbox in the dialog, default on: remove worktree dir + delete branch. |
| Conflicts | Pre-flight guard (worktree/branch state checks); on conflict during merge, git merge --abort and return conflicted files to UI. |
| UI entry points | Details island agent strip (wires existing stub) and DiffModal "Merge" button — both open the same modal. |
Architecture
New backend service
src/ClaudeDo.Worker/Services/TaskMergeService.cs — mirrors TaskResetService.
public sealed class TaskMergeService
{
Task<MergeResult> MergeAsync(
string taskId,
string targetBranch,
bool removeWorktree,
string commitMessage,
CancellationToken ct);
Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct);
}
public sealed record MergeResult(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
// Status ∈ "merged" | "conflict" | "blocked"
public sealed record MergeTargets(string DefaultBranch, IReadOnlyList<string> LocalBranches);
Pre-flight checks (all must pass):
- Task exists, status not
Running. - Worktree exists, state ==
Active. list.WorkingDiris set and is a git repo.- Target working tree is clean (
HasChangesAsync == false). - Target repo is not mid-merge (
IsMidMergeAsync == false).
Failures short-circuit to MergeResult("blocked", [], reason) before any git write.
Success path:
GitService.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct).- If
removeWorktree:WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct)— reuse existing method.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct).
WorktreeRepository.SetStateAsync(taskId, WorktreeState.Merged, ct).HubBroadcaster.BroadcastWorktreeUpdated(taskId).- Log info; return
MergeResult("merged", [], null).
Conflict path (merge invoked, git returns non-zero with CONFLICT on stderr/stdout):
- Collect conflicted files:
git diff --name-only --diff-filter=U. GitService.MergeAbortAsync(list.WorkingDir, ct).- Worktree state stays
Active; no broadcast (nothing changed). - Return
MergeResult("conflict", files, null).
GitService additions
Task<string> GetCurrentBranchAsync(string repoDir, CancellationToken ct); // git symbolic-ref --short HEAD
Task<List<string>> ListLocalBranchesAsync(string repoDir, CancellationToken ct); // git branch --format=%(refname:short)
Task MergeNoFfAsync(string repoDir, string sourceBranch, string message, CancellationToken ct); // git merge --no-ff -m <msg> <src>
Task MergeAbortAsync(string repoDir, CancellationToken ct); // git merge --abort
Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct); // File.Exists($"{gitDir}/MERGE_HEAD")
Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct); // git diff --name-only --diff-filter=U
MergeNoFfAsync must NOT throw on non-zero — it must return the exit code/stderr so the caller can distinguish conflict from other failures. Two ways:
- Overload to return
(int ExitCode, string Stderr); or - Throw a dedicated
GitMergeConflictExceptionvsInvalidOperationException.
Pick: expose a tuple-returning variant for MergeNoFfAsync only — keeps other methods consistent, avoids exception-for-control-flow.
Hub surface
src/ClaudeDo.Worker/Hub/WorkerHub.cs gains:
Task<MergeResultDto> MergeTask(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task<MergeTargetsDto> GetMergeTargets(string taskId);
DTOs mirror the service records. Unexpected exceptions are re-wrapped as HubException (same pattern as ResetTask). Expected conditions (blocked, conflict) travel via the result DTO, not exceptions.
WorkerClient
src/ClaudeDo.Ui/Services/WorkerClient.cs:
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task<MergeTargetsDto> GetMergeTargetsAsync(string taskId);
UI
New modal: src/ClaudeDo.Ui/Views/Modals/MergeModalView.axaml + MergeModalViewModel.cs.
Dialog fields:
- Target branch combobox (source:
GetMergeTargetsAsync.LocalBranches; default:DefaultBranch). - Remove worktree after merge checkbox (default: checked).
- Commit message text field (default:
Merge task: {Task.Title}). - OK / Cancel buttons.
Post-submit UI states (rendered inside the modal, not a second dialog):
merged→ brief success line, modal closes after 1–2s; parent refreshes.conflict→ red inline panel listing files; OK button hidden, only Close remains.blocked→ orange inline panel with the reason; OK button hidden, only Close remains.
Wiring:
DetailsIslandViewModel.ApproveMergeAsyncopensMergeModalView(factory injected throughMainWindowViewModel's existing dialog pattern).DiffModalViewgains a Merge button in its command strip; click opens the same modal with the current task's id.- Both entry points are only visible/enabled when
Task.Worktree?.State == Active(same predicate as the existing Reset/Continue visibility logic — extendShowFailedActions-style gating with a new flagCanMerge).
MergeModalViewModel depends only on WorkerClient. It does not touch GitService directly — all git access stays worker-side.
Data flow
User clicks Merge (Details island or DiffModal)
→ DetailsIslandViewModel / DiffModalViewModel opens MergeModalView
→ MergeModalViewModel.InitializeAsync
→ WorkerClient.GetMergeTargetsAsync(taskId)
→ Hub.GetMergeTargets
→ TaskMergeService.GetTargetsAsync
→ GitService.GetCurrentBranchAsync + ListLocalBranchesAsync
→ Combobox populated, default selected
User edits fields, clicks OK
→ WorkerClient.MergeTaskAsync(taskId, branch, remove, msg)
→ Hub.MergeTask
→ TaskMergeService.MergeAsync
→ pre-flight checks
→ GitService.MergeNoFfAsync → (success | conflict)
→ on success: optional remove + branch delete, SetState(Merged), broadcast
→ on conflict: MergeAbortAsync, return conflict DTO
→ MergeModalViewModel renders result
Error handling
| Case | Surfaced as |
|---|---|
| Task running | MergeResult("blocked", [], "task is running") |
| Worktree not Active | ("blocked", [], "worktree state is {state}") |
| Working dir dirty | ("blocked", [], "target branch has uncommitted changes") |
| Target mid-merge | ("blocked", [], "target branch is mid-merge") |
list.WorkingDir null |
("blocked", [], "list has no working directory") |
| Merge conflict | ("conflict", [files], null) — target auto-restored |
| Unknown git failure | HubException with stderr |
| Post-merge cleanup fails | Log a warning; merge already succeeded, state already Merged. Return ("merged", [], "cleanup: {reason}") — Status=="merged" with a non-null ErrorMessage means the merge went through but the worktree couldn't be removed. UI surfaces this as a yellow note, not a failure. |
Testing
tests/ClaudeDo.Worker.Tests/TaskMergeServiceTests.cs (real SQLite + real git, matching existing test conventions):
- Happy path, ff-able history → one merge commit, state Merged, broadcast fired.
- Happy path, diverged non-conflicting → merge commit created.
- Conflict path → conflicted files returned, target branch HEAD matches pre-merge,
MERGE_HEADabsent, worktree state still Active. - Pre-flight: worktree Merged/Discarded → blocked.
- Pre-flight: dirty working tree → blocked.
- Pre-flight: mid-merge target → blocked.
removeWorktree=true→ worktree dir gone, branch deleted, state Merged.removeWorktree=false→ worktree + branch survive, state Merged.- Task Running → blocked.
tests/ClaudeDo.Worker.Tests/GitServiceMergeTests.cs (narrow tests for new GitService methods): MergeNoFfAsync success/conflict tuple semantics, MergeAbortAsync clears MERGE_HEAD, IsMidMergeAsync true/false, ListLocalBranchesAsync returns expected set, GetCurrentBranchAsync on fresh repo.
Manual UI checklist captured in the implementation plan, not automated.
Implementation order (sketch)
- GitService additions + their tests.
TaskMergeService+ its tests (hub/UI not yet wired).- Hub methods +
WorkerClientmethods. MergeModalView+MergeModalViewModel.- Wire
DetailsIslandViewModel.ApproveMergeAsync. - Wire DiffModal Merge button.
- Manual UI walkthrough against the checklist.
Open items
None.