Files
ClaudeDo/docs/superpowers/plans/2026-10-01-unit-branch-2-children.md

11 KiB

Unit-Branch Teil 2: Children per fast-forward — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Children of a unit start from the unit branch head and are fast-forwarded into it as soon as they finish; a parent is approved like a single task.

Architecture: New UnitBranchService (Worker/Runner) owns the git side: it creates the planning parent's worktree on "Queue plan" and absorbs a finished child (merge --ff-only in the parent worktree, child worktree removed, row Merged, MergeCommit null). It never touches task status — TaskRunner (FailAsync on a ff failure) and TaskMergeService (approve of an interactive child) own the transitions, so TaskStateService/PlanningChainCoordinator stay git-free and the DI graph stays acyclic. Legacy units (a Done child with an Active worktree) keep going through PlanningMergeOrchestrator.

Tech Stack: .NET 8, EF Core SQLite, git CLI, xUnit (real git + real SQLite in Worker.Tests).

Spec: docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md, section 2. Private behaviour only — company mode (push/PR) is part 3.

File map

File Change
src/ClaudeDo.Data/Git/GitService.cs + MergeFastForwardOnlyAsync
src/ClaudeDo.Data/Repositories/WorktreeRepository.cs SetMergedAsync(…, string? mergeCommit)
src/ClaudeDo.Worker/Runner/WorktreeManager.cs ResolveBaseCommitAsync: child → rev-parse <parentWt.BranchName> for any parent with an Active worktree
src/ClaudeDo.Worker/Runner/UnitBranchService.cs new — EnsureUnitWorktreeAsync, AbsorbChildAsync, HasUnabsorbedChildrenAsync
src/ClaudeDo.Worker/Program.cs register UnitBranchService (+ Share<> for the external container is not needed — the MCP side only uses the static helper)
src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs QueuePlanAsync calls EnsureUnitWorktreeAsync
src/ClaudeDo.Worker/Runner/TaskRunner.cs HandleSuccess: absorb before CompleteAsync, ff failure → MarkFailed
src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs ApproveAndMergeAsync: child of a unit worktree → absorb instead of merge
src/ClaudeDo.Worker/Hub/WorkerHub.cs, src/ClaudeDo.Worker/External/ExternalMcpService.cs approve routing: orchestrator only for unabsorbed (legacy) children
src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs also merges a planning parent's own Active worktree (mixed legacy unit)
src/ClaudeDo.Worker/Planning/PlanningAggregator.cs combined diff = unitBase..unitBranch without checkout -b when the parent owns the unit; per-child diff of absorbed children from the commit range
tests/ClaudeDo.Worker.Tests/Runner/UnitBranchTests.cs new — all unit-branch tests
src/ClaudeDo.Worker/CLAUDE.md, docs/explore-notes/review-merge.md docs

Task 1: Git primitive + nullable MergeCommit

Files: Modify src/ClaudeDo.Data/Git/GitService.cs (after MergeNoFfAsync), src/ClaudeDo.Data/Repositories/WorktreeRepository.cs:39

  • Step 1: Add the method
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
    string worktreePath, string sourceRef, CancellationToken ct = default)
{
    var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["merge", "--ff-only", sourceRef], ct);
    return (exitCode, stderr);
}
  • Step 2: SetMergedAsync(string taskId, string? mergeCommit, …) — an absorbed child has no merge commit.
  • Step 3: Covered by the Task 3 tests (no standalone test for a one-line git wrapper).

Task 2: Child base = unit head

Files: Modify src/ClaudeDo.Worker/Runner/WorktreeManager.cs (ResolveBaseCommitAsync)

  • Step 1: Failing test (UnitBranchTests.Child_starts_from_unit_head): planning parent (PlanningPhase.Finalized) with a worktree, commit a file in the parent worktree (git -C <wt> commit), then CreateAsync(child) → ctx.BaseCommit == rev-parse <parentBranch>. Fails today because planning children fall through to HEAD of the main checkout.
  • Step 2: Implement
if (task.ParentTaskId is not null)
{
    using var ctx = _dbFactory.CreateDbContext();
    var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
    if (parentWt is { State: WorktreeState.Active })
        return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
}
  • Step 3: run --filter "FullyQualifiedName~UnitBranchTests|FullyQualifiedName~WorktreeManagerTests", commit.

Task 3: UnitBranchService

Files: Create src/ClaudeDo.Worker/Runner/UnitBranchService.cs, register in Program.cs next to WorktreeManager.

public sealed record AbsorbResult(bool Applicable, string? Error)
{
    public static readonly AbsorbResult NotApplicable = new(false, null);
    public static readonly AbsorbResult Absorbed = new(true, null);
    public bool Failed => Error is not null;
}

public sealed class UnitBranchService
{
    // ctor: GitService, WorktreeManager, IDbContextFactory, HubBroadcaster?, ILogger

    /// Planning parent → own worktree = unit branch; no-op if it already has an Active one or the list has no git working dir.
    public async Task EnsureUnitWorktreeAsync(string parentId, CancellationToken ct);

    /// ff the parent worktree to the child branch; child row Merged (MergeCommit null), child worktree + branch removed.
    /// NotApplicable when the child or its parent has no Active worktree. Never changes task status.
    public async Task<AbsorbResult> AbsorbChildAsync(string childId, CancellationToken ct);

