feat(worktrees): add GetOverviewAsync for overview modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-19 09:32:07 +02:00
parent 89f6b836ba
commit ce30d01b72
3 changed files with 161 additions and 0 deletions

View File

@@ -71,6 +71,30 @@ public sealed class WorktreeMaintenanceService
return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0); return new ResetResult(removed, rows.Count, Blocked: false, RunningTasks: 0);
} }
public async Task<IReadOnlyList<WorktreeOverviewRow>> 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<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct) private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
{ {
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir); var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);

View File

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

View File

@@ -250,4 +250,124 @@ public class WorktreeMaintenanceServiceTests : IDisposable
Assert.Single(remaining); Assert.Single(remaining);
Assert.Equal(taskB.Id, remaining[0].TaskId); 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);
}
} }