[Fact] public async Task GetTaskWorktree_NoWorktree_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.GetTaskWorktree(task.Id, CancellationToken.None)); } [Fact] public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, wt) = await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); Assert.Equal(wt.BranchName, info.Branch); Assert.Equal(wt.BaseCommit, info.BaseCommit); Assert.Equal(0, info.Ahead); Assert.False(info.IsDirty); } [Fact] public async Task GetTaskWorktree_BeforeMerge_MergeCommitIsNull() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); Assert.Null(info.MergeCommit); } [Fact] public async Task GetTaskWorktree_AfterMerge_ExposesMergeCommit() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "new\n"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: add file"); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); await sut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); var expected = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); Assert.Equal(expected, info.MergeCommit); } [Fact] public async Task GetTaskDiff_NoWorktree_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.GetTaskDiff(task.Id, false, null, CancellationToken.None)); } [Fact] public async Task GetTaskDiff_AfterCommit_ListsChangedFile() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None); Assert.Contains("added.txt", diff.Files); Assert.False(diff.Truncated); } [Fact] public async Task GetTaskDiff_WithPathsFilter_ReturnsOnlyRequestedFile() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, false, ["added.txt"], CancellationToken.None); Assert.Equal(new[] { "added.txt" }, diff.Files); Assert.DoesNotContain("other.txt", diff.Content); } [Fact] public async Task GetTaskDiff_StatMode_WithPathsFilter_ReturnsOnlyRequestedFile() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); File.WriteAllText(Path.Combine(wt.WorktreePath, "other.txt"), "other content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, true, ["other.txt"], CancellationToken.None); Assert.Equal(new[] { "other.txt" }, diff.Files); Assert.DoesNotContain("added.txt", diff.Content); } [Fact] public async Task GetTaskDiff_WorktreeLessHandlerTask_UsesHandlerCommitRangeOverListWorkingDir() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); // No WorktreeEntity for this task -- it's a worktree-less list-handler host task // (Mission Control's "Let Claude handle it"): the handler merged its own commit // straight into the list's working dir after HandlerBaseCommit was stamped. File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(task.Id); t!.HandlerBaseCommit = repo.BaseCommit; t.HandlerHeadCommit = headCommit; await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None); Assert.Contains("handled.txt", diff.Files); Assert.False(diff.Truncated); } [Fact] public async Task GetTaskDiff_WorktreeLessHandlerTask_StatMode_UsesHandlerCommitRange() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); var headCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(task.Id); t!.HandlerBaseCommit = repo.BaseCommit; t.HandlerHeadCommit = headCommit; await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var diff = await sut.GetTaskDiff(task.Id, true, null, CancellationToken.None); Assert.Contains("handled.txt", diff.Content); } [Fact] public async Task GetTaskDiff_ReportsProgressWhileDiffing() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = ExternalMcpService.ProgressReportInterval; ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; try { var (task, list, wt) = await SeedWorktreeAsync(); File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); var sut = BuildSut(CreateQueue()); var progress = new SyncProgress(); var diff = await sut.GetTaskDiff(task.Id, false, null, CancellationToken.None, progress); Assert.Contains("added.txt", diff.Files); Assert.NotEmpty(progress.Reports); Assert.All(progress.Reports, r => Assert.Contains("computing diff", r.Message)); } finally { ExternalMcpService.ProgressReportInterval = original; } } [Fact] public async Task ListWorktrees_Empty_ReturnsEmpty() { var sut = BuildSut(CreateQueue()); var rows = await sut.ListWorktrees(CancellationToken.None); Assert.Empty(rows); } [Fact] public async Task ListWorktrees_ReturnsCreatedWorktree() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var rows = await sut.ListWorktrees(CancellationToken.None); Assert.Contains(rows, r => r.TaskId == task.Id); } [Fact] public async Task ListWorktrees_ReportsProgressWhileSurveying() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = ExternalMcpService.ProgressReportInterval; ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; try { await SeedWorktreeAsync(); var sut = BuildSut(CreateQueue()); var progress = new SyncProgress(); var rows = await sut.ListWorktrees(CancellationToken.None, progress); Assert.NotEmpty(rows); Assert.NotEmpty(progress.Reports); } finally { ExternalMcpService.ProgressReportInterval = original; } } [Fact] public async Task CleanupTaskWorktree_NotFound_Throws() { var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None)); } [Fact] public async Task CleanupTaskWorktree_Running_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None)); Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task CleanupTaskWorktree_CleanWorktree_Removes() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None); Assert.True(result.Removed); Assert.False(Directory.Exists(wt.WorktreePath)); } [Fact] public async Task CleanupTaskWorktree_ReportsProgressWhileRemoving() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = ExternalMcpService.ProgressReportInterval; ExternalMcpService.ProgressReportInterval = TimeSpan.Zero; try { var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var progress = new SyncProgress(); var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None, progress); Assert.True(result.Removed); Assert.False(Directory.Exists(wt.WorktreePath)); Assert.NotEmpty(progress.Reports); } finally { ExternalMcpService.ProgressReportInterval = original; } }