From cfb9ca1ca4ed177802645c5ed6fc570b87a3efaa Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 21 Apr 2026 15:55:35 +0200 Subject: [PATCH] feat(worker): add WorktreeMaintenanceService for idle-worktree cleanup --- src/ClaudeDo.Data/Git/GitService.cs | 50 +++++ .../Repositories/WorktreeRepository.cs | 13 ++ src/ClaudeDo.Worker/Program.cs | 1 + src/ClaudeDo.Worker/Runner/WorktreeManager.cs | 50 ++++- .../Services/WorktreeMaintenanceService.cs | 128 +++++++++++ .../WorktreeMaintenanceServiceTests.cs | 203 ++++++++++++++++++ 6 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 src/ClaudeDo.Worker/Services/WorktreeMaintenanceService.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 8a11ce7..5d8b791 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -73,6 +73,21 @@ public sealed class GitService return stdout; } + /// + /// Full diff between and the current working tree + /// (committed-on-branch changes + uncommitted work). Used for viewing a Claude + /// task's total impact relative to where the branch started. + /// + public async Task GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(worktreePath, + ["diff", baseRef], ct); + if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout)) + return stdout; + // Fallback: whatever the worktree has vs HEAD (uncommitted only). + return await GetDiffAsync(worktreePath, ct); + } + public async Task DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default) { var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, @@ -93,6 +108,41 @@ public sealed class GitService throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}"); } + public async Task> ListWorktreePathsForBranchAsync(string repoDir, string branchName, CancellationToken ct = default) + { + var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct); + if (exitCode != 0) return new(); + + var target = $"refs/heads/{branchName}"; + var paths = new List(); + string? currentPath = null; + foreach (var raw in stdout.Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.StartsWith("worktree ", StringComparison.Ordinal)) + { + currentPath = line["worktree ".Length..].Trim(); + } + else if (line.StartsWith("branch ", StringComparison.Ordinal)) + { + var b = line["branch ".Length..].Trim(); + if (b == target && currentPath is not null) paths.Add(currentPath); + } + else if (string.IsNullOrWhiteSpace(line)) + { + currentPath = null; + } + } + return paths; + } + + public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct); + if (exitCode != 0) + throw new InvalidOperationException($"git worktree prune failed (exit {exitCode}): {stderr}"); + } + public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default) { var flag = force ? "-D" : "-d"; diff --git a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs index 39bf109..6499aa5 100644 --- a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +++ b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs @@ -40,4 +40,17 @@ public sealed class WorktreeRepository { await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct); } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + return await _context.Worktrees.AsNoTracking().ToListAsync(ct); + } + + public async Task> GetByStatesAsync( + IReadOnlyCollection states, CancellationToken ct = default) + { + return await _context.Worktrees.AsNoTracking() + .Where(w => states.Contains(w.State)) + .ToListAsync(ct); + } } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index fbf8c9a..bdc61b0 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -29,6 +29,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Agent file management. var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents"); diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index a0869f9..09aba22 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -39,8 +39,19 @@ public sealed class WorktreeManager var branchName = $"claudedo/{idForBranch}"; var slug = CommitMessageBuilder.ToSlug(list.Name); - var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase) - ? Path.Combine(_cfg.CentralWorktreeRoot, slug, task.Id) + 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); @@ -48,12 +59,43 @@ public sealed class WorktreeManager // Ensure parent directory exists. Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!); - // Create the worktree (this also creates the directory). - await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct); + // 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 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. 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, diff --git a/src/ClaudeDo.Worker/Services/WorktreeMaintenanceService.cs b/src/ClaudeDo.Worker/Services/WorktreeMaintenanceService.cs new file mode 100644 index 0000000..32e95c6 --- /dev/null +++ b/src/ClaudeDo.Worker/Services/WorktreeMaintenanceService.cs @@ -0,0 +1,128 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Services; + +public sealed class WorktreeMaintenanceService +{ + public sealed record CleanupResult(int Removed); + public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks); + + private readonly IDbContextFactory _dbFactory; + private readonly GitService _git; + private readonly ILogger _logger; + + public WorktreeMaintenanceService( + IDbContextFactory dbFactory, + GitService git, + ILogger logger) + { + _dbFactory = dbFactory; + _git = git; + _logger = logger; + } + + public async Task CleanupFinishedAsync(CancellationToken ct = default) + { + using var context = _dbFactory.CreateDbContext(); + 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 + where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded + select new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir)) + .AsNoTracking() + .ToListAsync(ct); + + int removed = 0; + foreach (var row in rows) + { + if (await TryRemoveAsync(row, force: false, ct)) + removed++; + } + return new CleanupResult(removed); + } + + 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); + + 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; + foreach (var row in rows) + { + if (await TryRemoveAsync(row, force: true, ct)) + removed++; + } + return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0); + } + + private async Task TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct) + { + var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir); + + if (repoDirExists) + { + try + { + await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct); + } + catch (Exception ex) + { + _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); + } + } + } + else + { + 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); + } + } + + // 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. + if (repoDirExists) + { + try { await _git.WorktreePruneAsync(row.WorkingDir!, ct); } + catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", row.WorkingDir); } + + if (!string.IsNullOrWhiteSpace(row.BranchName)) + { + try + { + await _git.BranchDeleteAsync(row.WorkingDir!, row.BranchName, force: true, ct); + } + catch (Exception ex) + { + _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; + } + + private sealed record WorktreeRow(string TaskId, string Path, string BranchName, string? WorkingDir); +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs new file mode 100644 index 0000000..3caddaa --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs @@ -0,0 +1,203 @@ +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Services; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Services; + +public class WorktreeMaintenanceServiceTests : IDisposable +{ + private readonly List _dbs = new(); + private readonly List _repos = new(); + + private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); + + private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; } + private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; } + + public void Dispose() + { + foreach (var d in _dbs) try { d.Dispose(); } catch { } + foreach (var r in _repos) try { r.Dispose(); } catch { } + } + + private static (ListEntity list, TaskEntity task) MakeEntities(string workingDir, ClaudeDo.Data.Models.TaskStatus status = ClaudeDo.Data.Models.TaskStatus.Done) + { + var list = new ListEntity + { + Id = Guid.NewGuid().ToString(), + Name = "test", + WorkingDir = workingDir, + DefaultCommitType = "feat", + CreatedAt = DateTime.UtcNow, + }; + var task = MakeTaskForList(list.Id, status); + return (list, task); + } + + private static TaskEntity MakeTaskForList(string listId, ClaudeDo.Data.Models.TaskStatus status) + => new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Description = null, + Status = status, + CreatedAt = DateTime.UtcNow, + }; + + private static async Task CreateWorktreeAsync(GitService git, string repoDir, string taskId) + { + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + var baseCommit = await git.RevParseHeadAsync(repoDir); + await git.WorktreeAddAsync(repoDir, $"test/{taskId}", wtPath, baseCommit); + return wtPath; + } + + [Fact] + public async Task CleanupFinished_Removes_Merged_And_Discarded_But_Skips_Active() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (list, activeTask) = MakeEntities(repo.RepoDir); + var mergedTask = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done); + var discardedTask = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done); + + var activeWt = await CreateWorktreeAsync(git, repo.RepoDir, activeTask.Id); + var mergedWt = await CreateWorktreeAsync(git, repo.RepoDir, mergedTask.Id); + var discardedWt = await CreateWorktreeAsync(git, repo.RepoDir, discardedTask.Id); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(list); + var taskRepo = new TaskRepository(ctx); + await taskRepo.AddAsync(activeTask); + await taskRepo.AddAsync(mergedTask); + await taskRepo.AddAsync(discardedTask); + var wtRepo = new WorktreeRepository(ctx); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = activeTask.Id, Path = activeWt, BranchName = $"test/{activeTask.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = mergedTask.Id, Path = mergedWt, BranchName = $"test/{mergedTask.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow, + }); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = discardedTask.Id, Path = discardedWt, BranchName = $"test/{discardedTask.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Discarded, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.CleanupFinishedAsync(); + + Assert.Equal(2, result.Removed); + Assert.True(Directory.Exists(activeWt)); + Assert.False(Directory.Exists(mergedWt)); + Assert.False(Directory.Exists(discardedWt)); + + using var checkCtx = db.CreateContext(); + var remaining = await new WorktreeRepository(checkCtx).GetAllAsync(); + Assert.Single(remaining); + Assert.Equal(activeTask.Id, remaining[0].TaskId); + } + + [Fact] + public async Task ResetAll_Blocked_When_Running_Task_Exists() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (list, task) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Running); + var wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(list); + await new TaskRepository(ctx).AddAsync(task); + await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity + { + TaskId = task.Id, Path = wt, BranchName = $"test/{task.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.ResetAllAsync(); + + Assert.True(result.Blocked); + Assert.Equal(1, result.RunningTasks); + Assert.Equal(0, result.Removed); + Assert.True(Directory.Exists(wt)); + + // Cleanup + try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { } + } + + [Fact] + public async Task ResetAll_Removes_All_When_No_Running_Tasks() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = NewRepo(); + var git = new GitService(); + var db = NewDb(); + + var (list, t1) = MakeEntities(repo.RepoDir, status: ClaudeDo.Data.Models.TaskStatus.Done); + var t2 = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Manual); + + var wt1 = await CreateWorktreeAsync(git, repo.RepoDir, t1.Id); + var wt2 = await CreateWorktreeAsync(git, repo.RepoDir, t2.Id); + + using (var ctx = db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(list); + var taskRepo = new TaskRepository(ctx); + await taskRepo.AddAsync(t1); + await taskRepo.AddAsync(t2); + var wtRepo = new WorktreeRepository(ctx); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = t1.Id, Path = wt1, BranchName = $"test/{t1.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + await wtRepo.AddAsync(new WorktreeEntity + { + TaskId = t2.Id, Path = wt2, BranchName = $"test/{t2.Id}", + BaseCommit = repo.BaseCommit, State = WorktreeState.Kept, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.ResetAllAsync(); + + Assert.False(result.Blocked); + Assert.Equal(2, result.Removed); + Assert.Equal(2, result.TasksAffected); + Assert.False(Directory.Exists(wt1)); + Assert.False(Directory.Exists(wt2)); + + using var checkCtx = db.CreateContext(); + var remaining = await new WorktreeRepository(checkCtx).GetAllAsync(); + Assert.Empty(remaining); + } +}