using System.Collections.Concurrent; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Worker.Hub; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Worktrees; public sealed class WorktreeMaintenanceService { public sealed record CleanupResult(int Removed, IReadOnlyList RemovedTaskIds); public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList RemovedTaskIds); public sealed record ForceRemoveResult(bool Removed, string? Reason, bool BranchDeleted); // opKey/phase for the OperationProgress broadcast. Not task-bound, so opKey is a stable // string rather than a task id — mirrors "startup-recovery"/"worktree-cleanup" in HubBroadcaster. public const string OpKey = "worktree-maintenance"; public const string PhaseMaintaining = "maintaining-worktrees"; private readonly IDbContextFactory _dbFactory; private readonly GitService _git; private readonly ILogger _logger; private readonly HubBroadcaster? _broadcaster; public WorktreeMaintenanceService( IDbContextFactory dbFactory, GitService git, ILogger logger, HubBroadcaster? broadcaster = null) { _dbFactory = dbFactory; _git = git; _logger = logger; _broadcaster = broadcaster; } private Task ReportProgress(int current, int total) => _broadcaster?.OperationProgress(OpKey, PhaseMaintaining, current, total) ?? Task.CompletedTask; public async Task CleanupFinishedAsync(string? listId = null, CancellationToken ct = default) { using var context = _dbFactory.CreateDbContext(); var query = from w in context.Worktrees join t in context.Tasks on w.TaskId equals t.Id join l in context.Lists on t.ListId equals l.Id where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded || (w.State == WorktreeState.Active && t.Status != ClaudeDo.Data.Models.TaskStatus.Running) select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId, w.State, w.BaseCommit, }; if (!string.IsNullOrEmpty(listId)) query = query.Where(x => x.ListId == listId); var candidates = await query.AsNoTracking().ToListAsync(ct); var rows = new List(); foreach (var c in candidates) { // An Active row only counts as finished when it was merged outside ClaudeDo (e.g. a // squash PR) and holds nothing uncommitted -- TryRemoveAsync would delete that too. if (c.State != WorktreeState.Active || (IsGitWorktree(c.Row.Path) && await IsContentInMainAsync(c.Row.Path, c.BaseCommit, ct) && !await TryHasChangesAsync(c.Row.Path, ct))) rows.Add(c.Row); } int removed = 0; var removedTaskIds = new List(); for (var i = 0; i < rows.Count; i++) { await ReportProgress(i + 1, rows.Count); var (rowRemoved, _) = await TryRemoveAsync(rows[i], force: false, ct); if (rowRemoved) { removed++; removedTaskIds.Add(rows[i].TaskId); } } return new CleanupResult(removed, removedTaskIds); } public async Task ResetAllAsync(CancellationToken ct = default) { using var context = _dbFactory.CreateDbContext(); var running = await context.Tasks.AsNoTracking() .CountAsync(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running, ct); if (running > 0) return new ResetResult(0, 0, Blocked: true, RunningTasks: running, Array.Empty()); var rows = await (from w in context.Worktrees join t in context.Tasks on w.TaskId equals t.Id join l in context.Lists on t.ListId equals l.Id select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir)) .AsNoTracking() .ToListAsync(ct); int removed = 0; var removedTaskIds = new List(); for (var i = 0; i < rows.Count; i++) { await ReportProgress(i + 1, rows.Count); var (rowRemoved, _) = await TryRemoveAsync(rows[i], force: true, ct); if (rowRemoved) { removed++; removedTaskIds.Add(rows[i].TaskId); } } return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0, removedTaskIds); } public async Task> GetOverviewAsync( string? listId, CancellationToken ct = default) { using var context = _dbFactory.CreateDbContext(); var query = from w in context.Worktrees join t in context.Tasks on w.TaskId equals t.Id join l in context.Lists on t.ListId equals l.Id select new { w.TaskId, t.Number, t.Title, t.Status, ListId = l.Id, ListName = l.Name, w.Path, w.BranchName, w.BaseCommit, w.State, w.DiffStat, w.CreatedAt, }; if (!string.IsNullOrEmpty(listId)) query = query.Where(x => x.ListId == listId); var rows = await query.AsNoTracking().ToListAsync(ct); return await Task.WhenAll(rows.Select(async x => { var isGitWorktree = IsGitWorktree(x.Path); var contentInMain = isGitWorktree && (x.State is WorktreeState.Active or WorktreeState.Kept) && await IsContentInMainAsync(x.Path, x.BaseCommit, ct); return new WorktreeOverviewRow( x.TaskId, x.Number, x.Title, x.Status, x.ListId, x.ListName, x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt, PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path), isGitWorktree, contentInMain); })); } /// True when holds a git checkout (.git file or dir), not just a directory. // A bare directory check isn't enough: git walks up from an empty dir into whatever repo encloses it. internal static bool IsGitWorktree(string? path) => !string.IsNullOrWhiteSpace(path) && (File.Exists(Path.Combine(path, ".git")) || Directory.Exists(Path.Combine(path, ".git"))); // ponytail: 30s TTL per path -- the overview modal reloads every 4s and this costs ~5 git // processes per row; key on (path, HEAD, target sha) if a stale answer ever matters. private static readonly TimeSpan ContentInMainTtl = TimeSpan.FromSeconds(30); private readonly ConcurrentDictionary _contentInMainCache = new(); /// /// The worktree's own commits are already in the default branch -- HEAD is an ancestor of it, or /// merging HEAD would not change its tree (squash PR). Checks local refs only, never fetches. /// private async Task IsContentInMainAsync(string path, string? baseCommit, CancellationToken ct) { if (string.IsNullOrWhiteSpace(baseCommit)) return false; if (_contentInMainCache.TryGetValue(path, out var hit) && DateTime.UtcNow - hit.At < ContentInMainTtl) return hit.Result; bool result; try { // No commits beyond the base: trivially "in main", but there is nothing merged. if (await _git.IsAncestorAsync(path, "HEAD", baseCommit, ct) != false) result = false; else if (await ResolveDefaultRefAsync(path, ct) is not { } target) result = false; else if (await _git.IsAncestorAsync(path, "HEAD", target, ct) == true) result = true; else { var preview = await _git.PreviewMergeAsync(path, target, "HEAD", ct); result = preview.Clean && preview.TreeOid == await _git.RevParseAsync(path, $"{target}^{{tree}}", ct); } } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogDebug(ex, "content-in-main check failed for {Path}", path); result = false; } _contentInMainCache[path] = (DateTime.UtcNow, result); return result; } /// origin/HEAD's branch, else the first existing of origin/main, origin/master, main, master. private async Task ResolveDefaultRefAsync(string dir, CancellationToken ct) { var remoteHead = await _git.GetRemoteHeadBranchAsync(dir, ct); string[] candidates = remoteHead is null ? ["origin/main", "origin/master", "main", "master"] : [$"origin/{remoteHead}"]; foreach (var c in candidates) { try { await _git.RevParseAsync(dir, $"{c}^{{commit}}", ct); return c; } catch (InvalidOperationException) { } } return null; } private async Task TryHasChangesAsync(string path, CancellationToken ct) { // Can't tell -> treat as dirty, so a bulk cleanup never deletes it. try { return await _git.HasChangesAsync(path, ct); } catch (OperationCanceledException) { throw; } catch { return true; } } /// The repo owning the worktree (parent of its git-common-dir); the list's working dir when the path is no git checkout anymore. private async Task ResolveOwnerRepoAsync(WorktreeRow row, CancellationToken ct) { if (!IsGitWorktree(row.Path)) return row.WorkingDir; var commonDir = await _git.GetCommonDirAsync(row.Path, ct); if (commonDir is null) return row.WorkingDir; var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(commonDir)); // Non-bare repo: ...//.git -> . A bare repo's common dir is the repo itself. return Path.GetFileName(full).Equals(".git", StringComparison.OrdinalIgnoreCase) ? Path.GetDirectoryName(full) : full; } public async Task ForceRemoveAsync(string taskId, CancellationToken ct = default) { using var context = _dbFactory.CreateDbContext(); var row = await (from w in context.Worktrees join t in context.Tasks on w.TaskId equals t.Id join l in context.Lists on t.ListId equals l.Id where w.TaskId == taskId select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), Status = t.Status }) .AsNoTracking() .FirstOrDefaultAsync(ct); if (row is null) return new ForceRemoveResult(false, "worktree not found", false); if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running) return new ForceRemoveResult(false, "task is currently running", false); var (ok, branchDeleted) = await TryRemoveAsync(row.Row, force: true, ct); return new ForceRemoveResult(ok, ok ? null : "remove failed", branchDeleted); } private async Task<(bool Removed, bool BranchDeleted)> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct) { // The list's working dir isn't necessarily the repo that owns the worktree (a list can point // at another repo than the task ran in) -- prune/branch -D there would silently miss. var repoDir = await ResolveOwnerRepoAsync(row, ct); var repoDirExists = !string.IsNullOrWhiteSpace(repoDir) && Directory.Exists(repoDir); bool dirRemoved; if (repoDirExists) { dirRemoved = true; try { await _git.WorktreeRemoveAsync(repoDir!, row.Path, force, ct); } catch (Exception ex) { // "is not a working tree" means git already forgot this worktree (pruned, or // removed by hand). Deleting the directory is then the whole job, not a fallback // from a real failure -- warning about it floods the footer log strip when a // batch of stale rows is cleaned up. if (IsAlreadyUnregistered(ex)) _logger.LogDebug(ex, "git no longer tracks {Path}; deleting the directory", row.Path); else _logger.LogWarning(ex, "git worktree remove failed for {Path}; falling back to directory delete", row.Path); try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); } catch (Exception delEx) { _logger.LogError(delEx, "Directory.Delete fallback also failed for {Path}", row.Path); dirRemoved = false; } } if (Directory.Exists(row.Path)) dirRemoved = false; } else { dirRemoved = true; try { if (Directory.Exists(row.Path)) Directory.Delete(row.Path, recursive: true); } catch (Exception ex) { _logger.LogError(ex, "Directory.Delete failed for {Path}", row.Path); dirRemoved = false; } } // Drop the DB row only when the on-disk worktree is gone; otherwise we'd silently // strand a directory while reporting success. if (!dirRemoved) return (false, false); // Branch cleanup: otherwise rerunning the task hits "branch already exists". // Prune first so git no longer thinks the branch is checked out by a phantom worktree. bool branchDeleted = false; if (repoDirExists) { try { await _git.WorktreePruneAsync(repoDir!, ct); } catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", repoDir); } if (!string.IsNullOrWhiteSpace(row.BranchName)) { try { await _git.BranchDeleteAsync(repoDir!, row.BranchName, force: true, ct); branchDeleted = true; } catch (Exception ex) { // Already deleted -- the normal case once the branch has been merged. if (IsBranchAlreadyGone(ex)) { _logger.LogDebug(ex, "branch {Branch} was already deleted", row.BranchName); branchDeleted = true; } else _logger.LogWarning(ex, "Failed to delete branch {Branch} for worktree {Path}", row.BranchName, row.Path); } } } using var context = _dbFactory.CreateDbContext(); await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct); return (true, branchDeleted); } // Both are "already done" outcomes, not failures — see the call sites. private static bool IsAlreadyUnregistered(Exception ex) => ex.Message.Contains("is not a working tree", StringComparison.OrdinalIgnoreCase); private static bool IsBranchAlreadyGone(Exception ex) => ex.Message.Contains("' not found", StringComparison.Ordinal); private sealed record WorktreeRow(string TaskId, string Path, string BranchName, string? WorkingDir); }