187 lines
7.2 KiB
C#
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;
|
|
}
|
|
}
|