    /// Legacy routing: a Done child whose worktree is still Active was never absorbed.
    public static Task<bool> HasUnabsorbedChildrenAsync(ClaudeDoDbContext ctx, string parentId, CancellationToken ct) =>
        ctx.Tasks.AnyAsync(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Done
            && t.Worktree != null && t.Worktree.State == WorktreeState.Active, ct);
}

Absorb order: ff → parent UpdateHeadAsync(head, diffstat base..head) → child SetMergedAsync(null) → best-effort worktree remove --force + branch -D → WorktreeUpdated for both. ff failure returns Error with a clear message and a ponytail: comment (no merge fallback).

  • Tests (real git): two serial children absorbed, child 2 sees child 1's file, parent branch head == child 2 head, child rows Merged with null MergeCommit, child branches gone; a child forked from the old head after the unit moved → Failed, unit branch unchanged.
  • Commit.

Task 4: Queue plan creates the parent worktree

Files: PlanningChainCoordinator.cs (optional ctor param UnitBranchService? unitBranch = null), TaskStateServiceBuilder.cs (optional pass-through).

if (_unitBranch is not null) await _unitBranch.EnsureUnitWorktreeAsync(parentTaskId, ct);
return await SetupChainAsync(parentTaskId, enqueue: true, ct);
  • Test: finalized planning parent + 1 child in a git list → after QueuePlanAsync the parent has an Active worktree row whose base = repo HEAD; a second call keeps the same row.
  • Commit.

Task 5: TaskRunner absorbs before CompleteAsync

Files: TaskRunner.cs (optional trailing ctor param UnitBranchService? unitBranch = null; in the child branch of HandleSuccess):

if (_unitBranch is not null)
{
    var absorb = await _unitBranch.AbsorbChildAsync(task.Id, CancellationToken.None);
    if (absorb.Failed)
    {
        await MarkFailed(task.Id, task.Number, task.Title, slot, absorb.Error, result.TurnCount);
        return;
    }
}
  • Test: TaskRunner with FakeClaudeProcess writing a file into its working dir, two queued children of a unit → both Done, unit head has both files; a child whose base was overtaken → Failed with the ff message.
  • Commit.

Task 6: Interactive child approve absorbs

Files: TaskMergeService.cs (optional ctor param UnitBranchService? unitBranch = null); in ApproveAndMergeAsync after the WaitingForReview guard:

if (task.ParentTaskId is not null && _unitBranch is not null)
{
    var absorb = await _unitBranch.AbsorbChildAsync(taskId, ct);
    if (absorb.Failed) return Blocked(absorb.Error!);
    if (absorb.Applicable)
    {
        var approved = await _state.ApproveReviewAsync(taskId, ct);
        return approved.Ok ? new MergeResult(StatusMerged, Array.Empty<string>(), null) : Blocked(approved.Reason ?? "approve failed");
    }
}

A blocked approve keeps the child WaitingForReview (the reviewer can reject/re-run) — the Failed rule applies to autonomous runs.

  • Test: child in WaitingForReview with a commit → approve → child Done, unit branch contains the file, main untouched.
  • Commit.

Task 7: Approve routing

Files: WorkerHub.ApproveReview, ExternalMcpService.ReviewTask, PlanningMergeOrchestrator.StartAsync.

  • Replace hasChildren with UnitBranchService.HasUnabsorbedChildrenAsync for the orchestrator branch (MCP still computes emptyChildren whenever children exist; absorbed children count as empty when their row has no HeadCommit).
  • Orchestrator: if (parentHasWorktree) idsToMerge.Add(parentTaskId) (drop !isPlanning) so a mixed legacy unit still lands the unit branch.
  • Tests: approve of a new parent (all children absorbed) merges the unit branch onto main and the parent goes Done; a legacy parent (Done child with Active worktree, no parent worktree) still goes through the orchestrator and lands the child.
  • Commit.

Task 8: Combined diff without the integration branch

Files: PlanningAggregator.cs.

  • BuildIntegrationBranchAsync: parent with Active worktree and no unabsorbed child → Ok(parentWt.BranchName, git diff <BaseCommit> <BranchName>), no checkout in the main checkout. Otherwise legacy path.
  • GetAggregatedDiffAsync: a node whose worktree directory is gone but has HeadCommit → GetCommitRangeDiffAsync(repoDir, BaseCommit, HeadCommit); an improvement parent's own node is limited to BaseCommit..firstChild.BaseCommit once children were forked from it (else it would repeat the absorbed children).
  • Test: unit with an absorbed child → combined diff contains the child's file, main checkout stays on main, no planning/*-integration branch exists.
  • Commit.

Task 9: Docs

src/ClaudeDo.Worker/CLAUDE.md (Planning Flow, Unified parent model, WorktreeManager base rule), docs/explore-notes/review-merge.md (Approve section, MergeCommit/revert note, Unit-Branch section). Commit.

Verification

dotnet build ClaudeDo.slnx -c Release, then dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~UnitBranch|FullyQualifiedName~WorktreeManager|FullyQualifiedName~PlanningMerge|FullyQualifiedName~PlanningAggregator|FullyQualifiedName~StandaloneChildren|FullyQualifiedName~TaskMergeService|FullyQualifiedName~ReviewTask".