feat(worktrees): add ForceRemoveAsync for targeted removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ClaudeDoDbContext> _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<ForceRemoveResult> 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<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
||||
{
|
||||
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
||||
|
||||
@@ -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<WorktreeMaintenanceService>.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<WorktreeMaintenanceService>.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<WorktreeMaintenanceService>.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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user