Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs
T
mika kuns 0c0bb7f24c fix(worktrees): detect external merges, clean up empty dirs, prune in owning repo
- ContentInMain: HEAD ancestor of the default branch or squash-merged
  (merge-tree == target tree), against local origin/<default> without fetch;
  list_worktrees mergedIntoMain and the overview modal ("merged (external)")
  use it, "Cleanup finished" takes clean Active rows that are in main
- cleanup_task_worktree skips the dirty check for a non-git directory
- prune / worktree remove / branch -D run in the repo owning the worktree
  (git-common-dir), not the list's working dir; branch already gone reports
  branchDeleted=true
2026-10-02 09:07:43 +02:00

662 lines
27 KiB
C#

using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Worktrees;
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.Idle);
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);
}
[Fact]
public async Task CleanupFinished_With_ListId_Only_Removes_That_Lists_Rows()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var git = new GitService();
var db = NewDb();
var (listA, taskA) = MakeEntities(repo.RepoDir);
var (listB, taskB) = MakeEntities(repo.RepoDir);
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
using (var ctx = db.CreateContext())
{
await new ListRepository(ctx).AddAsync(listA);
await new ListRepository(ctx).AddAsync(listB);
var taskRepo = new TaskRepository(ctx);
await taskRepo.AddAsync(taskA);
await taskRepo.AddAsync(taskB);
var wtRepo = new WorktreeRepository(ctx);
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
});
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
});
}
var svc = new WorktreeMaintenanceService(
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var result = await svc.CleanupFinishedAsync(listA.Id, CancellationToken.None);
Assert.Equal(1, result.Removed);
Assert.False(Directory.Exists(wtA));
Assert.True(Directory.Exists(wtB));
using var checkCtx = db.CreateContext();
var remaining = await new WorktreeRepository(checkCtx).GetAllAsync();
Assert.Single(remaining);
Assert.Equal(taskB.Id, remaining[0].TaskId);
}
[Fact]
public async Task GetOverview_Returns_All_When_ListId_Null()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var git = new GitService();
var db = NewDb();
var (listA, taskA) = MakeEntities(repo.RepoDir);
var (listB, taskB) = MakeEntities(repo.RepoDir);
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
using (var ctx = db.CreateContext())
{
await new ListRepository(ctx).AddAsync(listA);
await new ListRepository(ctx).AddAsync(listB);
await new TaskRepository(ctx).AddAsync(taskA);
await new TaskRepository(ctx).AddAsync(taskB);
var wtRepo = new WorktreeRepository(ctx);
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
BaseCommit = repo.BaseCommit, State = WorktreeState.Merged, CreatedAt = DateTime.UtcNow,
});
}
var svc = new WorktreeMaintenanceService(
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var rows = await svc.GetOverviewAsync(null, CancellationToken.None);
Assert.Equal(2, rows.Count);
Assert.Contains(rows, r => r.TaskId == taskA.Id && r.PathExistsOnDisk);
Assert.Contains(rows, r => r.TaskId == taskB.Id && r.PathExistsOnDisk);
}
[Fact]
public async Task GetOverview_Filters_By_ListId()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var git = new GitService();
var db = NewDb();
var (listA, taskA) = MakeEntities(repo.RepoDir);
var (listB, taskB) = MakeEntities(repo.RepoDir);
var wtA = await CreateWorktreeAsync(git, repo.RepoDir, taskA.Id);
var wtB = await CreateWorktreeAsync(git, repo.RepoDir, taskB.Id);
using (var ctx = db.CreateContext())
{
await new ListRepository(ctx).AddAsync(listA);
await new ListRepository(ctx).AddAsync(listB);
await new TaskRepository(ctx).AddAsync(taskA);
await new TaskRepository(ctx).AddAsync(taskB);
var wtRepo = new WorktreeRepository(ctx);
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = taskA.Id, Path = wtA, BranchName = $"test/{taskA.Id}",
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = taskB.Id, Path = wtB, BranchName = $"test/{taskB.Id}",
BaseCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
}
var svc = new WorktreeMaintenanceService(
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var rows = await svc.GetOverviewAsync(listA.Id, CancellationToken.None);
Assert.Single(rows);
Assert.Equal(taskA.Id, rows[0].TaskId);
}
[Fact]
public async Task GetOverview_Flags_PathExistsOnDisk_False_For_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 wt = await CreateWorktreeAsync(git, repo.RepoDir, task.Id);
try { await git.WorktreeRemoveAsync(repo.RepoDir, wt, force: true); } catch { }
if (Directory.Exists(wt)) Directory.Delete(wt, recursive: true);
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 rows = await svc.GetOverviewAsync(null, CancellationToken.None);
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);
}
[Fact]
public async Task ResetAll_Reports_OperationProgress_PerWorktree()
{
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.Idle);
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 hub = new CapturingHubContext();
var svc = new WorktreeMaintenanceService(
db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance, new HubBroadcaster(hub));
var result = await svc.ResetAllAsync();
Assert.Equal(2, result.Removed);
var progressCalls = hub.Proxy.Calls.Where(c => c.Method == "OperationProgress").ToList();
Assert.Equal(2, progressCalls.Count);
Assert.All(progressCalls, c =>
{
Assert.Equal(WorktreeMaintenanceService.OpKey, c.Args[0]);
Assert.Equal(WorktreeMaintenanceService.PhaseMaintaining, c.Args[1]);
Assert.Equal(2, c.Args[3]);
});
Assert.Equal(1, progressCalls[0].Args[2]);
Assert.Equal(2, progressCalls[1].Args[2]);
}
private static async Task SeedRowAsync(DbFixture db, ListEntity? list, TaskEntity task, string path, string branch,
string baseCommit, WorktreeState state = WorktreeState.Active)
{
using var ctx = db.CreateContext();
if (list is not null) await new ListRepository(ctx).AddAsync(list);
await new TaskRepository(ctx).AddAsync(task);
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
{
TaskId = task.Id, Path = path, BranchName = branch,
BaseCommit = baseCommit, State = state, CreatedAt = DateTime.UtcNow,
});
}
private static void CommitFile(string dir, string file)
{
File.WriteAllText(Path.Combine(dir, file), file);
GitRepoFixture.RunGit(dir, "add", "-A");
GitRepoFixture.RunGit(dir, "commit", "-m", $"add {file}");
}
private static void SquashMerge(string repoDir, string branch)
{
GitRepoFixture.RunGit(repoDir, "merge", "--squash", branch);
GitRepoFixture.RunGit(repoDir, "commit", "-m", $"squash {branch}");
}
[Fact]
public async Task GetOverview_ContentInMain_True_For_SquashMerged_False_For_Unmerged_Commit()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var git = new GitService();
var db = NewDb();
var (list, squashed) = MakeEntities(repo.RepoDir);
var unmerged = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
var squashedWt = await CreateWorktreeAsync(git, repo.RepoDir, squashed.Id);
var unmergedWt = await CreateWorktreeAsync(git, repo.RepoDir, unmerged.Id);
CommitFile(squashedWt, "squashed.txt");
CommitFile(unmergedWt, "unmerged.txt");
SquashMerge(repo.RepoDir, $"test/{squashed.Id}");
await SeedRowAsync(db, list, squashed, squashedWt, $"test/{squashed.Id}", repo.BaseCommit);
await SeedRowAsync(db, null, unmerged, unmergedWt, $"test/{unmerged.Id}", repo.BaseCommit);
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var rows = await svc.GetOverviewAsync(null);
Assert.True(rows.Single(r => r.TaskId == squashed.Id).ContentInMain);
Assert.False(rows.Single(r => r.TaskId == unmerged.Id).ContentInMain);
try { await git.WorktreeRemoveAsync(repo.RepoDir, squashedWt, force: true); } catch { }
try { await git.WorktreeRemoveAsync(repo.RepoDir, unmergedWt, force: true); } catch { }
}
[Fact]
public async Task CleanupFinished_Removes_Active_Worktree_Squash_Merged_Outside_ClaudeDo()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var git = new GitService();
var db = NewDb();
var (list, squashed) = MakeEntities(repo.RepoDir);
var unmerged = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
var squashedWt = await CreateWorktreeAsync(git, repo.RepoDir, squashed.Id);
var unmergedWt = await CreateWorktreeAsync(git, repo.RepoDir, unmerged.Id);
CommitFile(squashedWt, "squashed.txt");
CommitFile(unmergedWt, "unmerged.txt");
SquashMerge(repo.RepoDir, $"test/{squashed.Id}");
await SeedRowAsync(db, list, squashed, squashedWt, $"test/{squashed.Id}", repo.BaseCommit);
await SeedRowAsync(db, null, unmerged, unmergedWt, $"test/{unmerged.Id}", repo.BaseCommit);
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var result = await svc.CleanupFinishedAsync();
Assert.Equal(new[] { squashed.Id }, result.RemovedTaskIds);
Assert.False(Directory.Exists(squashedWt));
Assert.True(Directory.Exists(unmergedWt));
try { await git.WorktreeRemoveAsync(repo.RepoDir, unmergedWt, force: true); } catch { }
}
[Fact]
public async Task ForceRemove_Prunes_And_Deletes_Branch_In_Owning_Repo_Not_List_WorkingDir()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var owner = NewRepo();
var other = NewRepo();
var git = new GitService();
var db = NewDb();
// The list points at a different repo than the one the worktree was created in.
var (list, task) = MakeEntities(other.RepoDir);
var wt = await CreateWorktreeAsync(git, owner.RepoDir, task.Id);
await SeedRowAsync(db, list, task, wt, $"test/{task.Id}", owner.BaseCommit);
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
var result = await svc.ForceRemoveAsync(task.Id);
Assert.True(result.Removed);
Assert.True(result.BranchDeleted);
Assert.DoesNotContain($"test/{task.Id}", GitRepoFixture.RunGit(owner.RepoDir, "branch", "--list"));
Assert.DoesNotContain(Path.GetFileName(wt), GitRepoFixture.RunGit(owner.RepoDir, "worktree", "list"));
}
[Fact]
public async Task ForceRemove_Reports_BranchDeleted_When_Branch_Was_Already_Gone()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = NewRepo();
var db = NewDb();
var (list, task) = MakeEntities(repo.RepoDir);
var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}");
await SeedRowAsync(db, list, task, phantomPath, $"test/{task.Id}-never-existed", repo.BaseCommit);
var svc = new WorktreeMaintenanceService(db.CreateFactory(), new GitService(), NullLogger<WorktreeMaintenanceService>.Instance);
var result = await svc.ForceRemoveAsync(task.Id);
Assert.True(result.Removed);
Assert.True(result.BranchDeleted);
}
}