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 MergeMcpToolsTests : 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 MergeMcpToolsTests() { _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; } private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync( TaskStatus status = TaskStatus.WaitingForReview) { var (task, list, wt) = await SeedWorktreeAsync(status); File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# from worktree\n"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit"); File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# from main\n"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit"); return (task, list, wt); } // Three edits spread far enough apart in a 20-line file that git's 3-way merge keeps them as three // separate, non-adjacent hunks instead of folding them into one. private static readonly int[] MultiHunkEditLines = [3, 9, 15]; private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedMultiHunkConflictingWorktreeAsync() { var repo = new GitRepoFixture(); _repos.Add(repo); var baseLines = Enumerable.Range(1, 20).Select(n => $"line{n}").ToArray(); File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), string.Join('\n', baseLines) + "\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "multi-line base"); 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: TaskStatus.WaitingForReview); 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)); WriteMultiHunkVariant(wt.WorktreePath, baseLines, "WT"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit"); WriteMultiHunkVariant(list.WorkingDir!, baseLines, "MAIN"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit"); return (task, list, wt); } private static void WriteMultiHunkVariant(string dir, string[] baseLines, string tag) { var lines = (string[])baseLines.Clone(); foreach (var lineNum in MultiHunkEditLines) lines[lineNum - 1] = $"{tag}-{lineNum}"; File.WriteAllText(Path.Combine(dir, "README.md"), string.Join('\n', lines) + "\n"); } private async Task CreateActiveWorktreeAsync( GitRepoFixture repo, string taskId, string fileName, string content) { var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{taskId[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, fileName), content); GitRepoFixture.RunGit(wtPath, "add", fileName); GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}"); using var ctx = _db.CreateContext(); ctx.Worktrees.Add(new WorktreeEntity { TaskId = taskId, Path = wtPath, BranchName = branch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); return wtPath; } // queue is accepted (and ignored) so every extracted test body can keep calling // BuildSut(CreateQueue()) unchanged -- MergeMcpTools itself has no QueueService dependency. private MergeMcpTools BuildSut(QueueService queue) => BuildSut(queue, new VerifyCommandRunner()); private MergeMcpTools BuildSut(QueueService queue, IVerifyCommandRunner verify) { var git = new GitService(); var factory = _db.CreateFactory(); var state = TaskStateServiceBuilder.Build(factory).State; var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, 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); } // ReviewTask (approve) still lives on ExternalMcpService. Several tests here approve a task // first, then act on the resulting in-progress merge via MergeMcpTools -- for a plain (childless) // task that's safe with two independently-built instances (TaskMergeService/GitService are // git-disk- and DB-backed, not instance-stateful). Only the parent/children unit-merge path // (PlanningMergeOrchestrator's in-memory active-merge tracking) needs the two SUTs to share the // same underlying objects -- see BuildSharedSuts below. private ExternalMcpService BuildReviewSut(QueueService queue) => BuildReviewSut(queue, new VerifyCommandRunner()); private ExternalMcpService BuildReviewSut(QueueService queue, IVerifyCommandRunner verify) { var git = new GitService(); var factory = _db.CreateFactory(); var state = TaskStateServiceBuilder.Build(factory).State; var merge = new TaskMergeService(factory, git, _broadcaster, state, verify, NullLogger.Instance); var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); var planningMerge = new PlanningMergeOrchestrator( factory, merge, aggregator, _broadcaster, git, state, NullLogger.Instance); return new ExternalMcpService( _tasks, _lists, queue, _broadcaster, state, git, factory, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); } // Parent/children unit merges route through PlanningMergeOrchestrator's in-memory _states // dictionary (ContinueMerge/AbortMerge check HasActiveMerge on it) -- a review that starts the // unit merge and a later continue/abort must observe the SAME orchestrator instance, so this // shares git/factory/state/merge/planningMerge between the two SUTs instead of building fresh. private (ExternalMcpService ReviewSut, MergeMcpTools MergeSut) BuildSharedSuts(QueueService queue) { 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); var reviewSut = new ExternalMcpService( _tasks, _lists, queue, _broadcaster, state, git, factory, merge, planningMerge, new BaseDirtyChecker(git, NullLogger.Instance)); var mergeSut = new MergeMcpTools(_tasks, _lists, _broadcaster, git, factory, merge, planningMerge); return (reviewSut, mergeSut); } private QueueService CreateQueue() { var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_merge_{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 MergeTask_NotDone_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None)); Assert.Contains("Done", ex.Message); } [Fact] public async Task MergeTask_DryRun_DoesNotMerge() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.Null(result.MergeCommit); } [Fact] public async Task MergeTask_WaitingForReview_WithoutFlag_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None)); Assert.Contains("Done", ex.Message); } [Fact] public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.Null(result.MergeCommit); } [Fact] public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); 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 target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None); Assert.True(result.Merged); var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, reloaded!.Status); } [Fact] public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.True(result.ConflictsInTree); Assert.Equal(list.WorkingDir, result.RepoPath); Assert.Contains("README.md", result.Conflicts); Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); var sut = BuildSut(CreateQueue()); var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, leaveConflictsInTree: false, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.False(result.ConflictsInTree); Assert.Null(result.RepoPath); Assert.Contains("README.md", result.Conflicts); Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); } [Fact] public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var reviewSut = BuildReviewSut(CreateQueue()); await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); // Resolve the conflict on disk and stage it. File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); var sut = BuildSut(CreateQueue()); var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); Assert.True(result.Merged); Assert.Equal("Done", result.TaskStatus); Assert.Empty(result.Conflicts); Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); } [Fact] public async Task ContinueMerge_SlowVerifyCommand_ReportsProgressWellBeforeIdleTimeout() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var original = TaskMergeService.ProgressReportInterval; TaskMergeService.ProgressReportInterval = TimeSpan.FromMilliseconds(50); try { var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var plainSut = BuildReviewSut(CreateQueue()); await plainSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md"); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok"), Delay = TimeSpan.FromMilliseconds(300), }; var sut = BuildSut(CreateQueue(), fakeVerify); var reports = new List(); var progress = new Progress(reports.Add); var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None, progress: progress); Assert.True(result.Merged); Assert.Equal("Done", result.TaskStatus); await Task.Delay(200); Assert.Contains(reports, r => r.Message != null && r.Message.Contains("verify gate running")); } finally { TaskMergeService.ProgressReportInterval = original; } } [Fact] public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var reviewSut = BuildReviewSut(CreateQueue()); await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); // Markers never resolved — continue must refuse and list the files. var sut = BuildSut(CreateQueue()); var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.Contains("README.md", result.Conflicts); Assert.Equal(list.WorkingDir, result.RepoPath); Assert.Equal("WaitingForReview", result.TaskStatus); Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var reviewSut = BuildReviewSut(CreateQueue()); await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); var sut = BuildSut(CreateQueue()); var dto = await sut.AbortMerge(task.Id, CancellationToken.None); Assert.Equal("WaitingForReview", dto.Status); Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md"))); Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!)); } [Fact] public async Task ContinueMerge_NoMergeInProgress_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None)); Assert.Contains("mid-merge", ex.Message); } [Fact] public async Task AbortMerge_NoMergeInProgress_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.AbortMerge(task.Id, CancellationToken.None)); Assert.Contains("mid-merge", ex.Message); } [Fact] public async Task GetMergeConflicts_ReportsHunksWithStartLineAndDiff3Base() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var reviewSut = BuildReviewSut(CreateQueue()); await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(1, result.RemainingHunks); var file = Assert.Single(result.Files); Assert.Equal("README.md", file.Path); Assert.False(file.IsBinary); var hunk = Assert.Single(file.Hunks); Assert.Equal(0, hunk.Index); Assert.True(hunk.StartLine >= 1); Assert.Equal("# from main", hunk.Ours.TrimEnd('\r', '\n')); Assert.Equal("# from worktree", hunk.Theirs.TrimEnd('\r', '\n')); Assert.NotEmpty(result.ConflictStyleNote); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task GetMergeConflicts_NoMergeInProgress_ReturnsEmpty() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var sut = BuildSut(CreateQueue()); var result = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(0, result.RemainingHunks); Assert.Empty(result.Files); } [Fact] public async Task ResolveConflictHunk_OursTheirsAndText_ResolveEachHunkAndTrackRemainingCount() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync(); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); var sut = BuildSut(CreateQueue()); var merge = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); Assert.True(merge.ConflictsInTree); var before = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(3, before.RemainingHunks); Assert.Equal(3, Assert.Single(before.Files).Hunks.Count); // Resolve hunk 0 three times in a row: each resolution removes a hunk from the front, so the // next still-open hunk always slides into index 0. var r1 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None); Assert.Equal(2, r1.RemainingHunks); var r2 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "theirs", CancellationToken.None); Assert.Equal(1, r2.RemainingHunks); var r3 = await sut.ResolveConflictHunk(task.Id, "README.md", 0, "CUSTOM-15\n", CancellationToken.None); Assert.Equal(0, r3.RemainingHunks); var finalConflicts = await sut.GetMergeConflicts(task.Id, CancellationToken.None); Assert.Equal(0, finalConflicts.RemainingHunks); var continueResult = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); Assert.True(continueResult.Merged); Assert.Equal("Done", continueResult.TaskStatus); var mergedText = File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")); Assert.Contains("MAIN-3", mergedText); // hunk 1 resolved "ours" (the target branch) Assert.Contains("WT-9", mergedText); // hunk 2 resolved "theirs" (the incoming branch) Assert.Contains("CUSTOM-15", mergedText); // hunk 3 resolved to literal text Assert.DoesNotContain("<<<<<<<", mergedText); } [Fact] public async Task ResolveConflictHunk_LeavesOtherHunksUntouchedUntilContinueMerge() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedMultiHunkConflictingWorktreeAsync(); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!); var sut = BuildSut(CreateQueue()); await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); // Resolve only one of the three hunks. await sut.ResolveConflictHunk(task.Id, "README.md", 0, "ours", CancellationToken.None); // continue_merge must still refuse: writing one hunk must not have staged the file as resolved. var result = await sut.ContinueMerge(task.Id, cancellationToken: CancellationToken.None); Assert.False(result.Merged); Assert.Contains("README.md", result.Conflicts); Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!)); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task ResolveConflictHunk_UnknownFileOrOutOfRangeIndex_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var reviewSut = BuildReviewSut(CreateQueue()); await reviewSut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None)); await Assert.ThrowsAsync( () => sut.ResolveConflictHunk(task.Id, "README.md", 5, "ours", CancellationToken.None)); GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort"); } [Fact] public async Task RevertMerge_NotMerged_Throws() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.RevertMerge(task.Id, "main", CancellationToken.None)); Assert.Contains("expected Merged", ex.Message); } [Fact] public async Task RevertMerge_AfterCleanMerge_RevertsAndReturnsTaskToWaitingForReview() { 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); Assert.True(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); Assert.True(result.Reverted); Assert.False(string.IsNullOrWhiteSpace(result.RevertCommit)); Assert.Empty(result.Conflicts); Assert.False(File.Exists(Path.Combine(list.WorkingDir!, "added.txt"))); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); } [Fact] public async Task RevertMerge_ConflictingRevert_ReturnsRevertedFalseWithConflictsAndAbortsCleanly() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done); File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# test repo\nfeature\n"); GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A"); GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feat: append feature line"); 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); // A later commit on main edits the exact line the merge introduced, so the revert conflicts. File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# test repo\npost-merge edit\n"); GitRepoFixture.RunGit(list.WorkingDir!, "add", "-A"); GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "chore: edit the same line"); var headBefore = GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim(); var result = await sut.RevertMerge(task.Id, target, CancellationToken.None); Assert.False(result.Reverted); Assert.Contains("README.md", result.Conflicts); Assert.False(await new GitService().IsMidRevertAsync(list.WorkingDir!)); Assert.Equal(headBefore, GitRepoFixture.RunGit(list.WorkingDir!, "rev-parse", "HEAD").Trim()); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, reloaded!.Status); } [Fact] public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); // main edits README.md so the child's edit conflicts. File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit"); var listId = Guid.NewGuid().ToString(); var parentId = Guid.NewGuid().ToString(); var childId = Guid.NewGuid().ToString(); using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow, Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized, Number = ++_numberSeed }); ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1, Number = ++_numberSeed }); var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{childId[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n"); GitRepoFixture.RunGit(wtPath, "add", "README.md"); GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit"); ctx.Worktrees.Add(new WorktreeEntity { TaskId = childId, Path = wtPath, BranchName = branch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var (reviewSut, sut) = BuildSharedSuts(CreateQueue()); var review = await reviewSut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus); Assert.Equal(repo.RepoDir, review.RepoPath); Assert.Contains("continue_merge", review.MergeMessage); // Resolve the conflict on disk. File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n"); var result = await sut.ContinueMerge(parentId, cancellationToken: CancellationToken.None); Assert.True(result.Merged); Assert.Equal("Done", result.TaskStatus); Assert.Empty(result.Conflicts); Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir)); using var verify = _db.CreateContext(); Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State); } [Fact] public async Task PreviewMerge_CleanMerge_ReturnsCleanStatusAndDoesNotChangeRepo() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); 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 git = new GitService(); var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Empty(result.ConflictFiles); Assert.Equal(1, result.ChangedFileCount); Assert.Equal(0, result.Behind); Assert.False(result.IsEmpty); Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); } [Fact] public async Task PreviewMerge_EmptyWorktree_ReturnsIsEmptyTrue() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } // Worktree created but nothing committed beyond base -- e.g. a child that reported // CLAUDEDO_BLOCKED before writing any code. var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Equal(0, result.ChangedFileCount); Assert.True(result.IsEmpty); } [Fact] public async Task PreviewMerge_WorktreeLessHandlerTask_EmptyRange_ReturnsIsEmptyTrue() { 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); // Worktree-less list-handler host task whose handler range never actually committed // anything: HandlerBaseCommit == HandlerHeadCommit, not "no worktree -> unknown". using (var ctx = _db.CreateContext()) { var t = await ctx.Tasks.FindAsync(task.Id); t!.HandlerBaseCommit = repo.BaseCommit; t.HandlerHeadCommit = repo.BaseCommit; await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Equal(0, result.ChangedFileCount); Assert.True(result.IsEmpty); } [Fact] public async Task PreviewMerge_WorktreeLessHandlerTask_NonEmptyRange_ReturnsIsEmptyFalse() { 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 result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Equal(1, result.ChangedFileCount); Assert.False(result.IsEmpty); } [Fact] public async Task PreviewMerge_ConflictingBranches_ReturnsConflictStatusAndDoesNotChangeRepo() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview); var git = new GitService(); var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.PreviewConflict, result.Status); Assert.Contains("README.md", result.ConflictFiles); Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); Assert.False(await git.IsMidMergeAsync(list.WorkingDir!)); Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); } [Fact] public async Task PreviewMerge_NoWorktree_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(CreateQueue()); await Assert.ThrowsAsync( () => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None)); } [Fact] public async Task PreviewMerge_MissingWorkingDir_ThrowsClearError() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); using (var ctx = _db.CreateContext()) { var row = await ctx.Lists.FindAsync(list.Id); row!.WorkingDir = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}"); await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var ex = await Assert.ThrowsAsync( () => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None)); Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task PreviewMerge_VerifyCommandConfigured_RunsInScratchWorktreeAndDoesNotTouchTarget() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); 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); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); var git = new GitService(); var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; var sut = BuildSut(CreateQueue(), fakeVerify); var result = await sut.PreviewMerge(task.Id, target, runVerify: true, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Equal(0, result.VerifyExitCode); Assert.Equal("dotnet build", fakeVerify.CapturedCommand); Assert.NotEqual(list.WorkingDir, fakeVerify.CapturedWorkingDir); Assert.Equal(headBefore, await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None)); Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None)); } [Fact] public async Task PreviewMerge_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); 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); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" }); var git = new GitService(); var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; var sut = BuildSut(CreateQueue(), fakeVerify); var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None); Assert.Equal(TaskMergeService.PreviewClean, result.Status); Assert.Null(result.VerifyExitCode); Assert.Null(result.VerifyDurationMs); Assert.Null(result.VerifyOutputTail); Assert.Null(fakeVerify.CapturedCommand); } [Fact] public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); 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, "a", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, task.Id, "shared.txt", "from branch\n"); // The target branch itself moved on and touched the same file after the fork point. File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "from main\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "shared.txt"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt"); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None); Assert.Equal(1, result.Behind); Assert.NotNull(result.StaleFiles); Assert.Contains("shared.txt", result.StaleFiles!); } [Fact] public async Task PreviewMerge_TargetBehindButUnrelatedFile_ReportsNoStaleFiles() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); 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, "a", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, task.Id, "branch-only.txt", "from branch\n"); // Target moved on (behind > 0) but touched an entirely different file -- a stale // branch that still collides with nothing. File.WriteAllText(Path.Combine(repo.RepoDir, "unrelated.txt"), "from main\n"); GitRepoFixture.RunGit(repo.RepoDir, "add", "unrelated.txt"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else"); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None); Assert.Equal(1, result.Behind); Assert.NotNull(result.StaleFiles); Assert.Empty(result.StaleFiles!); } [Fact] public async Task PreviewMerge_WorktreeLessHandlerTask_ReportsNoStaleFiles() { 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 result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None); Assert.NotNull(result.StaleFiles); Assert.Empty(result.StaleFiles!); } [Fact] public async Task PreviewMergeSet_TwoTasksSharedFile_ReportsOverlap() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, taskA.Id, "shared.txt", "a change\n"); await CreateActiveWorktreeAsync(repo, taskB.Id, "shared.txt", "b change\n"); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None); Assert.Equal(2, result.Tasks.Count); Assert.All(result.Tasks, t => Assert.Null(t.Error)); Assert.All(result.Tasks, t => Assert.Equal(TaskMergeService.PreviewClean, t.Status)); var overlap = Assert.Single(result.Overlaps); Assert.Equal("shared.txt", overlap.File); Assert.Equal( new[] { taskA.Id, taskB.Id }.OrderBy(x => x), overlap.TaskIds.OrderBy(x => x)); // Equal file sets overlap but neither is a PROPER subset of the other. Assert.Empty(result.Subsets); } [Fact] public async Task PreviewMergeSet_TaskFilesAreProperSubsetOfAnother_ReportsSubset() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var subsetTask = await SeedTaskAsync(listId, "subset", TaskStatus.WaitingForReview); var supersetTask = await SeedTaskAsync(listId, "superset", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, subsetTask.Id, "shared.txt", "shared change\n"); var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, wtPath)); var branch = $"claudedo/{supersetTask.Id[..8]}"; GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit); File.WriteAllText(Path.Combine(wtPath, "shared.txt"), "shared change too\n"); File.WriteAllText(Path.Combine(wtPath, "extra.txt"), "extra\n"); GitRepoFixture.RunGit(wtPath, "add", "-A"); GitRepoFixture.RunGit(wtPath, "commit", "-m", "edit shared+extra"); using (var ctx = _db.CreateContext()) { ctx.Worktrees.Add(new WorktreeEntity { TaskId = supersetTask.Id, Path = wtPath, BranchName = branch, BaseCommit = repo.BaseCommit, HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(), State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([subsetTask.Id, supersetTask.Id], "main", cancellationToken: CancellationToken.None); var relation = Assert.Single(result.Subsets); Assert.Equal(subsetTask.Id, relation.TaskId); Assert.Equal(supersetTask.Id, relation.SupersetTaskId); } [Fact] public async Task PreviewMergeSet_SingleTaskId_ReturnsEmptyOverlap() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None); Assert.Single(result.Tasks); Assert.Empty(result.Overlaps); } [Fact] public async Task PreviewMergeSet_ReportsExpectsNoDiffPerTask() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); task.ExpectsNoDiff = true; await _tasks.UpdateAsync(task, CancellationToken.None); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([task.Id], target, cancellationToken: CancellationToken.None); Assert.True(Assert.Single(result.Tasks).ExpectsNoDiff); } [Fact] public async Task PreviewMergeSet_ReportsProgressPerTask() { var listId = await SeedListAsync(); var t1 = await SeedTaskAsync(listId, "a"); var t2 = await SeedTaskAsync(listId, "b"); var sut = BuildSut(CreateQueue()); var progress = new SyncProgress(); // Neither task has a worktree -- each entry fails individually (PreviewUnavailable), but // the loop still reports progress per task since a per-task InvalidOperationException // doesn't abort the batch. await sut.PreviewMergeSet( [t1.Id, t2.Id], "main", cancellationToken: CancellationToken.None, progress: progress); Assert.Equal(2, progress.Reports.Count); Assert.Equal("Previewing merges (1/2)", progress.Reports[0].Message); Assert.Equal("Previewing merges (2/2)", progress.Reports[1].Message); Assert.Equal(2, progress.Reports[1].Total); } [Fact] public async Task PreviewMergeSet_OneTaskWithoutWorktree_ReportsErrorWithoutAbortingOthers() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var (task, list, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var listId2 = await SeedListAsync("no-worktree-list"); var noWorktreeTask = await SeedTaskAsync(listId2, "b", TaskStatus.Idle); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([task.Id, noWorktreeTask.Id], target, cancellationToken: CancellationToken.None); Assert.Equal(2, result.Tasks.Count); var ok = result.Tasks.Single(t => t.TaskId == task.Id); Assert.Null(ok.Error); Assert.Equal(TaskMergeService.PreviewClean, ok.Status); var failed = result.Tasks.Single(t => t.TaskId == noWorktreeTask.Id); Assert.NotNull(failed.Error); Assert.Equal(TaskMergeService.PreviewUnavailable, failed.Status); Assert.Empty(result.Overlaps); } [Fact] public async Task PreviewMergeSet_MixedEmptyAndNonEmpty_ReportsIsEmptyPerEntry() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); var emptyTask = await SeedTaskAsync(listId, "empty", TaskStatus.WaitingForReview); var fullTask = await SeedTaskAsync(listId, "full", TaskStatus.WaitingForReview); var emptyBranch = $"claudedo/{emptyTask.Id[..8]}"; var emptyWtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); _worktreeCleanups.Add((repo.RepoDir, emptyWtPath)); GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", emptyBranch, emptyWtPath, repo.BaseCommit); using (var ctx = _db.CreateContext()) { ctx.Worktrees.Add(new WorktreeEntity { TaskId = emptyTask.Id, Path = emptyWtPath, BranchName = emptyBranch, BaseCommit = repo.BaseCommit, HeadCommit = repo.BaseCommit, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } await CreateActiveWorktreeAsync(repo, fullTask.Id, "full.txt", "content\n"); var sut = BuildSut(CreateQueue()); var result = await sut.PreviewMergeSet([emptyTask.Id, fullTask.Id], "main", cancellationToken: CancellationToken.None); var emptyEntry = result.Tasks.Single(t => t.TaskId == emptyTask.Id); var fullEntry = result.Tasks.Single(t => t.TaskId == fullTask.Id); Assert.Null(emptyEntry.Error); Assert.True(emptyEntry.IsEmpty); Assert.Null(fullEntry.Error); Assert.False(fullEntry.IsEmpty); } [Fact] public async Task PreviewMergeSet_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n"); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") }; var sut = BuildSut(CreateQueue(), fakeVerify); var result = await sut.PreviewMergeSet([task.Id], "main", cancellationToken: CancellationToken.None); Assert.Null(result.Tasks.Single().VerifyExitCode); Assert.Null(fakeVerify.CapturedCommand); } [Fact] public async Task PreviewMergeSet_RunVerifyTrue_InvokesRunnerPerTask() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); using (var ctx = _db.CreateContext()) await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = "dotnet build" }); var task = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview); await CreateActiveWorktreeAsync(repo, task.Id, "added.txt", "content\n"); var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") }; var sut = BuildSut(CreateQueue(), fakeVerify); var result = await sut.PreviewMergeSet([task.Id], "main", runVerify: true, cancellationToken: CancellationToken.None); Assert.Equal(0, result.Tasks.Single().VerifyExitCode); Assert.Equal("dotnet build", fakeVerify.CapturedCommand); } }