From 2cab33d70880446c6080bf1770d559bd13571f84 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 24 Apr 2026 16:18:45 +0200 Subject: [PATCH] feat(worker): add PlanningAggregator.BuildIntegrationBranchAsync --- .../Planning/PlanningAggregator.cs | 86 +++++++++++++++++ .../Planning/PlanningAggregatorTests.cs | 95 +++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs index 121da6e..9866378 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs @@ -70,4 +70,90 @@ public sealed class PlanningAggregator } return result; } + + public async Task BuildIntegrationBranchAsync( + string planningTaskId, string targetBranch, CancellationToken ct) + { + var (planning, repoDir, childSubtasks) = await LoadPlanningContextAsync(planningTaskId, ct); + + var integrationBranch = BuildIntegrationBranchName(planning); + + // Reset: delete if exists, then recreate off the target branch. + try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } + catch { /* didn't exist */ } + + await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); + await GitRawAsync(repoDir, ct, "checkout", "-b", integrationBranch); + + foreach (var child in childSubtasks) + { + if (child.Worktree is null) continue; + var (code, _) = await _git.MergeNoFfAsync( + repoDir, child.Worktree.BranchName, + $"Integrate subtask: {child.Title}", ct); + if (code != 0) + { + List files; + try { files = await _git.ListConflictedFilesAsync(repoDir, ct); } + catch { files = new(); } + + try { await _git.MergeAbortAsync(repoDir, ct); } catch { } + try { await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); } catch { } + try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } catch { } + + return new CombinedDiffResult.Failed( + new CombinedDiffFailure(child.Id, files)); + } + } + + var unifiedDiff = await GitRawAsync(repoDir, ct, "diff", $"{targetBranch}..{integrationBranch}"); + return new CombinedDiffResult.Ok(new CombinedDiffSuccess(integrationBranch, unifiedDiff)); + } + + private async Task<(TaskEntity planning, string repoDir, IReadOnlyList children)> + LoadPlanningContextAsync(string planningTaskId, CancellationToken ct) + { + using var ctx = _dbFactory.CreateDbContext(); + var planning = await ctx.Tasks + .Include(t => t.List) + .Include(t => t.Children).ThenInclude(c => c.Worktree) + .SingleOrDefaultAsync(t => t.Id == planningTaskId, ct) + ?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found."); + var repoDir = planning.List.WorkingDir + ?? throw new InvalidOperationException("List has no working directory."); + var children = planning.Children.OrderBy(c => c.SortOrder).ToList(); + return (planning, repoDir, children); + } + + internal static string BuildIntegrationBranchName(TaskEntity planning) + { + var slug = new string(planning.Title + .ToLowerInvariant() + .Select(c => char.IsLetterOrDigit(c) ? c : '-') + .ToArray()) + .Trim('-'); + if (string.IsNullOrEmpty(slug)) slug = planning.Id[..8]; + if (slug.Length > 40) slug = slug[..40].TrimEnd('-'); + return $"planning/{slug}-integration"; + } + + private static async Task GitRawAsync(string cwd, CancellationToken ct, params string[] args) + { + var psi = new System.Diagnostics.ProcessStartInfo("git") + { + WorkingDirectory = cwd, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = System.Diagnostics.Process.Start(psi)!; + var stdoutTask = p.StandardOutput.ReadToEndAsync(); + var stderrTask = p.StandardError.ReadToEndAsync(); + await p.WaitForExitAsync(ct); + var stdout = await stdoutTask; + var stderr = await stderrTask; + if (p.ExitCode != 0) throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}"); + return stdout; + } } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs index 75e5655..d762f49 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs @@ -116,4 +116,99 @@ public class PlanningAggregatorTests : IDisposable CreatedAt = DateTime.UtcNow, }); } + + [Fact] + public async Task BuildIntegrationBranchAsync_NonConflictingChildren_ReturnsOkWithCombinedDiff() + { + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + var (parentId, _, _) = await SeedPlanningWithTwoChildrenAsync(db, repo); + + var git = new GitService(); + var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.BuildIntegrationBranchAsync(parentId, targetBranch: "main", CancellationToken.None); + + var ok = Assert.IsType(result); + Assert.EndsWith("-integration", ok.Value.IntegrationBranch); + Assert.Contains("diff --git", ok.Value.UnifiedDiff); + + var branches = await git.ListLocalBranchesAsync(repo.RepoDir, CancellationToken.None); + Assert.Contains(branches, b => b == ok.Value.IntegrationBranch); + } + + [Fact] + public async Task BuildIntegrationBranchAsync_ConflictingChildren_ReturnsFailedAndResetsBranch() + { + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + var (parentId, subA, subB) = await SeedPlanningWithConflictingChildrenAsync(db, repo); + + var git = new GitService(); + var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.BuildIntegrationBranchAsync(parentId, targetBranch: "main", CancellationToken.None); + + var failed = Assert.IsType(result); + Assert.Equal(subB, failed.Value.FirstConflictSubtaskId); + Assert.NotEmpty(failed.Value.ConflictedFiles); + + Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None)); + } + + private async Task<(string parentId, string subA, string subB)> SeedPlanningWithConflictingChildrenAsync( + DbFixture db, GitRepoFixture repo) + { + using var ctx = db.CreateContext(); + var listId = Guid.NewGuid().ToString(); + ctx.Lists.Add(new ListEntity + { + Id = listId, Name = "test", CreatedAt = DateTime.UtcNow, WorkingDir = repo.RepoDir, + }); + var parentId = Guid.NewGuid().ToString(); + ctx.Tasks.Add(new TaskEntity + { + Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, + Status = TaskStatus.Planning, SortOrder = 0, + }); + var subA = Guid.NewGuid().ToString(); + var subB = Guid.NewGuid().ToString(); + ctx.Tasks.Add(new TaskEntity + { + Id = subA, ListId = listId, Title = "A", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, + }); + ctx.Tasks.Add(new TaskEntity + { + Id = subB, ListId = listId, Title = "B", CreatedAt = DateTime.UtcNow, + ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2, + }); + await ctx.SaveChangesAsync(); + + SeedWorktreeWithFile(ctx, repo, subA, "README.md", "A wins\n"); + SeedWorktreeWithFile(ctx, repo, subB, "README.md", "B wins\n"); + await ctx.SaveChangesAsync(); + + return (parentId, subA, subB); + } + + private void SeedWorktreeWithFile(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", $"edit {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, + }); + } }