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