201 lines
8.0 KiB
C#
201 lines
8.0 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 parent = await ctx.Tasks
|
|
.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<SubtaskDiff>();
|
|
|
|
var nodes = new List<TaskEntity>();
|
|
// 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));
|
|
|
|
var result = new List<SubtaskDiff>();
|
|
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);
|
|
string unified;
|
|
try
|
|
{
|
|
unified = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "diff failed for node {Id}", node.Id);
|
|
unified = "";
|
|
}
|
|
result.Add(new SubtaskDiff(
|
|
node.Id, node.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);
|
|
|
|
var nodes = new List<TaskEntity>();
|
|
// Fold the improvement parent's own branch in first (planning parents have none).
|
|
if (planning.PlanningPhase == PlanningPhase.None && planning.Worktree is { State: WorktreeState.Active })
|
|
nodes.Add(planning);
|
|
nodes.AddRange(childSubtasks);
|
|
|
|
foreach (var node in nodes)
|
|
{
|
|
if (node.Worktree is null) continue;
|
|
var (code, _) = await _git.MergeNoFfAsync(
|
|
repoDir, node.Worktree.BranchName,
|
|
$"Integrate: {node.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(node.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.Worktree)
|
|
.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;
|
|
}
|
|
}
|