using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Planning; file sealed class TreeMergeRecordingHubClients : IHubClients { public TreeMergeRecordingClientProxy Proxy { get; } = new(); public IClientProxy All => Proxy; public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Client(string connectionId) => Proxy; public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; public IClientProxy Group(string groupName) => Proxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; public IClientProxy User(string userId) => Proxy; public IClientProxy Users(IReadOnlyList userIds) => Proxy; } file sealed class TreeMergeRecordingClientProxy : IClientProxy { public List<(string Method, object?[] Args)> Calls { get; } = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Calls.Add((method, args)); return Task.CompletedTask; } } file sealed class TreeMergeFakeHubContext : IHubContext { public TreeMergeRecordingHubClients RecordingClients { get; } = new(); public IHubClients Clients => RecordingClients; public IGroupManager Groups => throw new NotImplementedException(); } public sealed class TreeMergeTests : IDisposable { private readonly List _dbs = new(); private readonly List _repos = new(); private readonly List<(string repoDir, string wtPath)> _wtCleanups = new(); private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; } private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; } public void Dispose() { foreach (var (repo, wt) in _wtCleanups) try { GitRepoFixture.RunGit(repo, "worktree", "remove", "--force", wt); } catch { } foreach (var d in _dbs) try { d.Dispose(); } catch { } foreach (var r in _repos) try { r.Dispose(); } catch { } } [Fact] public async Task ImprovementParent_foldsOwnBranch_thenChild_andMarksDone() { if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available"); return; } var db = NewDb(); var repo = NewRepo(); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); var parentId = Guid.NewGuid().ToString(); var childId = Guid.NewGuid().ToString(); using (var ctx = db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "Parent", Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.None, SortOrder = 0, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "Child", Status = TaskStatus.Done, ParentTaskId = parentId, SortOrder = 1, CreatedAt = DateTime.UtcNow }); await ctx.SaveChangesAsync(); var parentWt = SeedWorktree(repo, parentId, repo.BaseCommit, "parent.txt", "parent work"); var childWt = SeedWorktree(repo, childId, parentWt.head, "child.txt", "child work"); ctx.Worktrees.Add(MakeRow(parentId, parentWt)); ctx.Worktrees.Add(MakeRow(childId, childWt)); await ctx.SaveChangesAsync(); } var (orch, calls) = BuildOrchestrator(db); await orch.StartAsync(parentId, "main", CancellationToken.None); using var verify = db.CreateContext(); Assert.Equal(TaskStatus.Done, verify.Tasks.Single(t => t.Id == parentId).Status); Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == parentId).State); Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State); Assert.Contains(calls, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == parentId); Assert.Contains(calls, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == childId); Assert.Contains(calls, c => c.Method == "PlanningCompleted"); Assert.True(File.Exists(Path.Combine(repo.RepoDir, "parent.txt"))); Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt"))); } private (string path, string branch, string head) SeedWorktree( GitRepoFixture repo, string taskId, string baseCommit, string file, string content) { var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _wtCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{taskId.Replace("-", "")}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, baseCommit); File.WriteAllText(Path.Combine(wtPath, file), content); GitRepoFixture.RunGit(wtPath, "add", file); GitRepoFixture.RunGit(wtPath, "commit", "-m", $"add {file}"); var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(); return (wtPath, branch, head); } private static WorktreeEntity MakeRow(string taskId, (string path, string branch, string head) wt) => new() { TaskId = taskId, Path = wt.path, BranchName = wt.branch, BaseCommit = "x", HeadCommit = wt.head, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow }; private (PlanningMergeOrchestrator orch, List<(string Method, object?[] Args)> calls) BuildOrchestrator(DbFixture db) { var fakeHub = new TreeMergeFakeHubContext(); var spy = fakeHub.RecordingClients.Proxy; var broadcaster = new HubBroadcaster(fakeHub); var git = new GitService(); var factory = db.CreateFactory(); var built = TaskStateServiceBuilder.Build(factory); var merge = new TaskMergeService( factory, git, broadcaster, built.State, NullLogger.Instance); var aggregator = new PlanningAggregator( factory, git, NullLogger.Instance); var orch = new PlanningMergeOrchestrator( factory, merge, aggregator, broadcaster, git, built.State, NullLogger.Instance); return (orch, spy.Calls); } }