From b095a29f972c30185b1fb2818349af27013030f2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Tue, 19 May 2026 09:34:32 +0200 Subject: [PATCH] feat(worktrees): add ForceRemoveAsync for targeted removal Co-Authored-By: Claude Sonnet 4.6 --- .../Worktrees/WorktreeMaintenanceService.cs | 24 ++++ .../WorktreeMaintenanceServiceTests.cs | 107 ++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs index 69c4569..71e1124 100644 --- a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs +++ b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs @@ -9,6 +9,7 @@ public sealed class WorktreeMaintenanceService { public sealed record CleanupResult(int Removed); public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks); + public sealed record ForceRemoveResult(bool Removed, string? Reason); private readonly IDbContextFactory _dbFactory; private readonly GitService _git; @@ -95,6 +96,29 @@ public sealed class WorktreeMaintenanceService PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList(); } + 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"); + + if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running) + return new ForceRemoveResult(false, "task is currently running"); + + var ok = await TryRemoveAsync(row.Row, force: true, ct); + return new ForceRemoveResult(ok, ok ? null : "remove failed"); + } + private async Task TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct) { var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir); diff --git a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs index 9ff6b1d..1d5f8f9 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs @@ -370,4 +370,111 @@ public class WorktreeMaintenanceServiceTests : IDisposable Assert.Single(rows); Assert.False(rows[0].PathExistsOnDisk); } + + [Fact] + public async Task ForceRemove_Removes_Active_Worktree() + { + 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.Done); + 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.ForceRemoveAsync(task.Id, CancellationToken.None); + + Assert.True(result.Removed); + Assert.Null(result.Reason); + Assert.False(Directory.Exists(wt)); + + using var checkCtx = db.CreateContext(); + var remaining = await new WorktreeRepository(checkCtx).GetAllAsync(); + Assert.Empty(remaining); + } + + [Fact] + public async Task ForceRemove_Blocked_When_Task_Running() + { + 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.ForceRemoveAsync(task.Id, CancellationToken.None); + + Assert.False(result.Removed); + Assert.Equal("task is currently running", result.Reason); + Assert.True(Directory.Exists(wt)); + + try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { } + } + + [Fact] + public async Task ForceRemove_Removes_Phantom_Row() + { + 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); + var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}"); + + 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 = phantomPath, BranchName = $"test/{task.Id}-phantom", + BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, + }); + } + + var svc = new WorktreeMaintenanceService( + db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.ForceRemoveAsync(task.Id, CancellationToken.None); + + Assert.True(result.Removed); + + using var checkCtx = db.CreateContext(); + var remaining = await new WorktreeRepository(checkCtx).GetAllAsync(); + Assert.Empty(remaining); + } }