Files
ClaudeDo/src/ClaudeDo.Worker/Planning/PlanningAggregator.cs
2026-04-24 16:34:25 +02:00

187 lines
7.2 KiB
C#

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<string> 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<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly ILogger<PlanningAggregator> _logger;
public PlanningAggregator(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
ILogger<PlanningAggregator> logger)
{
_dbFactory = dbFactory;
_git = git;
_logger = logger;
}
public async Task<IReadOnlyList<SubtaskDiff>> 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<SubtaskDiff>();
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<CombinedDiffResult> 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<string> 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<TaskEntity> 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<string> 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;
}
}