Files
ClaudeDo/docs/superpowers/specs/2026-04-22-worktree-merge-design.md
Mika Kuns 944d3bd3e8 docs: add worktree merge design spec
Captures decisions for merging a task worktree into a target branch:
merge-commit strategy, dual UI entry (Details island + DiffModal),
per-merge cleanup checkbox, pre-flight + abort-on-conflict.
2026-04-22 09:08:44 +02:00

9.6 KiB
Raw Blame History

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's WorkingDir.
  • 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):

  1. Task exists, status not Running.
  2. Worktree exists, state == Active.
  3. list.WorkingDir is set and is a git repo.
  4. Target working tree is clean (HasChangesAsync == false).
  5. Target repo is not mid-merge (IsMidMergeAsync == false).

Failures short-circuit to MergeResult("blocked", [], reason) before any git write.

Success path:

  1. GitService.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct).
  2. If removeWorktree:
    • WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: false, ct) — reuse existing method.
    • BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: false, ct).
  3. WorktreeRepository.SetStateAsync(taskId, WorktreeState.Merged, ct).
  4. HubBroadcaster.BroadcastWorktreeUpdated(taskId).
  5. Log info; return MergeResult("merged", [], null).

Conflict path (merge invoked, git returns non-zero with CONFLICT on stderr/stdout):

  1. Collect conflicted files: git diff --name-only --diff-filter=U.
  2. GitService.MergeAbortAsync(list.WorkingDir, ct).
  3. Worktree state stays Active; no broadcast (nothing changed).
  4. 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 GitMergeConflictException vs InvalidOperationException.

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 12s; 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.ApproveMergeAsync opens MergeModalView (factory injected through MainWindowViewModel's existing dialog pattern).
  • DiffModalView gains 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 — extend ShowFailedActions-style gating with a new flag CanMerge).

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", [], null) with a note in ErrorMessage.

Testing

tests/ClaudeDo.Worker.Tests/TaskMergeServiceTests.cs (real SQLite + real git, matching existing test conventions):

  1. Happy path, ff-able history → one merge commit, state Merged, broadcast fired.
  2. Happy path, diverged non-conflicting → merge commit created.
  3. Conflict path → conflicted files returned, target branch HEAD matches pre-merge, MERGE_HEAD absent, worktree state still Active.
  4. Pre-flight: worktree Merged/Discarded → blocked.
  5. Pre-flight: dirty working tree → blocked.
  6. Pre-flight: mid-merge target → blocked.
  7. removeWorktree=true → worktree dir gone, branch deleted, state Merged.
  8. removeWorktree=false → worktree + branch survive, state Merged.
  9. 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)

  1. GitService additions + their tests.
  2. TaskMergeService + its tests (hub/UI not yet wired).
  3. Hub methods + WorkerClient methods.
  4. MergeModalView + MergeModalViewModel.
  5. Wire DetailsIslandViewModel.ApproveMergeAsync.
  6. Wire DiffModal Merge button.
  7. Manual UI walkthrough against the checklist.

Open items

None.