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 _dbFactory; private readonly WorkerConfig _cfg; private readonly ILogger _logger; private readonly HubBroadcaster? _broadcaster; private readonly RunningOperationRegistry _operations; private readonly AzureDevOpsClient? _azdo; public WorktreeManager( GitService git, IDbContextFactory dbFactory, WorkerConfig cfg, ILogger 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; /// /// Branch leaf and folder name: {nummer}-{slug}. 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. /// 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}"; } /// Branch-Präfix nach work-conventions: feat → feature, Typen ohne eigenes Präfix → chore. 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 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 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); } /// true if a commit was made; false if no changes. public async Task 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); } /// Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit). private async Task 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); } /// /// 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 /// list_config.base_branch, else the current HEAD — in a company repo off a freshly fetched /// origin/<basis> (). /// private async Task 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); } /// /// Fetches the basis (base_branch, else the ADO repo's default branch, else origin/HEAD) and /// returns origin/<basis>'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. /// private async Task 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); } /// Pulls the local basis up to origin/<basis> without ever touching a dirty or diverged checkout. 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 TryRevParseAsync(string workingDir, string revision, CancellationToken ct) { try { return await _git.RevParseAsync(workingDir, revision, ct); } catch (InvalidOperationException) { return null; } } }