    [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<InvalidOperationException>(
            () => 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<InvalidOperationException>(
            () => 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<WorktreeManager>.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 sut = BuildSut(CreateQueue());
        await sut.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 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 = BuildSut(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<ProgressNotificationValue>();
            var progress = new Progress<ProgressNotificationValue>(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 sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null,
            leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        // Markers never resolved — continue must refuse and list the files.
        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 sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null,
            leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        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<InvalidOperationException>(
            () => 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<InvalidOperationException>(
            () => 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 sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        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 sut = BuildSut(CreateQueue());
        await sut.ReviewTask(task.Id, "approve", null, null, leaveConflictsInTree: true, cancellationToken: CancellationToken.None);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => sut.ResolveConflictHunk(task.Id, "not-a-real-file.md", 0, "ours", CancellationToken.None));
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => 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<InvalidOperationException>(
            () => 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 sut = BuildSut(CreateQueue());
        var review = await sut.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<WorktreeManager>.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.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.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.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.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.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<InvalidOperationException>(
            () => sut.PreviewMerge(task.Id, null, 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<InvalidOperationException>(
            () => sut.PreviewMerge(task.Id, null, 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<WorktreeManager>.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, 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_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.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.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.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_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<ProgressNotificationValue>();

        // 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);
    }
