using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Runner;
/// Unit branch (spec 2026-10-01, section 2): children fork from and fast-forward into the parent's worktree. Real git + SQLite.
public sealed class UnitBranchTests : IDisposable
{
private const string Parent = "p";
private const string Child1 = "c1";
private const string Child2 = "c2";
private readonly DbFixture _db = new();
private readonly GitRepoFixture _repo = new();
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"cd_unit_{Guid.NewGuid():N}");
private readonly string _listName = $"ub{Guid.NewGuid():N}"[..12];
private readonly WorkerConfig _cfg;
private readonly GitService _git = new();
private readonly WorktreeManager _wt;
private readonly UnitBranchService _unit;
private readonly TaskStateServiceBuilder.Built _built;
private readonly HubBroadcaster _broadcaster = new(new CapturingHubContext());
public UnitBranchTests()
{
Directory.CreateDirectory(_tempDir);
GitRepoFixture.RunGit(_repo.RepoDir, "branch", "-m", "main");
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir, WorktreeRootStrategy = "sibling" };
var factory = _db.CreateFactory();
_wt = new WorktreeManager(_git, factory, _cfg, NullLogger.Instance);
_unit = new UnitBranchService(_git, _wt, factory, NullLogger.Instance);
_built = TaskStateServiceBuilder.Build(factory, unitBranch: _unit);
Seed();
}
public void Dispose()
{
try
{
foreach (var line in GitRepoFixture.RunGit(_repo.RepoDir, "worktree", "list", "--porcelain").Split('\n'))
if (line.StartsWith("worktree ") && !Path.GetFullPath(line[9..].Trim()).Equals(Path.GetFullPath(_repo.RepoDir), StringComparison.OrdinalIgnoreCase))
try { GitRepoFixture.RunGit(_repo.RepoDir, "worktree", "remove", "--force", line[9..].Trim()); } catch { }
}
catch { }
try { Directory.Delete(Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(_repo.RepoDir), ".claudedo-worktrees", _listName), true); } catch { }
_db.Dispose();
_repo.Dispose();
try { Directory.Delete(_tempDir, true); } catch { }
}
private void Seed()
{
using var ctx = _db.CreateContext();
ctx.Lists.Add(new ListEntity { Id = "l1", Name = _listName, WorkingDir = _repo.RepoDir, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = Parent, ListId = "l1", Title = "Plan", Number = 1, CommitType = "feat",
Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = Child1, ListId = "l1", Title = "First", Number = 2, ParentTaskId = Parent,
SortOrder = 1, Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = Child2, ListId = "l1", Title = "Second", Number = 3, ParentTaskId = Parent,
SortOrder = 2, Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow.AddSeconds(1) });
ctx.SaveChanges();
}
private async Task WorktreeOf(string taskId)
{
using var ctx = _db.CreateContext();
return await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId);
}
private async Task TaskOf(string taskId)
{
using var ctx = _db.CreateContext();
return (await new TaskRepository(ctx).GetByIdAsync(taskId))!;
}
private async Task SetStatus(string taskId, TaskStatus status)
{
using var ctx = _db.CreateContext();
var t = ctx.Tasks.Single(x => x.Id == taskId);
t.Status = status;
await ctx.SaveChangesAsync();
}
private ListEntity List() { using var ctx = _db.CreateContext(); return ctx.Lists.Single(); }
private TaskRunner Runner(FakeClaudeProcess fake) => new(fake, _db.CreateFactory(), _broadcaster, _wt,
new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, _built.State, new TaskRunTokenRegistry(),
new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader(), _unit);
private TaskMergeService MergeService() => new(_db.CreateFactory(), _git, _broadcaster, _built.State,
new FakeVerifyCommandRunner(), NullLogger.Instance, _unit);
private static FakeClaudeProcess Writes(Action? before = null) => new((prompt, dir, _, _, _) =>
{
before?.Invoke(dir);
File.WriteAllText(Path.Combine(dir, $"{Guid.NewGuid():N}.txt"), "x");
return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" });
});
private async Task RunChild(string childId, FakeClaudeProcess fake)
{
await SetStatus(childId, TaskStatus.Running);
await Runner(fake).RunAsync(await TaskOf(childId), "slot-1", default, alreadyClaimed: true);
}
/// Runs both children serially; child 1 writes a.txt, child 2 records whether it saw it and writes b.txt.
private async Task RunTwoSerialChildren()
{
await _unit.EnsureUnitWorktreeAsync(Parent, default);
await RunChild(Child1, new FakeClaudeProcess((_, dir, _, _, _) =>
{
File.WriteAllText(Path.Combine(dir, "a.txt"), "a");
return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" });
}));
var sawA = false;
await RunChild(Child2, new FakeClaudeProcess((_, dir, _, _, _) =>
{
sawA = File.Exists(Path.Combine(dir, "a.txt"));
File.WriteAllText(Path.Combine(dir, "b.txt"), "b");
return Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" });
}));
return sawA;
}
[Fact]
public async Task QueuePlan_gives_planning_parent_its_unit_worktree()
{
await _built.Chain.QueuePlanAsync(Parent);
var wt = await WorktreeOf(Parent);
Assert.NotNull(wt);
Assert.Equal(WorktreeState.Active, wt!.State);
Assert.Equal(_repo.BaseCommit, wt.BaseCommit);
Assert.StartsWith("feature/1-plan", wt.BranchName);
Assert.Equal(TaskStatus.Queued, (await TaskOf(Child1)).Status);
await _unit.EnsureUnitWorktreeAsync(Parent, default);
Assert.Equal(wt.Path, (await WorktreeOf(Parent))!.Path);
}
[Fact]
public async Task Child_starts_from_current_unit_head()
{
await _unit.EnsureUnitWorktreeAsync(Parent, default);
var unit = (await WorktreeOf(Parent))!;
File.WriteAllText(Path.Combine(unit.Path, "unit.txt"), "u");
GitRepoFixture.RunGit(unit.Path, "add", "-A");
GitRepoFixture.RunGit(unit.Path, "commit", "-m", "unit work");
var unitHead = GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", unit.BranchName).Trim();
var ctx = await _wt.CreateAsync(await TaskOf(Child1), List(), default);
Assert.Equal(unitHead, ctx.BaseCommit);
Assert.NotEqual(_repo.BaseCommit, ctx.BaseCommit);
}
[Fact]
public async Task Serial_children_are_fast_forwarded_and_child2_sees_child1()
{
var sawA = await RunTwoSerialChildren();
Assert.True(sawA);
Assert.Equal(TaskStatus.Done, (await TaskOf(Child1)).Status);
Assert.Equal(TaskStatus.Done, (await TaskOf(Child2)).Status);
Assert.Equal(TaskStatus.WaitingForReview, (await TaskOf(Parent)).Status);
var unit = (await WorktreeOf(Parent))!;
Assert.True(File.Exists(Path.Combine(unit.Path, "a.txt")));
Assert.True(File.Exists(Path.Combine(unit.Path, "b.txt")));
Assert.Equal(GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", unit.BranchName).Trim(), unit.HeadCommit);
var branches = await _git.ListLocalBranchesAsync(_repo.RepoDir);
foreach (var child in new[] { Child1, Child2 })
{
var wt = (await WorktreeOf(child))!;
Assert.Equal(WorktreeState.Merged, wt.State);
Assert.Null(wt.MergeCommit);
Assert.False(Directory.Exists(wt.Path));
Assert.DoesNotContain(wt.BranchName, branches);
}
// Nothing lands on main before the parent's approve.
Assert.Equal(_repo.BaseCommit, GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", "main").Trim());
}
[Fact]
public async Task Child_that_cannot_fast_forward_fails_and_leaves_unit_alone()
{
await _unit.EnsureUnitWorktreeAsync(Parent, default);
var unit = (await WorktreeOf(Parent))!;
string? movedHead = null;
// The unit moves while the child runs -- what a parallel "Run now" sibling would do.
await RunChild(Child1, Writes(_ =>
{
File.WriteAllText(Path.Combine(unit.Path, "sibling.txt"), "s");
GitRepoFixture.RunGit(unit.Path, "add", "-A");
GitRepoFixture.RunGit(unit.Path, "commit", "-m", "sibling");
movedHead = GitRepoFixture.RunGit(unit.Path, "rev-parse", "HEAD").Trim();
}));
var child = await TaskOf(Child1);
Assert.Equal(TaskStatus.Failed, child.Status);
Assert.Equal(movedHead, GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", unit.BranchName).Trim());
Assert.Contains("fast-forward", child.Result);
Assert.Contains("Not possible to fast-forward", child.Result); // git's own stderr is surfaced
Assert.Equal(WorktreeState.Active, (await WorktreeOf(Child1))!.State);
}
[Fact]
public async Task Approving_a_new_parent_merges_the_unit_branch_onto_main()
{
await RunTwoSerialChildren();
using (var ctx = _db.CreateContext())
Assert.False(await UnitBranchService.HasUnabsorbedChildrenAsync(ctx, Parent, default));
var result = await MergeService().ApproveAndMergeAsync(Parent, "main", default);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal(TaskStatus.Done, (await TaskOf(Parent)).Status);
Assert.NotNull(await _git.ShowFileAsync(_repo.RepoDir, "main", "a.txt"));
Assert.NotNull(await _git.ShowFileAsync(_repo.RepoDir, "main", "b.txt"));
Assert.Equal(WorktreeState.Merged, (await WorktreeOf(Parent))!.State);
}
[Fact]
public async Task Approving_a_planning_parent_with_an_unfinished_child_is_blocked()
{
await _unit.EnsureUnitWorktreeAsync(Parent, default);
await RunChild(Child1, Writes());
await SetStatus(Child2, TaskStatus.Failed);
await SetStatus(Parent, TaskStatus.WaitingForReview);
var result = await MergeService().ApproveAndMergeAsync(Parent, "main", default);
Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
Assert.Equal($"subtask {Child2} is not Done (status Failed)", result.ErrorMessage);
Assert.Equal(TaskStatus.WaitingForReview, (await TaskOf(Parent)).Status);
Assert.Equal(WorktreeState.Active, (await WorktreeOf(Parent))!.State);
Assert.Equal(_repo.BaseCommit, GitRepoFixture.RunGit(_repo.RepoDir, "rev-parse", "main").Trim());
}
[Fact]
public async Task Approving_an_interactive_child_absorbs_it_instead_of_merging_to_main()
{
await _unit.EnsureUnitWorktreeAsync(Parent, default);
var child = await TaskOf(Child1);
var ctx = await _wt.CreateAsync(child, List(), default);
File.WriteAllText(Path.Combine(ctx.WorktreePath, "i.txt"), "i");
await _wt.CommitIfChangedAsync(ctx, child, List(), default);
await SetStatus(Child1, TaskStatus.WaitingForReview);
var result = await MergeService().ApproveAndMergeAsync(Child1, "", default);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal(TaskStatus.Done, (await TaskOf(Child1)).Status);
Assert.True(File.Exists(Path.Combine((await WorktreeOf(Parent))!.Path, "i.txt")));
Assert.Null(await _git.ShowFileAsync(_repo.RepoDir, "main", "i.txt"));
}
[Fact]
public async Task Legacy_unit_still_goes_through_the_orchestrator()
{
// Pre-rollout shape: the planning parent has no worktree, a Done child still owns an Active one.
var child = await TaskOf(Child1);
var ctx = await _wt.CreateAsync(child, List(), default);
File.WriteAllText(Path.Combine(ctx.WorktreePath, "legacy.txt"), "l");
await _wt.CommitIfChangedAsync(ctx, child, List(), default);
await SetStatus(Child1, TaskStatus.Done);
await SetStatus(Child2, TaskStatus.Cancelled);
await SetStatus(Parent, TaskStatus.WaitingForReview);
using (var db = _db.CreateContext())
Assert.True(await UnitBranchService.HasUnabsorbedChildrenAsync(db, Parent, default));
// The legacy child is not absorbed by a late approve call either: it has no parent worktree.
Assert.False((await _unit.AbsorbChildAsync(Child1, default)).Applicable);
var factory = _db.CreateFactory();
var orch = new PlanningMergeOrchestrator(factory, MergeService(),
new PlanningAggregator(factory, _git, NullLogger.Instance),
_broadcaster, _git, _built.State, NullLogger.Instance);
await SetStatus(Child2, TaskStatus.Done); // StartAsync requires every planning child Done
using (var db = _db.CreateContext())
{
db.Worktrees.Add(new WorktreeEntity { TaskId = Child2, Path = Path.Combine(_tempDir, "gone"),
BranchName = "gone", BaseCommit = _repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var result = await orch.StartAsync(Parent, "main", default);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.NotNull(await _git.ShowFileAsync(_repo.RepoDir, "main", "legacy.txt"));
Assert.Equal(TaskStatus.Done, (await TaskOf(Parent)).Status);
}
[Fact]
public async Task Combined_diff_of_a_unit_is_base_to_head_without_an_integration_branch()
{
await RunTwoSerialChildren();
var factory = _db.CreateFactory();
var aggregator = new PlanningAggregator(factory, _git, NullLogger.Instance);
var combined = await aggregator.BuildIntegrationBranchAsync(Parent, "main", default);
var ok = Assert.IsType(combined);
Assert.Equal((await WorktreeOf(Parent))!.BranchName, ok.Value.IntegrationBranch);
Assert.Contains("a.txt", ok.Value.UnifiedDiff);
Assert.Contains("b.txt", ok.Value.UnifiedDiff);
Assert.Equal("main", await _git.GetCurrentBranchAsync(_repo.RepoDir));
Assert.DoesNotContain(await _git.ListLocalBranchesAsync(_repo.RepoDir), b => b.StartsWith("planning/"));
var perChild = await aggregator.GetAggregatedDiffAsync(Parent, default);
var d1 = perChild.Single(d => d.SubtaskId == Child1).UnifiedDiff;
var d2 = perChild.Single(d => d.SubtaskId == Child2).UnifiedDiff;
Assert.Contains("a.txt", d1);
Assert.DoesNotContain("b.txt", d1);
Assert.Contains("b.txt", d2);
Assert.DoesNotContain("a.txt", d2);
}
}