Files
ClaudeDo/src/ClaudeDo.Worker/Runner/WorktreeManager.cs

209 lines
9.1 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Runner;
public sealed record WorktreeContext(string WorktreePath, string BranchName, string BaseCommit);
public sealed class WorktreeManager
{
private readonly GitService _git;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerConfig _cfg;
private readonly ILogger<WorktreeManager> _logger;
public WorktreeManager(GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg, ILogger<WorktreeManager> logger)
{
_git = git;
_dbFactory = dbFactory;
_cfg = cfg;
_logger = logger;
}
public async Task<WorktreeContext> CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct)
{
var workingDir = list.WorkingDir
?? throw new InvalidOperationException("list.WorkingDir is null");
if (!await _git.IsGitRepoAsync(workingDir, ct))
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
// Use the full task id (dashes stripped) in the branch name so
// two GUIDs sharing an 8-char prefix cannot collide on the same branch.
var idForBranch = task.Id.Replace("-", "");
var branchName = $"claudedo/{idForBranch}";
var slug = CommitMessageBuilder.ToSlug(list.Name);
string strategy;
string? centralRoot;
using (var settingsCtx = _dbFactory.CreateDbContext())
{
var settings = await new AppSettingsRepository(settingsCtx).GetAsync(ct);
strategy = settings.WorktreeStrategy;
centralRoot = !string.IsNullOrWhiteSpace(settings.CentralWorktreeRoot)
? settings.CentralWorktreeRoot
: _cfg.CentralWorktreeRoot;
}
var worktreePath = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug, task.Id)
: Path.Combine(Path.GetDirectoryName(workingDir)!, ".claudedo-worktrees", slug, task.Id);
worktreePath = Path.GetFullPath(worktreePath);
// Ensure parent directory exists.
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
// Create the worktree. If a stale branch from a previous run remains
// (e.g. after force-remove), delete it and retry once.
try
{
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Branch {Branch} already exists; cleaning phantom worktrees and retrying", branchName);
// Find and forcefully remove any existing worktree registered against this branch.
List<string> stalePaths;
try { stalePaths = await _git.ListWorktreePathsForBranchAsync(workingDir, branchName, ct); }
catch (Exception listEx)
{
_logger.LogWarning(listEx, "git worktree list failed during self-heal");
stalePaths = new();
}
foreach (var stalePath in stalePaths)
{
try { await _git.WorktreeRemoveAsync(workingDir, stalePath, force: true, ct); }
catch (Exception wrEx) { _logger.LogWarning(wrEx, "Failed to remove stale worktree at {Path}", stalePath); }
}
try { await _git.WorktreePruneAsync(workingDir, ct); }
catch (Exception pruneEx) { _logger.LogWarning(pruneEx, "git worktree prune failed during self-heal"); }
try { await _git.BranchDeleteAsync(workingDir, branchName, force: true, ct); }
catch (Exception delEx) { _logger.LogWarning(delEx, "git branch -D failed during self-heal"); }
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
}
// Insert worktrees row AFTER git succeeds — if git throws, no row is created.
// If the insert itself fails, the worktree is already on disk with nothing
// tracking it: remove it (best-effort, non-cancellable) before rethrowing.
try
{
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
// Drop any stale row from a prior run (force-remove may have left the DB side behind).
await wtRepo.DeleteAsync(task.Id, ct);
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = task.Id,
Path = worktreePath,
BranchName = branchName,
BaseCommit = baseCommit,
HeadCommit = null,
DiffStat = null,
State = WorktreeState.Active,
CreatedAt = DateTime.UtcNow,
}, ct);
}
catch (Exception dbEx)
{
_logger.LogError(dbEx,
"Failed to record worktree row for task {TaskId}; removing orphaned worktree at {Path}",
task.Id, worktreePath);
try { await _git.WorktreeRemoveAsync(workingDir, worktreePath, force: true, CancellationToken.None); }
catch (Exception rmEx) { _logger.LogWarning(rmEx, "Failed to remove orphaned worktree at {Path}", worktreePath); }
try { await _git.BranchDeleteAsync(workingDir, branchName, force: true, CancellationToken.None); }
catch (Exception delEx) { _logger.LogWarning(delEx, "Failed to delete orphaned branch {Branch}", branchName); }
throw;
}
_logger.LogInformation("Created worktree for task {TaskId} at {Path} (branch {Branch}, base {Base})",
task.Id, worktreePath, branchName, baseCommit);
return new WorktreeContext(worktreePath, branchName, baseCommit);
}
/// <returns>true if a commit was made; false if no changes.</returns>
public async Task<bool> CommitIfChangedAsync(WorktreeContext ctx, TaskEntity task, ListEntity list, CancellationToken ct)
{
if (!await _git.HasChangesAsync(ctx.WorktreePath, ct))
{
_logger.LogInformation("No changes in worktree for task {TaskId}, skipping commit", task.Id);
return false;
}
await _git.AddAllAsync(ctx.WorktreePath, ct);
var message = CommitMessageBuilder.Build(
task.CommitType, list.Name, task.Title, task.Description, task.Id);
await _git.CommitAsync(ctx.WorktreePath, message, ct);
var head = await _git.RevParseHeadAsync(ctx.WorktreePath, ct);
var diffStat = await _git.DiffStatAsync(ctx.WorktreePath, ctx.BaseCommit, head, ct);
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
await wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct);
_logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head);
return true;
}
public async Task DiscardAsync(WorktreeEntity wt, string workingDir, CancellationToken ct)
{
try
{
await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: true, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "git worktree remove failed for {Path}", wt.Path);
throw;
}
try
{
await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: true, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "git branch -D {Branch} failed after worktree removal; continuing", wt.BranchName);
}
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(wt.TaskId, WorktreeState.Discarded, ct);
_logger.LogInformation("Discarded worktree for task {TaskId} (branch {Branch})", wt.TaskId, wt.BranchName);
}
// Improvement children (parent is a non-planning task with its own worktree) branch
// from the parent's recorded HEAD so they build on the parent's not-yet-merged work.
// Planning children and standalone tasks base off the list's current HEAD.
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
{
if (task.ParentTaskId is not null)
{
using var ctx = _dbFactory.CreateDbContext();
var parent = await ctx.Tasks.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == task.ParentTaskId, ct);
if (parent is not null && parent.PlanningPhase == PlanningPhase.None)
{
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit;
if (parentHead is not null)
return parentHead;
}
}
return await _git.RevParseHeadAsync(workingDir, ct);
}
}