diff --git a/docs/superpowers/specs/2026-04-22-worktree-merge-design.md b/docs/superpowers/specs/2026-04-22-worktree-merge-design.md new file mode 100644 index 0000000..f70ae35 --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-worktree-merge-design.md @@ -0,0 +1,208 @@ +# 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 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 MergeAsync( + string taskId, + string targetBranch, + bool removeWorktree, + string commitMessage, + CancellationToken ct); + + Task GetTargetsAsync(string taskId, CancellationToken ct); +} + +public sealed record MergeResult(string Status, IReadOnlyList ConflictFiles, string? ErrorMessage); +// Status ∈ "merged" | "conflict" | "blocked" + +public sealed record MergeTargets(string DefaultBranch, IReadOnlyList 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 GetCurrentBranchAsync(string repoDir, CancellationToken ct); // git symbolic-ref --short HEAD +Task> 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 +Task MergeAbortAsync(string repoDir, CancellationToken ct); // git merge --abort +Task IsMidMergeAsync(string repoDir, CancellationToken ct); // File.Exists($"{gitDir}/MERGE_HEAD") +Task> 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 MergeTask(string taskId, string targetBranch, bool removeWorktree, string commitMessage); +Task 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 MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage); +Task 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.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.