diff --git a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs new file mode 100644 index 0000000..121da6e --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs @@ -0,0 +1,73 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Planning; + +public sealed record SubtaskDiff( + string SubtaskId, + string Title, + string BranchName, + string BaseCommit, + string HeadCommit, + string? DiffStat, + string UnifiedDiff); + +public sealed record CombinedDiffSuccess(string IntegrationBranch, string UnifiedDiff); +public sealed record CombinedDiffFailure(string FirstConflictSubtaskId, IReadOnlyList ConflictedFiles); + +public abstract record CombinedDiffResult +{ + public sealed record Ok(CombinedDiffSuccess Value) : CombinedDiffResult; + public sealed record Failed(CombinedDiffFailure Value) : CombinedDiffResult; +} + +public sealed class PlanningAggregator +{ + private readonly IDbContextFactory _dbFactory; + private readonly GitService _git; + private readonly ILogger _logger; + + public PlanningAggregator( + IDbContextFactory dbFactory, + GitService git, + ILogger logger) + { + _dbFactory = dbFactory; + _git = git; + _logger = logger; + } + + public async Task> GetAggregatedDiffAsync( + string planningTaskId, CancellationToken ct) + { + using var ctx = _dbFactory.CreateDbContext(); + var children = await ctx.Tasks + .Include(t => t.Worktree) + .Where(t => t.ParentTaskId == planningTaskId) + .OrderBy(t => t.SortOrder) + .ToListAsync(ct); + + var result = new List(); + foreach (var child in children) + { + if (child.Worktree is null) continue; + var wt = child.Worktree; + var head = wt.HeadCommit ?? await _git.RevParseHeadAsync(wt.Path, ct); + string unified; + try + { + unified = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "diff failed for subtask {Id}", child.Id); + unified = ""; + } + result.Add(new SubtaskDiff( + child.Id, child.Title, wt.BranchName, wt.BaseCommit, head, wt.DiffStat, unified)); + } + return result; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs new file mode 100644 index 0000000..75e5655 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs @@ -0,0 +1,119 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +public class PlanningAggregatorTests : 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 GetAggregatedDiffAsync_ReturnsOneEntryPerSubtaskInSortOrder() + { + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var (parentId, subAId, subBId) = await SeedPlanningWithTwoChildrenAsync(db, repo); + + var svc = new PlanningAggregator( + db.CreateFactory(), + new GitService(), + NullLogger.Instance); + + var result = await svc.GetAggregatedDiffAsync(parentId, CancellationToken.None); + + Assert.Equal(2, result.Count); + Assert.Equal(subAId, result[0].SubtaskId); + Assert.Equal(subBId, result[1].SubtaskId); + Assert.Contains("diff --git", result[0].UnifiedDiff); + Assert.Contains("diff --git", result[1].UnifiedDiff); + } + + private async Task<(string parentId, string subA, string subB)> SeedPlanningWithTwoChildrenAsync( + DbFixture db, GitRepoFixture repo) + { + using var ctx = db.CreateContext(); + + // List with WorkingDir set to the repo. + var listId = Guid.NewGuid().ToString(); + ctx.Lists.Add(new ListEntity + { + Id = listId, Name = "test", CreatedAt = DateTime.UtcNow, + WorkingDir = repo.RepoDir, + }); + + // Planning parent. + var parentId = Guid.NewGuid().ToString(); + ctx.Tasks.Add(new TaskEntity + { + Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, + Status = TaskStatus.Planning, SortOrder = 0, + }); + + // Two children (sorted A then B). + var subA = Guid.NewGuid().ToString(); + var subB = Guid.NewGuid().ToString(); + ctx.Tasks.Add(new TaskEntity + { + Id = subA, ListId = listId, Title = "child A", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, + }); + ctx.Tasks.Add(new TaskEntity + { + Id = subB, ListId = listId, Title = "child B", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, + }); + await ctx.SaveChangesAsync(); + + // Create real worktrees for each child with a distinct commit. + SeedWorktree(ctx, repo, subA, "fileA.txt", "content A"); + SeedWorktree(ctx, repo, subB, "fileB.txt", "content B"); + await ctx.SaveChangesAsync(); + + return (parentId, subA, subB); + } + + private void SeedWorktree(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content) + { + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _wtCleanups.Add((repo.RepoDir, wtPath)); + var branch = $"claudedo/{taskId[..8]}"; + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, filename), content); + GitRepoFixture.RunGit(wtPath, "add", filename); + GitRepoFixture.RunGit(wtPath, "commit", "-m", $"add {filename}"); + var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(); + + ctx.Worktrees.Add(new WorktreeEntity + { + TaskId = taskId, + Path = wtPath, + BranchName = branch, + BaseCommit = repo.BaseCommit, + HeadCommit = head, + DiffStat = null, + State = WorktreeState.Active, + CreatedAt = DateTime.UtcNow, + }); + } +}