using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Tests.Services; using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Worktrees; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; // A synchronously-collecting IProgress: Progress marshals through the SynchronizationContext // captured at construction, which is unreliable to assert on immediately in a test. file sealed class SyncProgress : IProgress { public readonly List Reports = new(); public void Report(T value) => Reports.Add(value); } public sealed class WorktreeMcpToolsTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly HubBroadcaster _broadcaster; private readonly List _repos = new(); private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new(); private int _numberSeed; private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public WorktreeMcpToolsTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _broadcaster = new HubBroadcaster(new CapturingHubContext()); } public void Dispose() { foreach (var (repoDir, wtPath) in _worktreeCleanups) { try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { } } foreach (var r in _repos) r.Dispose(); _ctx.Dispose(); _db.Dispose(); } private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync( TaskStatus status = TaskStatus.Done) { var repo = new GitRepoFixture(); _repos.Add(repo); var listId = Guid.NewGuid().ToString(); var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; await _lists.AddAsync(list); var task = await SeedTaskAsync(listId, status: status); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); var wt = await mgr.CreateAsync(task, list, CancellationToken.None); _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath)); return (task, list, wt); } private async Task SeedListAsync(string name = "L") { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow }); return id; } private async Task SeedTaskAsync( string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null) { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Status = status, ParentTaskId = parentId, CreatedAt = DateTime.UtcNow, CommitType = "chore", Number = ++_numberSeed, }; await _tasks.AddAsync(task); return task; } // queue is accepted (and ignored) so every extracted test body can keep calling // BuildSut(CreateQueue()) unchanged -- WorktreeMcpTools itself has no QueueService dependency. private WorktreeMcpTools BuildSut(QueueService queue) { var git = new GitService(); var factory = _db.CreateFactory(); var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger.Instance); return new WorktreeMcpTools(_tasks, git, factory, maintenance); } // GetTaskWorktree_AfterMerge_ExposesMergeCommit needs a real merge first -- MergeTask lives on // MergeMcpTools now, not here. A fresh instance is fine (TaskMergeService/GitService are // git-disk- and DB-backed, no shared in-memory state to worry about for a plain merge). private MergeMcpTools BuildMergeSut() { var git = new GitService(); var factory = _db.CreateFactory(); var state = TaskStateServiceBuilder.Build(factory).State; var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( factory, merge, aggregator, _broadcaster, git, state, NullLogger.Instance); return new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge); } private QueueService CreateQueue() { var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_worktree_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var cfg = new WorkerConfig { SandboxRoot = Path.Combine(tempDir, "sandbox"), LogRoot = Path.Combine(tempDir, "logs"), QueueBackstopIntervalMs = 50, }; var fake = new FakeClaudeProcess(); var hubCtx = new CapturingHubContext(); var broadcaster = new HubBroadcaster(hubCtx); var dbFactory = _db.CreateFactory(); var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var runCancels = new RunCancellationRegistry(NullLogger.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); var usageState = new UsageState(); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, waker, picker, overrideSlot, state, runCancels, new FakeUsageGate(), usageState, broadcaster, new UsageLimitAutoContinueCoordinator(dbFactory, usageState, overrideSlot, broadcaster, NullLogger.Instance)); } [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 mergeSut = BuildMergeSut(); await mergeSut.MergeTask(task.Id, target, true, false, false, cancellationToken: CancellationToken.None); var sut = BuildSut(CreateQueue()); 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 = WorktreeMcpTools.ProgressReportInterval; WorktreeMcpTools.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 { WorktreeMcpTools.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 = WorktreeMcpTools.ProgressReportInterval; WorktreeMcpTools.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 { WorktreeMcpTools.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 = WorktreeMcpTools.ProgressReportInterval; WorktreeMcpTools.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 { WorktreeMcpTools.ProgressReportInterval = original; } } }