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; } public async Task BuildIntegrationBranchAsync( string planningTaskId, string targetBranch, CancellationToken ct) { var (planning, repoDir, childSubtasks) = await LoadPlanningContextAsync(planningTaskId, ct); var integrationBranch = BuildIntegrationBranchName(planning); // Reset: checkout target first (so we're never ON the integration branch when deleting it), // then delete if exists, then recreate off the target branch. await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } catch { /* didn't exist */ } 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)); } public async Task CleanupIntegrationBranchAsync(string planningTaskId, CancellationToken ct) { var (planning, repoDir, _) = await LoadPlanningContextAsync(planningTaskId, ct); var branch = BuildIntegrationBranchName(planning); var current = await _git.GetCurrentBranchAsync(repoDir, ct); if (string.Equals(current, branch, StringComparison.Ordinal)) { var branches = await _git.ListLocalBranchesAsync(repoDir, ct); var target = branches.FirstOrDefault(b => b != branch && !b.StartsWith("claudedo/", StringComparison.Ordinal)) ?? "main"; await _git.CheckoutBranchAsync(repoDir, target, ct); } try { await _git.BranchDeleteAsync(repoDir, branch, force: true, ct); } catch { /* already gone — idempotent */ } } 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(); try { await p.WaitForExitAsync(ct); } catch (OperationCanceledException) { try { if (!p.HasExited) p.Kill(entireProcessTree: true); } catch { } throw; } var stdout = await stdoutTask; var stderr = await stderrTask; if (p.ExitCode != 0) throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}"); return stdout; } }