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);
+ }
+}