diff --git a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs index ee4ddde..69c4569 100644 --- a/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs +++ b/src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs @@ -71,6 +71,30 @@ public sealed class WorktreeMaintenanceService return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0); } + public async Task> GetOverviewAsync( + string? listId, CancellationToken ct = default) + { + using var context = _dbFactory.CreateDbContext(); + var query = 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 + { + w.TaskId, t.Title, t.Status, ListId = l.Id, ListName = l.Name, + w.Path, w.BranchName, w.State, w.DiffStat, w.CreatedAt, + }; + + if (!string.IsNullOrEmpty(listId)) + query = query.Where(x => x.ListId == listId); + + var rows = await query.AsNoTracking().ToListAsync(ct); + + return rows.Select(x => new WorktreeOverviewRow( + x.TaskId, x.Title, x.Status, x.ListId, x.ListName, + x.Path, x.BranchName, x.State, x.DiffStat, x.CreatedAt, + PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList(); + } + private async Task TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct) { var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir); diff --git a/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs b/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs new file mode 100644 index 0000000..da46ca6 --- /dev/null +++ b/src/ClaudeDo.Worker/Worktrees/WorktreeOverviewRow.cs @@ -0,0 +1,17 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Worktrees; + +public sealed record WorktreeOverviewRow( + string TaskId, + string TaskTitle, + TaskStatus TaskStatus, + string ListId, + string ListName, + string Path, + string BranchName, + WorktreeState State, + string? DiffStat, + DateTime CreatedAt, + bool PathExistsOnDisk); diff --git a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs index 3a4a8b7..9ff6b1d 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs @@ -250,4 +250,124 @@ public class WorktreeMaintenanceServiceTests : IDisposable 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.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.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.Instance); + + var rows = await svc.GetOverviewAsync(null, CancellationToken.None); + + Assert.Single(rows); + Assert.False(rows[0].PathExistsOnDisk); + } }