diff --git a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs index b9ab7f42..adeead3f 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs @@ -2,6 +2,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Planning; @@ -44,32 +45,47 @@ public sealed class PlanningAggregator { using var ctx = _dbFactory.CreateDbContext(); var parent = await ctx.Tasks + .Include(t => t.List) .Include(t => t.Worktree) .Include(t => t.Children).ThenInclude(c => c.Worktree) .SingleOrDefaultAsync(t => t.Id == planningTaskId, ct); if (parent is null) return new List(); + // Once a child was absorbed, the parent's branch also carries the children's commits; its own + // share ends where the first child forked off. + var children = parent.Children.OrderBy(c => c.SortOrder).ToList(); + var parentOwnHead = children.Any(c => c.Worktree is { State: WorktreeState.Merged, MergeCommit: null }) + ? children.Select(c => c.Worktree?.BaseCommit).FirstOrDefault(b => b is not null) + : null; + var nodes = new List(); // An improvement parent carries its own code branch — fold it in first so the // combined diff matches what the tree-merge will produce. if (parent.PlanningPhase == PlanningPhase.None && parent.Worktree is { State: WorktreeState.Active }) nodes.Add(parent); - nodes.AddRange(parent.Children.OrderBy(c => c.SortOrder)); + nodes.AddRange(children); var result = new List(); foreach (var node in nodes) { if (node.Worktree is null) continue; var wt = node.Worktree; - var head = wt.HeadCommit ?? await _git.RevParseHeadAsync(wt.Path, ct); + var isOwnShare = node == parent && parentOwnHead is not null; + // An absorbed child's worktree is gone; its commits live on in the unit branch. + var isGone = !Directory.Exists(wt.Path) && wt.HeadCommit is not null && parent.List.WorkingDir is not null; + string head; string unified; try { - unified = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, ct: ct); + head = isOwnShare ? parentOwnHead! : wt.HeadCommit ?? await _git.RevParseHeadAsync(wt.Path, ct); + unified = isOwnShare || isGone + ? await _git.GetCommitRangeDiffAsync(parent.List.WorkingDir ?? wt.Path, wt.BaseCommit, head, ct: ct) + : await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, ct: ct); } catch (Exception ex) { _logger.LogWarning(ex, "diff failed for node {Id}", node.Id); + head = wt.HeadCommit ?? wt.BaseCommit; unified = ""; } result.Add(new SubtaskDiff( @@ -83,6 +99,15 @@ public sealed class PlanningAggregator { var (planning, repoDir, childSubtasks) = await LoadPlanningContextAsync(planningTaskId, ct); + // A parent that owns the unit branch already is the integration: unitBase..unitHead, no + // checkout in the main checkout. Legacy units (a Done child never absorbed) keep the old path. + if (planning.Worktree is { State: WorktreeState.Active } unitWt + && !childSubtasks.Any(c => c.Status == TaskStatus.Done && c.Worktree is { State: WorktreeState.Active })) + { + var unitDiff = await _git.GetCommitRangeDiffAsync(repoDir, unitWt.BaseCommit, unitWt.BranchName, ct: ct); + return new CombinedDiffResult.Ok(new CombinedDiffSuccess(unitWt.BranchName, unitDiff)); + } + var integrationBranch = BuildIntegrationBranchName(planning); // Reset: checkout target first (so we're never ON the integration branch when deleting it),