374 lines
18 KiB
C#
374 lines
18 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.AzureDevOps;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Git;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Tickets;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
public sealed record WorktreeContext(string WorktreePath, string BranchName, string BaseCommit);
|
|
|
|
public sealed class WorktreeManager
|
|
{
|
|
// Phase token for the OperationProgress broadcast while `git worktree add` (+ the
|
|
// self-heal retry) is in flight -- stable identifier, localized by the UI via the
|
|
// pre-existing "ops.worker.creatingWorktree" key. opKey is the task id.
|
|
public const string PhaseCreatingWorktree = "creating_worktree";
|
|
|
|
private readonly GitService _git;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly WorkerConfig _cfg;
|
|
private readonly ILogger<WorktreeManager> _logger;
|
|
private readonly HubBroadcaster? _broadcaster;
|
|
private readonly RunningOperationRegistry _operations;
|
|
private readonly AzureDevOpsClient? _azdo;
|
|
|
|
public WorktreeManager(
|
|
GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg,
|
|
ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null,
|
|
RunningOperationRegistry? operations = null, AzureDevOpsClient? azdo = null)
|
|
{
|
|
_git = git;
|
|
_dbFactory = dbFactory;
|
|
_cfg = cfg;
|
|
_logger = logger;
|
|
_broadcaster = broadcaster;
|
|
_operations = operations ?? new RunningOperationRegistry();
|
|
_azdo = azdo;
|
|
}
|
|
|
|
private Task BroadcastCreating(string taskId) =>
|
|
_broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask;
|
|
|
|
/// <summary>
|
|
/// Branch leaf and folder name: <c>{nummer}-{slug}</c>. Nummer = Ticket-Id, sonst die Task-Nummer —
|
|
/// außer im Firmen-Repo ohne Ticket, dort sähe sie wie eine Ticket-Id aus und entfällt.
|
|
/// </summary>
|
|
public static string NameFor(TaskEntity task, int? ticketId, bool isCompanyRepo)
|
|
{
|
|
var titleSlug = CommitMessageBuilder.ToSlug(task.Title);
|
|
if (titleSlug.Length > 40) titleSlug = titleSlug[..40].TrimEnd('-');
|
|
var number = ticketId?.ToString() ?? (isCompanyRepo ? null : task.Number.ToString());
|
|
if (titleSlug.Length == 0) return number ?? task.Number.ToString();
|
|
return number is null ? titleSlug : $"{number}-{titleSlug}";
|
|
}
|
|
|
|
/// <summary>Branch-Präfix nach work-conventions: <c>feat</c> → <c>feature</c>, Typen ohne eigenes Präfix → <c>chore</c>.</summary>
|
|
public static string BranchTypeFor(string? commitType)
|
|
{
|
|
var t = (commitType ?? "").Trim().ToLowerInvariant();
|
|
return t switch
|
|
{
|
|
"feat" => "feature",
|
|
"fix" or "refactor" or "docs" or "test" or "chore" => t,
|
|
_ => "chore",
|
|
};
|
|
}
|
|
|
|
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}");
|
|
|
|
// A fresh `git init` repo has no HEAD commit to base a worktree on — bootstrap one.
|
|
if (await _git.EnsureHeadCommitAsync(workingDir, ct))
|
|
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
|
|
|
|
var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
|
|
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, isCompany, ct);
|
|
var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany);
|
|
var branchType = BranchTypeFor(task.CommitType);
|
|
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 parentDir = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
|
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug)
|
|
: Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug);
|
|
|
|
// Numbers restart after a DB reset, and without the claudedo/ prefix a taken name may also be
|
|
// the user's own branch — the self-heal below would force-delete it, the id suffix keeps it untouched.
|
|
if (Directory.Exists(Path.Combine(parentDir, name))
|
|
|| (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"{branchType}/{name}"))
|
|
{
|
|
var id = task.Id.Replace("-", "");
|
|
name = $"{name}-{id[..Math.Min(8, id.Length)]}";
|
|
}
|
|
|
|
var branchName = $"{branchType}/{name}";
|
|
var worktreePath = Path.GetFullPath(Path.Combine(parentDir, name));
|
|
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
|
|
|
|
// 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.
|
|
using var creating = _operations.Begin(OperationKinds.CreatingWorktree, task.Title, task.Id, task.Number, task.ListId);
|
|
await BroadcastCreating(task.Id);
|
|
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);
|
|
// Self-heal is the slow section (list -> remove -> prune -> branch delete -> retry);
|
|
// report it as its own section rather than staying silent through the retry.
|
|
await BroadcastCreating(task.Id);
|
|
|
|
// 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 isCompany = list.WorkingDir is not null
|
|
&& await CompanyRepo.IsCompanyAsync(_git, list.WorkingDir, _cfg.CompanyRemotePattern, ct);
|
|
var message = CommitMessageBuilder.Build(
|
|
task.CommitType, list.Name, task.Title, task.Description, task.Id, includeTrailer: !isCompany);
|
|
|
|
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: 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);
|
|
}
|
|
|
|
/// <summary>Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit).</summary>
|
|
private async Task<int?> ResolveTicketIdAsync(TaskEntity task, CancellationToken ct)
|
|
{
|
|
var own = TicketStatusMap.ParseBandelTicketId(task.TicketRef);
|
|
if (own is not null || task.ParentTaskId is null) return own;
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var parentRef = await ctx.Tasks.AsNoTracking()
|
|
.Where(t => t.Id == task.ParentTaskId)
|
|
.Select(t => t.TicketRef)
|
|
.FirstOrDefaultAsync(ct);
|
|
return TicketStatusMap.ParseBandelTicketId(parentRef);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A child of a parent with an Active worktree starts from the unit branch's current head, so it
|
|
/// sees every sibling absorbed before it. Unit roots (and legacy children) base off
|
|
/// <c>list_config.base_branch</c>, else the current HEAD — in a company repo off a freshly fetched
|
|
/// <c>origin/<basis></c> (<see cref="FetchCompanyBaseAsync"/>).
|
|
/// </summary>
|
|
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, bool isCompany, CancellationToken ct)
|
|
{
|
|
if (task.ParentTaskId is not null)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
|
|
if (parentWt is { State: WorktreeState.Active })
|
|
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
|
|
}
|
|
using var cfgCtx = _dbFactory.CreateDbContext();
|
|
var baseBranch = (await cfgCtx.ListConfigs.AsNoTracking()
|
|
.Where(c => c.ListId == task.ListId)
|
|
.Select(c => c.BaseBranch)
|
|
.FirstOrDefaultAsync(ct)).NullIfBlank();
|
|
|
|
if (isCompany)
|
|
{
|
|
try
|
|
{
|
|
if (await FetchCompanyBaseAsync(workingDir, baseBranch, ct) is { } fetched) return fetched;
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(ex, "Fetching the company base in {working_dir} failed, continuing with the local branch", workingDir);
|
|
}
|
|
}
|
|
|
|
return baseBranch is null
|
|
? await _git.RevParseHeadAsync(workingDir, ct)
|
|
: await _git.RevParseAsync(workingDir, baseBranch, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches the basis (<c>base_branch</c>, else the ADO repo's default branch, else <c>origin/HEAD</c>) and
|
|
/// returns <c>origin/<basis></c>'s commit; the local basis is fast-forwarded best-effort. Null when no
|
|
/// basis is known or the fetch failed (offline, no credentials) — the caller then uses the local basis,
|
|
/// a fetch problem never fails the task.
|
|
/// </summary>
|
|
private async Task<string?> FetchCompanyBaseAsync(string workingDir, string? baseBranch, CancellationToken ct)
|
|
{
|
|
var remote = await _git.GetRemoteUrlAsync(workingDir, "origin", ct);
|
|
var pat = _azdo?.Pat;
|
|
var basis = baseBranch;
|
|
if (basis is null && _azdo is not null && pat is not null && AzureDevOpsClient.ParseRemote(remote) is { } repo)
|
|
{
|
|
try { basis = await _azdo.GetDefaultBranchAsync(repo, ct); }
|
|
catch (Exception ex) when (ex is AzureDevOpsException or InvalidOperationException) { _logger.LogWarning("Default branch from Azure DevOps unavailable: {reason}", ex.Message); }
|
|
}
|
|
basis ??= await _git.GetRemoteHeadBranchAsync(workingDir, ct);
|
|
if (basis is null)
|
|
{
|
|
_logger.LogWarning("Company repo {working_dir}: no base branch known, worktree starts from HEAD without fetch", workingDir);
|
|
return null;
|
|
}
|
|
|
|
var (fetchExit, fetchErr) = await _git.FetchBranchAsync(workingDir, basis, AzureDevOpsClient.GitAuthEnv(pat), ct);
|
|
if (fetchExit != 0)
|
|
{
|
|
_logger.LogWarning("git fetch origin {branch} failed in {working_dir}, continuing with the local branch: {reason}",
|
|
basis, workingDir, fetchErr);
|
|
return baseBranch is null ? await TryRevParseAsync(workingDir, basis, ct) : null;
|
|
}
|
|
|
|
await FastForwardLocalBaseAsync(workingDir, basis, ct);
|
|
return await _git.RevParseAsync(workingDir, $"refs/remotes/origin/{basis}", ct);
|
|
}
|
|
|
|
/// <summary>Pulls the local basis up to <c>origin/<basis></c> without ever touching a dirty or diverged checkout.</summary>
|
|
private async Task FastForwardLocalBaseAsync(string workingDir, string basis, CancellationToken ct)
|
|
{
|
|
string? current;
|
|
try { current = await _git.GetCurrentBranchAsync(workingDir, ct); }
|
|
catch (InvalidOperationException) { current = null; } // detached HEAD
|
|
|
|
(int ExitCode, string Stderr) result;
|
|
if (current == basis)
|
|
{
|
|
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
|
|
{
|
|
_logger.LogWarning("Local {branch} in {working_dir} has uncommitted changes, not fast-forwarded to origin", basis, workingDir);
|
|
return;
|
|
}
|
|
result = await _git.MergeFastForwardOnlyAsync(workingDir, $"refs/remotes/origin/{basis}", ct);
|
|
}
|
|
else
|
|
{
|
|
if (!(await _git.ListLocalBranchesAsync(workingDir, ct)).Contains(basis)) return;
|
|
result = await _git.FastForwardLocalBranchAsync(workingDir, basis, ct);
|
|
}
|
|
if (result.ExitCode != 0)
|
|
_logger.LogWarning("Local {branch} in {working_dir} not fast-forwarded to origin: {reason}", basis, workingDir, result.Stderr);
|
|
}
|
|
|
|
private async Task<string?> TryRevParseAsync(string workingDir, string revision, CancellationToken ct)
|
|
{
|
|
try { return await _git.RevParseAsync(workingDir, revision, ct); }
|
|
catch (InvalidOperationException) { return null; }
|
|
}
|
|
}
|