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); } }