feat(worker): add WorktreeMaintenanceService for idle-worktree cleanup

This commit is contained in:
Mika Kuns
2026-04-21 15:55:35 +02:00
parent 62a1121571
commit cfb9ca1ca4
6 changed files with 441 additions and 4 deletions

View File

@@ -73,6 +73,21 @@ public sealed class GitService
return stdout;
}
/// <summary>
/// Full diff between <paramref name="baseRef"/> 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.
/// </summary>
public async Task<string> 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<string> 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<List<string>> 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>();
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";

View File

@@ -40,4 +40,17 @@ public sealed class WorktreeRepository
{
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
}
public async Task<List<WorktreeEntity>> GetAllAsync(CancellationToken ct = default)
{
return await _context.Worktrees.AsNoTracking().ToListAsync(ct);
}
public async Task<List<WorktreeEntity>> GetByStatesAsync(
IReadOnlyCollection<WorktreeState> states, CancellationToken ct = default)
{
return await _context.Worktrees.AsNoTracking()
.Where(w => states.Contains(w.State))
.ToListAsync(ct);
}
}

View File

@@ -29,6 +29,7 @@ builder.Services.AddSingleton<GitService>();
builder.Services.AddSingleton<WorktreeManager>();
builder.Services.AddSingleton<ClaudeArgsBuilder>();
builder.Services.AddSingleton<TaskRunner>();
builder.Services.AddSingleton<WorktreeMaintenanceService>();
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");

View File

@@ -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).
// 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<string> 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,

View File

@@ -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<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly ILogger<WorktreeMaintenanceService> _logger;
public WorktreeMaintenanceService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
ILogger<WorktreeMaintenanceService> logger)
{
_dbFactory = dbFactory;
_git = git;
_logger = logger;
}
public async Task<CleanupResult> 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<ResetResult> 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<bool> 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);
}

View File

@@ -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<DbFixture> _dbs = new();
private readonly List<GitRepoFixture> _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<string> 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<WorktreeMaintenanceService>.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<WorktreeMaintenanceService>.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<WorktreeMaintenanceService>.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);
}
}