using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.Services;

public class TaskMergeServiceTests : IDisposable
{
    private readonly List<DbFixture> _dbs = new();
    private readonly List<GitRepoFixture> _repos = new();
    private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();

    private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
    private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }

    public void Dispose()
    {
        foreach (var (repoDir, wtPath) in _wtCleanups)
        {
            try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
        }
        foreach (var d in _dbs) try { d.Dispose(); } catch { }
        foreach (var r in _repos) try { r.Dispose(); } catch { }
    }

    private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(
        DbFixture db, IVerifyCommandRunner? verify = null)
    {
        var fakeHub = new MergeRecordingHubContext();
        var broadcaster = new HubBroadcaster(fakeHub);
        var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
        var svc = new TaskMergeService(
            db.CreateFactory(),
            new GitService(),
            broadcaster,
            state,
            verify ?? new VerifyCommandRunner(),
            NullLogger<TaskMergeService>.Instance);
        return (svc, fakeHub.Proxy);
    }

    private static async Task SeedVerifyCommand(DbFixture db, string listId, string command)
    {
        using var ctx = db.CreateContext();
        await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = command });
    }

    private static WorktreeManager BuildWorktreeManager(DbFixture db)
    {
        return new WorktreeManager(
            new GitService(),
            db.CreateFactory(),
            new ClaudeDo.Worker.Config.WorkerConfig { WorktreeRootStrategy = "sibling" },
            NullLogger<WorktreeManager>.Instance);
    }

    private static async Task SeedWorktree(
        DbFixture db, string taskId, string path, string branchName, string baseCommit,
        WorktreeState state = WorktreeState.Active, string? mergeCommit = null)
    {
        var wt = new WorktreeEntity
        {
            TaskId = taskId,
            Path = path,
            BranchName = branchName,
            BaseCommit = baseCommit,
            State = state,
            MergeCommit = mergeCommit,
            CreatedAt = DateTime.UtcNow,
        };
        using var ctx = db.CreateContext();
        await new WorktreeRepository(ctx).AddAsync(wt);
    }

    private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask(
        DbFixture db, string workingDir, TaskStatus status)
    {
        var list = new ListEntity
        {
            Id = Guid.NewGuid().ToString(),
            Name = "merge-test",
            WorkingDir = workingDir,
            DefaultCommitType = "feat",
            CreatedAt = DateTime.UtcNow,
        };
        var task = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = list.Id,
            Title = "merge-task",
            Status = status,
            CreatedAt = DateTime.UtcNow,
        };
        using var ctx = db.CreateContext();
        await new ListRepository(ctx).AddAsync(list);
        await new TaskRepository(ctx).AddAsync(task);
        return (list, task);
    }

    [Fact]
    public async Task MergeAsync_RunningTask_ReturnsBlocked()
    {
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Running);
        var (svc, proxy) = BuildService(db);

        var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);

        Assert.Equal("blocked", result.Status);
        Assert.Contains("running", result.ErrorMessage ?? "");
        Assert.Empty(proxy.Calls);
    }

    [Fact]
    public async Task MergeAsync_NoWorktree_ReturnsBlocked()
    {
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
        var (svc, _) = BuildService(db);

        var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);

        Assert.Equal("blocked", result.Status);
        Assert.Contains("no worktree", result.ErrorMessage ?? "");
    }

    [Fact]
    public async Task MergeAsync_FfAble_KeepWorktree_SetsMergedAndBroadcasts()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        // Create worktree and make a real commit inside it.
        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, proxy) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge task", ct: CancellationToken.None);

        Assert.Equal("merged", result.Status);
        Assert.Empty(result.ConflictFiles);

        // Worktree state now Merged, dir and branch still present.
        using var ctx = db.CreateContext();
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.NotNull(wt);
        Assert.Equal(WorktreeState.Merged, wt!.State);
        Assert.True(Directory.Exists(wtCtx.WorktreePath));

        // Broadcast fired.
        Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);

        // added.txt is now on the main branch of the repo.
        Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
    }

    [Fact]
    public async Task MergeAsync_FfAble_RemoveWorktree_CleansEverything()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
            commitMessage: "Merge", ct: CancellationToken.None);

        Assert.Equal("merged", result.Status);
        Assert.False(Directory.Exists(wtCtx.WorktreePath));

        // Branch must be gone.
        var branches = await new GitService().ListLocalBranchesAsync(repo.RepoDir);
        Assert.DoesNotContain(wtCtx.BranchName, branches);

        // DB state still Merged.
        using var ctx = db.CreateContext();
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Merged, wt!.State);
    }

    [Fact]
    public async Task MergeAsync_DivergedNonConflicting_ProducesMergeCommit()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "feat\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        // Advance main by adding a different file.
        File.WriteAllText(Path.Combine(repo.RepoDir, "main-only.txt"), "main\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main moved");

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge diverged", ct: CancellationToken.None);

        Assert.Equal("merged", result.Status);
        // HEAD must be a merge commit (two parents).
        var parents = GitRepoFixture.RunGit(repo.RepoDir, "rev-list", "--parents", "-n", "1", "HEAD").Trim();
        Assert.True(parents.Split(' ').Length >= 3, $"Expected merge commit, got '{parents}'");
    }

    [Fact]
    public async Task MergeAsync_Conflict_AbortsAndReturnsConflictedFiles()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        // Worktree edits README.md
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        // Main also edits README.md (conflicting).
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit");
        var mainHeadBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        var (svc, proxy) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
            commitMessage: "Merge", ct: CancellationToken.None);

        Assert.Equal("conflict", result.Status);
        Assert.Contains("README.md", result.ConflictFiles);

        // Main branch must be restored exactly.
        var mainHeadAfter = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
        Assert.Equal(mainHeadBefore, mainHeadAfter);
        Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));

        // Worktree state stays Active (no broadcast).
        using var ctx = db.CreateContext();
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Active, wt!.State);
        Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
    }

    [Fact]
    public async Task MergeAsync_BlankCommitMessage_UsesConventionalDefault()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "feat\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "", ct: CancellationToken.None);

        Assert.Equal("merged", result.Status);
        var subject = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%s").Trim();
        // task.CommitType (not the list default) — same type the task's own commits used.
        Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", subject);
        var body = GitRepoFixture.RunGit(repo.RepoDir, "log", "-1", "--format=%b");
        Assert.Contains($"ClaudeDo-Task: {task.Id}", body);
    }

    [Fact]
    public async Task MergeAsync_VerifyGate_BroadcastsMergingAndVerifyingPhases()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        await SeedVerifyCommand(db, list.Id, "echo ok");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "feat\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, proxy) = BuildService(db, new FakeVerifyCommandRunner());
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "", ct: CancellationToken.None);

        Assert.Equal("merged", result.Status);
        Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress"
            && (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseMerging);
        Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress"
            && (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseVerifying);
        Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
            && c.Args[0] is string s && s.Contains("Verify command running"));
    }

    [Fact]
    public async Task GetTargetsAsync_ReturnsDefaultCommitMessage()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var (svc, _) = BuildService(db);
        var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);

        Assert.Equal($"{task.CommitType}(merge-test): merge merge-task", targets.DefaultCommitMessage.Split('\n')[0]);
    }

    [Fact]
    public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/extra");
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var (svc, _) = BuildService(db);
        var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);

        Assert.False(string.IsNullOrWhiteSpace(targets.DefaultBranch));
        Assert.Contains("feature/extra", targets.LocalBranches);
        Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
    }

    [Fact]
    public async Task MergeAsync_DirtyWorkingTree_ReturnsBlocked()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        // Modify a tracked file in the target working dir (real uncommitted change).
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");

        var (svc, _) = BuildService(db);
        var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);

        Assert.Equal("blocked", result.Status);
        Assert.Contains("uncommitted", result.ErrorMessage ?? "");
    }

    [Fact]
    public async Task MergeAsync_UntrackedFileInTargetWorkingTree_DoesNotBlock()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        // An untracked file (e.g. left behind by a concurrent session) must not block the merge.
        File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge", ct: CancellationToken.None);

        Assert.Equal("merged", result.Status);
    }

    [Fact]
    public async Task MergeAsync_BranchAddsPathUntrackedInTarget_RefusesAndLeavesLocalContentIntact()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        // The task branch creates a brand new file at a path it has never seen tracked.
        Directory.CreateDirectory(Path.Combine(wtCtx.WorktreePath, "docs"));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "docs", "PLUGIN.md"), "agent-written replacement\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        // The same path already exists, untracked, in the target working directory — never
        // committed, not ignored — carrying content unrelated to the task branch.
        Directory.CreateDirectory(Path.Combine(repo.RepoDir, "docs"));
        var localPath = Path.Combine(repo.RepoDir, "docs", "PLUGIN.md");
        const string localContent = "local plugin docs — must survive\n";
        File.WriteAllText(localPath, localContent);

        var (svc, proxy) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
        var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge task", ct: CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
        Assert.Contains("docs/PLUGIN.md", result.ConflictFiles);
        Assert.Contains("docs/PLUGIN.md", result.ErrorMessage ?? "");
        Assert.Contains($"{System.Text.Encoding.UTF8.GetByteCount(localContent)} bytes", result.ErrorMessage ?? "");

        // Nothing landed: no merge was attempted, the local file is untouched, worktree unaffected.
        Assert.Equal(localContent, File.ReadAllText(localPath));
        Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
        Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
        Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");

        using var ctx = db.CreateContext();
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Active, wt!.State);
    }

    [Fact]
    public async Task MergeAsync_TargetBranchDifferentFromHead_ChecksOutBeforeMerging()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        // Create a feature branch in the repo (from current HEAD).
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/target");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feat.txt"), "data\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        // Repo is currently on main/master; request merge into feature/target.
        var result = await svc.MergeAsync(task.Id, "feature/target", removeWorktree: false,
            commitMessage: "Merge into feature", ct: CancellationToken.None);

        Assert.Equal("merged", result.Status);

        // HEAD must now be feature/target.
        var head = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
        Assert.Equal("feature/target", head);

        // The merged file must exist on feature/target.
        Assert.True(File.Exists(Path.Combine(repo.RepoDir, "feat.txt")));
    }

    [Fact]
    public async Task MergeAsync_TargetBranchDoesNotExist_ReturnsBlocked()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));

        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "x.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var result = await svc.MergeAsync(task.Id, "nonexistent/branch", removeWorktree: false,
            commitMessage: "Merge", ct: CancellationToken.None);

        Assert.Equal("blocked", result.Status);
        Assert.Contains("switch target branch", result.ErrorMessage ?? "");
    }

    [Fact]
    public async Task ContinueMergeAsync_AfterUserResolves_CompletesMergeAndSetsWorktreeMerged()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t2", wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
        GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");

        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, wtPath, "claudedo/t2", repo.BaseCommit);

        var (svc, _) = BuildService(db);

        var first = await svc.MergeAsync(task.Id, "main", false, "msg",
            leaveConflictsInTree: true, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusConflict, first.Status);

        // Simulate the user resolving the conflict.
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");

        var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));

        using var ctx = db.CreateContext();
        var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
        Assert.Equal(WorktreeState.Merged, wt.State);
    }

    [Fact]
    public async Task ContinueMergeAsync_UnrelatedChangesInSharedCheckout_AreNotStagedOrCommitted()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
        File.WriteAllText(Path.Combine(repo.RepoDir, "tracked.txt"), "tracked v1\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "tracked.txt");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t5", wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
        GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");

        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, wtPath, "claudedo/t5", repo.BaseCommit);

        var (svc, _) = BuildService(db);

        var first = await svc.MergeAsync(task.Id, "main", false, "msg",
            leaveConflictsInTree: true, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusConflict, first.Status);

        // Simulate the user resolving the conflict.
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");

        // A concurrent session leaves stray changes in this shared checkout while the merge is
        // paused — these must survive continue_merge untouched and never enter the merge commit.
        File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "from another session\n");
        File.WriteAllText(Path.Combine(repo.RepoDir, "tracked.txt"), "tracked v2 (unrelated edit)\n");

        var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);

        Assert.True(File.Exists(Path.Combine(repo.RepoDir, "untracked.txt")));
        Assert.Equal("from another session\n", File.ReadAllText(Path.Combine(repo.RepoDir, "untracked.txt")));
        Assert.Equal("tracked v2 (unrelated edit)\n", File.ReadAllText(Path.Combine(repo.RepoDir, "tracked.txt")));

        var status = GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain");
        Assert.Contains("?? untracked.txt", status);
        Assert.Contains(" M tracked.txt", status);

        var committedFiles = GitRepoFixture.RunGit(repo.RepoDir, "show", "--name-only", "--pretty=format:", "HEAD");
        Assert.DoesNotContain("untracked.txt", committedFiles);
        Assert.DoesNotContain("tracked.txt", committedFiles);
    }

    [Fact]
    public async Task ContinueMergeAsync_UnresolvedConflictMarkersLeft_RefusesAndDoesNotCommit()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t4", wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
        GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");

        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, wtPath, "claudedo/t4", repo.BaseCommit);

        var (svc, _) = BuildService(db);

        var first = await svc.MergeAsync(task.Id, "main", false, "msg",
            leaveConflictsInTree: true, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusConflict, first.Status);
        var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        // User never resolves the conflict — README.md still contains "<<<<<<<" markers.
        var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusConflict, result.Status);
        Assert.Contains("README.md", result.ConflictFiles);
        Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));

        // No commit must have happened, and the repo must still be mid-merge so Abort can clean it up.
        Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
        Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));

        var abort = await svc.AbortMergeAsync(task.Id, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusAborted, abort.Status);
        Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
        Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
    }

    [Fact]
    public async Task ContinueMergeAsync_PathBecomesUntrackedDuringResolution_RefusesBeforeAddAll()
    {
        // git's own preflight already refuses a merge whose incoming branch collides with a path
        // that is untracked in the target *at merge-start* (verified against real git while
        // investigating this guard). The only path left uncovered is one that becomes untracked
        // again *during* conflict resolution, after the initial merge already staged it cleanly —
        // this reproduces exactly that narrow window, right before `git add -A` would sweep it
        // back in unnoticed.
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t-untracked", wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
        File.WriteAllText(Path.Combine(wtPath, "extra.md"), "agent content\n");
        GitRepoFixture.RunGit(wtPath, "add", "-A");
        GitRepoFixture.RunGit(wtPath, "commit", "-m", "branch change + add extra.md");

        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, wtPath, "claudedo/t-untracked", repo.BaseCommit);

        var (svc, _) = BuildService(db);

        var first = await svc.MergeAsync(task.Id, "main", false, "msg",
            leaveConflictsInTree: true, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusConflict, first.Status);

        // extra.md landed clean already (git stages any non-conflicting file the moment the
        // merge starts, even while README.md is left conflicted) -- confirm that, then unstage
        // it and drop in different, untracked local content, reproducing "became untracked
        // again mid-resolution".
        Assert.Equal("A", GitRepoFixture.RunGit(repo.RepoDir, "diff", "--name-status", "--cached", "--", "extra.md").Trim()[..1]);
        GitRepoFixture.RunGit(repo.RepoDir, "rm", "--cached", "extra.md");
        const string localContent = "important local content that must survive\n";
        File.WriteAllText(Path.Combine(repo.RepoDir, "extra.md"), localContent);

        // Resolve the real (unrelated) conflict.
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");

        var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusUntrackedCollision, result.Status);
        Assert.Contains("extra.md", result.ConflictFiles);
        Assert.Equal(localContent, File.ReadAllText(Path.Combine(repo.RepoDir, "extra.md")));

        // Nothing committed; still mid-merge so abort/continue can still resolve it.
        Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));
        using var ctx = db.CreateContext();
        var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
        Assert.Equal(WorktreeState.Active, wt.State);
    }

    [Fact]
    public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t3", wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
        GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");

        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, wtPath, "claudedo/t3", repo.BaseCommit);

        var (svc, _) = BuildService(db);
        await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);

        var result = await svc.AbortMergeAsync(task.Id, CancellationToken.None);

        Assert.Equal("aborted", result.Status);
        Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));

        using var ctx = db.CreateContext();
        var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
        Assert.Equal(WorktreeState.Active, wt.State);
    }

    [Fact]
    public async Task PreviewAsync_CleanWorktree_ReturnsClean()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
        Assert.True(preview.ChangedFileCount >= 1);
    }

    [Fact]
    public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");

        var (svc, _) = BuildService(db);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
        Assert.Contains("README.md", preview.ConflictFiles);
    }

    [Fact]
    public async Task PreviewAsync_BranchAddsPathUntrackedInTarget_ReturnsUntrackedCollision()
    {
        // merge-tree (the real preview mechanism) never touches the working directory, so
        // without this check the preview would say "clean" for a merge that can't actually land.
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "PLUGIN.md"), "agent content\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        File.WriteAllText(Path.Combine(repo.RepoDir, "PLUGIN.md"), "local content\n");

        var (svc, _) = BuildService(db);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewUntrackedCollision, preview.Status);
        Assert.Contains("PLUGIN.md", preview.ConflictFiles);
    }

    [Fact]
    public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
    {
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
        var (svc, _) = BuildService(db);

        var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
    }

    [Fact]
    public async Task PreviewAsync_RunVerifyTrue_NoVerifyCommandConfigured_NeverInvokesRunner()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
        var (svc, _) = BuildService(db, fakeVerify);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
        Assert.Null(preview.VerifyExitCode);
        Assert.Null(fakeVerify.CapturedCommand);
    }

    [Fact]
    public async Task PreviewAsync_RunVerifyFalse_VerifyCommandConfigured_NeverInvokesRunner()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet build");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
        var (svc, _) = BuildService(db, fakeVerify);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
        Assert.Null(preview.VerifyExitCode);
        Assert.Null(fakeVerify.CapturedCommand);
    }

    [Fact]
    public async Task PreviewAsync_RunVerifyTrue_VerifyPasses_ReturnsCleanBuildAndLeavesTargetUntouched()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet build");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner
        {
            Result = new VerifyCommandResult(0, false, "build ok"),
            FileToCheck = "added.txt",
        };
        var (svc, _) = BuildService(db, fakeVerify);
        var git = new GitService();
        var target = await git.GetCurrentBranchAsync(repo.RepoDir);
        var statusBefore = await git.GetStatusPorcelainAsync(repo.RepoDir);
        var headBefore = await git.RevParseHeadAsync(repo.RepoDir);

        var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);

        Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
        Assert.Equal(0, preview.VerifyExitCode);
        Assert.NotNull(preview.VerifyDurationMs);
        Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
        Assert.NotEqual(repo.RepoDir, fakeVerify.CapturedWorkingDir);
        Assert.True(fakeVerify.WorkingDirHadFile, "the merged file must be present in the scratch worktree at verify time");

        // Scratch worktree removed afterward; the real target working tree never changed.
        Assert.False(Directory.Exists(fakeVerify.CapturedWorkingDir));
        Assert.Equal(statusBefore, await git.GetStatusPorcelainAsync(repo.RepoDir));
        Assert.Equal(headBefore, await git.RevParseHeadAsync(repo.RepoDir));
    }

    [Fact]
    public async Task PreviewAsync_RunVerifyTrue_VerifyFails_ReturnsExitCodeAndOutputTailWithoutTouchingTarget()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet build");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
        var (svc, _) = BuildService(db, fakeVerify);
        var git = new GitService();
        var target = await git.GetCurrentBranchAsync(repo.RepoDir);
        var headBefore = await git.RevParseHeadAsync(repo.RepoDir);

        var preview = await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);

        // The merge-tree result itself is still clean -- only the build/test of it failed.
        Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
        Assert.Equal(1, preview.VerifyExitCode);
        Assert.Contains("build broke: CS0103", preview.VerifyOutputTail);
        Assert.False(Directory.Exists(fakeVerify.CapturedWorkingDir));
        Assert.Equal(headBefore, await git.RevParseHeadAsync(repo.RepoDir));
    }

    [Fact]
    public async Task PreviewAsync_RunVerifyTrue_CleansUpScratchWorktreeRegistration()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet build");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
        var (svc, _) = BuildService(db, fakeVerify);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        await svc.PreviewAsync(task.Id, target, runVerify: true, CancellationToken.None);

        var worktreeListing = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list", "--porcelain");
        Assert.DoesNotContain(fakeVerify.CapturedWorkingDir!, worktreeListing);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, updated!.Status);
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Merged, wt!.State);
        // Approve removes the worktree so merged worktrees don't pile up.
        Assert.False(Directory.Exists(wtCtx.WorktreePath));
    }

    [Fact]
    public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
        var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        var (svc, _) = BuildService(db);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusConflict, result.Status);
        Assert.Contains("README.md", result.ConflictFiles);

        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Active, wt!.State);
        Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
        Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
    }

    [Fact]
    public async Task GetConflictDocumentsAsync_ParsesLineLevelHunksWithDiff3Base()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

        // A committed README gives the conflict a common ancestor, so diff3 records a base.
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "line1\nbase\nline3\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "line1\nWORKTREE\nline3\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "line1\nMAIN\nline3\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");

        var (svc, _) = BuildService(db);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var merge = await svc.MergeAsync(
            task.Id, target, removeWorktree: false, "merge",
            leaveConflictsInTree: true, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusConflict, merge.Status);

        var docs = await svc.GetConflictDocumentsAsync(task.Id, CancellationToken.None);

        var file = Assert.Single(docs.Files);
        Assert.EndsWith("README.md", file.Path.Replace('\\', '/'));
        Assert.False(file.IsBinary);

        var conflict = Assert.Single(file.Segments.Where(s => s.IsConflict).ToList());
        Assert.Contains("MAIN", conflict.Ours);          // ours = current branch (merge target)
        Assert.Contains("WORKTREE", conflict.Theirs);     // theirs = incoming worktree branch
        Assert.NotNull(conflict.Base);
        Assert.Contains("base", conflict.Base!);          // diff3 merge base
        Assert.Contains(file.Segments, s => !s.IsConflict && s.Text.Contains("line1"));
    }

    // git never writes <<<<<<< markers for a modify/delete conflict -- it just leaves the
    // modifying side's content sitting in the working tree, which used to read as an
    // already-resolved stable file (no conflict at all) to both GetConflictDocumentsAsync and
    // ContinueMergeAsync's marker scan, so `continue_merge` would silently keep the modification.
    [Fact]
    public async Task ModifyDeleteConflict_IsSynthesizedAndCannotBeSilentlyStaged()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/modify-delete", wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "README.md"), "# modified by branch\n");
        GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch modifies README");

        GitRepoFixture.RunGit(repo.RepoDir, "rm", "README.md");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main deletes README");

        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, wtPath, "claudedo/modify-delete", repo.BaseCommit);

        var (svc, _) = BuildService(db);
        var merge = await svc.MergeAsync(task.Id, "main", removeWorktree: false, "msg",
            leaveConflictsInTree: true, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusConflict, merge.Status);

        // No <<<<<<< markers anywhere -- git left the branch's modification as-is.
        Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));

        var docs = await svc.GetConflictDocumentsAsync(task.Id, CancellationToken.None);
        var file = Assert.Single(docs.Files);
        Assert.EndsWith("README.md", file.Path.Replace('\\', '/'));
        Assert.False(file.IsBinary);
        var conflict = Assert.Single(file.Segments.Where(s => s.IsConflict).ToList());
        Assert.Equal("", conflict.Ours);                        // ours (main) deleted it
        Assert.Contains("modified by branch", conflict.Theirs); // theirs (branch) modified it

        // Nobody resolved it — continue must refuse rather than silently keeping the modification.
        var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
        var refused = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusConflict, refused.Status);
        Assert.Contains("README.md", refused.ConflictFiles);
        Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
        Assert.True(await new GitService().IsMidMergeAsync(repo.RepoDir));

        // Explicitly resolving to the deleted (ours) side must `git rm`, not stage an empty file.
        await svc.WriteResolutionAsync(task.Id, "README.md", "", CancellationToken.None);
        var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        Assert.False(File.Exists(Path.Combine(repo.RepoDir, "README.md")));
        Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
    }

    [Fact]
    public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
    {
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
        var (svc, _) = BuildService(db);

        var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, updated!.Status);
    }

    // A list-handler task owns no worktree -- it commits straight into the list's working dir --
    // so the approve path short-circuits past the merge. The verify gate must still apply, or the
    // run that lands the most on the target branch would be the one run nothing checks.
    [Fact]
    public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyFails_StaysOutOfDone()
    {
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");
        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "2 failed") };
        var (svc, _) = BuildService(db, fakeVerify);

        var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
        Assert.Contains("2 failed", result.ErrorMessage ?? "");
        Assert.Equal("/tmp", fakeVerify.CapturedWorkingDir);
        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyPasses_MarksDone()
    {
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");
        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
        var (svc, _) = BuildService(db, fakeVerify);

        var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, updated!.Status);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_NoVerifyCommandConfigured_NeverInvokesRunnerAndMarksDone()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
        var (svc, _) = BuildService(db, fakeVerify);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        Assert.Null(fakeVerify.CapturedCommand);
        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, updated!.Status);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_VerifyCommandSucceeds_RunsInListWorkingDirAndMarksDone()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "all green") };
        var (svc, _) = BuildService(db, fakeVerify);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
        Assert.Equal(repo.RepoDir, fakeVerify.CapturedWorkingDir);
        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, updated!.Status);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_VerifyCommandFails_KeepsMergeButNotDone()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
        var (svc, _) = BuildService(db, fakeVerify);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
        Assert.Contains("build broke: CS0103", result.ErrorMessage);

        // The git merge itself is left in place — main already has the merged content.
        Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));

        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Merged, wt!.State);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_VerifyCommandTimesOut_ReturnsVerifyFailedWithTimeoutMessage()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;
        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(-1, true, "") };
        var (svc, _) = BuildService(db, fakeVerify);
        var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
        Assert.Contains("timed out", result.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);

        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
    }

    [Fact]
    public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t1", wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
        GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");

        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit);

        var (svc, _) = BuildService(db);

        var result = await svc.MergeAsync(
            task.Id, "main", removeWorktree: false, "msg",
            leaveConflictsInTree: true,
            CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusConflict, result.Status);
        Assert.Contains("README.md", result.ConflictFiles);

        var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"));
        Assert.True(midMerge, "repo should be left in mid-merge state");

        // Cleanup
        GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
    }

    [Fact]
    public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");

        var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPath));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
        File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
        GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");

        var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
        await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);

        var (svc, _) = BuildService(db);
        await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);

        await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
        var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
        Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
    }

    [Fact]
    public async Task MergeAsync_Success_RecordsMergeCommitOnWorktree()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge task", ct: CancellationToken.None);
        Assert.Equal("merged", result.Status);

        var headSha = (await new GitService().RevParseHeadAsync(repo.RepoDir)).Trim();

        using var ctx = db.CreateContext();
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(headSha, wt!.MergeCommit);
    }

    [Fact]
    public async Task RevertMergeAsync_NoWorktree_ReturnsBlocked()
    {
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
        var (svc, _) = BuildService(db);

        var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
        Assert.Contains("no worktree", result.ErrorMessage ?? "");
    }

    [Fact]
    public async Task RevertMergeAsync_TaskNotDone_ReturnsBlocked()
    {
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
        await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
            state: WorktreeState.Merged, mergeCommit: "cafebabe");
        var (svc, _) = BuildService(db);

        var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
        Assert.Contains("not Done", result.ErrorMessage ?? "");
    }

    [Fact]
    public async Task RevertMergeAsync_NoMergeCommitRecorded_ReturnsBlocked()
    {
        // Simulates a worktree merged before this feature existed — Merged state, no SHA.
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
            state: WorktreeState.Merged, mergeCommit: null);
        var (svc, _) = BuildService(db);

        var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
        Assert.Contains("no merge commit recorded", result.ErrorMessage ?? "");
    }

    [Fact]
    public async Task RevertMergeAsync_WorktreeNotMerged_ReturnsBlocked()
    {
        var db = NewDb();
        var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
        await SeedWorktree(db, task.Id, "/tmp/wt", "claudedo/x", "deadbeef",
            state: WorktreeState.Active, mergeCommit: null);
        var (svc, _) = BuildService(db);

        var result = await svc.RevertMergeAsync(task.Id, "main", CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusBlocked, result.Status);
        Assert.Contains("expected Merged", result.ErrorMessage ?? "");
    }

    [Fact]
    public async Task RevertMergeAsync_AfterCleanMerge_CreatesRevertCommitAndResetsState()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, proxy) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
            commitMessage: "Merge task", ct: CancellationToken.None);
        Assert.Equal("merged", mergeResult.Status);
        Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));

        var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
        Assert.False(string.IsNullOrWhiteSpace(revertResult.RevertCommit));
        Assert.Empty(revertResult.ConflictFiles);
        Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));

        using var ctx = db.CreateContext();
        var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, updatedTask!.Status);
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Kept, wt!.State);

        Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_AfterRevert_BranchGone_ReturnsBlockedInsteadOfFakeMerge()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        // Approve removes the worktree AND deletes its branch (removeWorktree: true), so after a
        // later revert there is genuinely nothing left to re-merge.
        var approveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusMerged, approveResult.Status);

        var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);

        var reapproveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusBlocked, reapproveResult.Status);
        Assert.Contains("branch", reapproveResult.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
        Assert.Contains("no longer exists", reapproveResult.ErrorMessage ?? "");

        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Kept, wt!.State);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_AfterRevert_BranchStillExists_ReallyMergesAgain()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        // Merge (not approve) with removeWorktree: false, so the branch and worktree dir survive
        // the merge -- e.g. a manual merge_task call, or a merge whose cleanup was skipped.
        var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge task", ct: CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusMerged, mergeResult.Status);

        string? firstMergeCommit;
        using (var preRevertCtx = db.CreateContext())
            firstMergeCommit = (await new WorktreeRepository(preRevertCtx).GetByTaskIdAsync(task.Id))!.MergeCommit;

        var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
        Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));

        var reapproveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusMerged, reapproveResult.Status);
        Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));

        using var ctx = db.CreateContext();
        var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, updated!.Status);
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Merged, wt!.State);
        Assert.False(string.IsNullOrWhiteSpace(wt.MergeCommit));
        Assert.NotEqual(firstMergeCommit, wt.MergeCommit);
    }

    [Fact]
    public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
            commitMessage: "Merge task", ct: CancellationToken.None);
        Assert.Equal("merged", mergeResult.Status);

        // A concurrent session leaves an uncommitted, tracked-file change in the shared checkout.
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty from another session\n");
        var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusBlocked, revertResult.Status);
        Assert.Contains("uncommitted", revertResult.ErrorMessage ?? "");
        Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());

        using var ctx = db.CreateContext();
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Merged, wt!.State);
    }

    [Fact]
    public async Task RevertMergeAsync_ConflictingRevert_AbortsCleanlyAndReportsConflictFiles()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# test repo\nfeature\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
            commitMessage: "Merge task", ct: CancellationToken.None);
        Assert.Equal("merged", mergeResult.Status);

        // A later commit on the target edits the exact line the merge introduced, so the revert conflicts.
        File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# test repo\npost-merge edit\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: edit the same line");
        var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusConflictAborted, revertResult.Status);
        Assert.Contains("README.md", revertResult.ConflictFiles);
        Assert.False(await new GitService().IsMidRevertAsync(repo.RepoDir));
        Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
        Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));

        // Task/worktree state must not change on a conflicted, aborted revert.
        using var ctx = db.CreateContext();
        var updatedTask = await new TaskRepository(ctx).GetByIdAsync(task.Id);
        Assert.Equal(TaskStatus.Done, updatedTask!.Status);
        var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
        Assert.Equal(WorktreeState.Merged, wt!.State);
    }

    [Fact]
    public async Task MergeAsync_OverlappingWaitingForReviewBranch_GetsRebasedOntoNewTip()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "line1\nline2\nline3\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "seed shared file");
        var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPathA));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/reba-a", wtPathA, baseCommit);
        File.WriteAllText(Path.Combine(wtPathA, "shared.txt"), "line1-changed\nline2\nline3\n");
        GitRepoFixture.RunGit(wtPathA, "commit", "-am", "A changes line1");
        await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/reba-a", baseCommit);

        // Task B is WaitingForReview and touches the same file A just changed, but a different
        // line, so rebasing it onto the new tip should apply cleanly.
        var taskB = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = list.Id,
            Title = "task-b",
            Status = TaskStatus.WaitingForReview,
            CreatedAt = DateTime.UtcNow,
        };
        using (var ctx = db.CreateContext())
            await new TaskRepository(ctx).AddAsync(taskB);

        var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPathB));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/reba-b", wtPathB, baseCommit);
        File.WriteAllText(Path.Combine(wtPathB, "shared.txt"), "line1\nline2\nline3-changed\n");
        GitRepoFixture.RunGit(wtPathB, "commit", "-am", "B changes line3");
        await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/reba-b", baseCommit);

        var (svc, proxy) = BuildService(db);

        var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusMerged, result.Status);

        // B's worktree must now carry both changes -- rebased cleanly onto the new main tip.
        Assert.Equal("line1-changed\nline2\nline3-changed\n",
            File.ReadAllText(Path.Combine(wtPathB, "shared.txt")).Replace("\r\n", "\n"));

        var mainHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "main").Trim();
        using var ctx2 = db.CreateContext();
        var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
        Assert.Equal(mainHead, wtB!.BaseCommit);
        Assert.Equal(GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim(), wtB.HeadCommit);

        Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == taskB.Id);

        // The rebase phase must be visible on the wire while it runs -- otherwise the UI still
        // shows the stalled "Merging…" phase for the whole (best-effort) rebase loop.
        Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress"
            && c.Args[0] is string opKey && opKey == taskA.Id
            && c.Args[1] is string phase && phase == TaskMergeService.PhaseRebasing
            && c.Args[2] is int current && current == 1
            && c.Args[3] is int total && total == 1);
    }

    [Fact]
    public async Task MergeAsync_NonOverlappingWaitingForReviewBranch_IsLeftAlone()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
        var baseCommit = repo.BaseCommit;

        var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPathA));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/noov-a", wtPathA, baseCommit);
        File.WriteAllText(Path.Combine(wtPathA, "a-only.txt"), "a\n");
        GitRepoFixture.RunGit(wtPathA, "add", "-A");
        GitRepoFixture.RunGit(wtPathA, "commit", "-m", "A adds a-only.txt");
        await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/noov-a", baseCommit);

        // Task B touches a completely different file -- merely behind, not worth disturbing.
        var taskB = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = list.Id,
            Title = "task-b",
            Status = TaskStatus.WaitingForReview,
            CreatedAt = DateTime.UtcNow,
        };
        using (var ctx = db.CreateContext())
            await new TaskRepository(ctx).AddAsync(taskB);

        var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPathB));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/noov-b", wtPathB, baseCommit);
        File.WriteAllText(Path.Combine(wtPathB, "b-only.txt"), "b\n");
        GitRepoFixture.RunGit(wtPathB, "add", "-A");
        GitRepoFixture.RunGit(wtPathB, "commit", "-m", "B adds b-only.txt");
        var headBBefore = GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim();
        await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/noov-b", baseCommit);

        var (svc, proxy) = BuildService(db);

        var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusMerged, result.Status);

        // B is untouched: same head, same recorded base commit, no broadcast for B.
        Assert.Equal(headBBefore, GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim());
        using var ctx2 = db.CreateContext();
        var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
        Assert.Equal(baseCommit, wtB!.BaseCommit);
        Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == taskB.Id);
    }

    [Fact]
    public async Task MergeAsync_OverlappingButConflictingRebase_LeavesBranchAsIsAndWarns()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var db = NewDb();
        var repo = NewRepo();
        GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");

        File.WriteAllText(Path.Combine(repo.RepoDir, "shared.txt"), "line1\nline2\nline3\n");
        GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
        GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "seed shared file");
        var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();

        var (list, taskA) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
        var wtPathA = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPathA));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/conf-a", wtPathA, baseCommit);
        File.WriteAllText(Path.Combine(wtPathA, "shared.txt"), "line1\nA-line2\nline3\n");
        GitRepoFixture.RunGit(wtPathA, "commit", "-am", "A changes line2");
        await SeedWorktree(db, taskA.Id, wtPathA, "claudedo/conf-a", baseCommit);

        // Task B changes the exact same line differently -- rebasing must conflict.
        var taskB = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = list.Id,
            Title = "task-b",
            Status = TaskStatus.WaitingForReview,
            CreatedAt = DateTime.UtcNow,
        };
        using (var ctx = db.CreateContext())
            await new TaskRepository(ctx).AddAsync(taskB);

        var wtPathB = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
        _wtCleanups.Add((repo.RepoDir, wtPathB));
        GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/conf-b", wtPathB, baseCommit);
        File.WriteAllText(Path.Combine(wtPathB, "shared.txt"), "line1\nB-line2\nline3\n");
        GitRepoFixture.RunGit(wtPathB, "commit", "-am", "B changes line2");
        var headBBefore = GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim();
        await SeedWorktree(db, taskB.Id, wtPathB, "claudedo/conf-b", baseCommit);

        var (svc, proxy) = BuildService(db);

        var result = await svc.MergeAsync(taskA.Id, "main", removeWorktree: false, "Merge A", CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusMerged, result.Status);

        // B is left exactly as it was: same head commit, same content, no mid-rebase state, base
        // commit unchanged in the DB, and a clear warning went out instead of silent partial work.
        Assert.Equal(headBBefore, GitRepoFixture.RunGit(wtPathB, "rev-parse", "HEAD").Trim());
        Assert.Equal("line1\nB-line2\nline3\n",
            File.ReadAllText(Path.Combine(wtPathB, "shared.txt")).Replace("\r\n", "\n"));
        Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(wtPathB, "status", "--porcelain")));

        using var ctx2 = db.CreateContext();
        var wtB = await new WorktreeRepository(ctx2).GetByTaskIdAsync(taskB.Id);
        Assert.Equal(baseCommit, wtB!.BaseCommit);

        Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
            && c.Args[0] is string s && s.Contains("Auto-rebase failed") && s.Contains("task-b"));
    }

    [Fact]
    public async Task MergeAsync_SkipVerify_DefersGateAndDoneTransition()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "batch.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "must not run") };
        var (svc, _) = BuildService(db, fakeVerify);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge", leaveConflictsInTree: false, CancellationToken.None, skipVerify: true);

        Assert.Equal(TaskMergeService.StatusMergedVerifyPending, result.Status);
        Assert.Null(fakeVerify.CapturedCommand);

        // Merge landed (worktree Merged), but the Done transition is deferred.
        using var ctx = db.CreateContext();
        Assert.Equal(WorktreeState.Merged, (await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id))!.State);
        Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
    }

    [Fact]
    public async Task MergeAsync_SkipVerify_NoVerifyCommand_CompletesNormally()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "plain.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var (svc, _) = BuildService(db);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge", leaveConflictsInTree: false, CancellationToken.None, skipVerify: true);

        Assert.Equal(TaskMergeService.StatusMerged, result.Status);
        using var ctx = db.CreateContext();
        Assert.Equal(TaskStatus.Done, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
    }

    [Fact]
    public async Task RunListVerifyAsync_Passes_PromotesPendingAndRefusesActiveWorktree()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, merged) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(merged, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "batch.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, merged, list, CancellationToken.None);

        // A second task whose worktree is still Active -- its branch never landed, so a passing
        // verify must NOT promote it.
        var unmerged = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = list.Id,
            Title = "unmerged",
            Status = TaskStatus.WaitingForReview,
            CreatedAt = DateTime.UtcNow,
        };
        using (var ctx = db.CreateContext())
            await new TaskRepository(ctx).AddAsync(unmerged);
        await SeedWorktree(db, unmerged.Id, "/tmp/never-merged", "claudedo/never", "abc");

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
        var (svc, _) = BuildService(db, fakeVerify);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        var mergeResult = await svc.MergeAsync(merged.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge", leaveConflictsInTree: false, CancellationToken.None, skipVerify: true);
        Assert.Equal(TaskMergeService.StatusMergedVerifyPending, mergeResult.Status);

        var verify = await svc.RunListVerifyAsync(list.Id, new[] { merged.Id, unmerged.Id }, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusVerifyPassed, verify.Status);
        Assert.Equal(1, fakeVerify.CallCount);
        Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
        Assert.Equal(repo.RepoDir, fakeVerify.CapturedWorkingDir);

        var mergedOutcome = verify.Tasks.Single(o => o.TaskId == merged.Id);
        Assert.True(mergedOutcome.Promoted);
        var unmergedOutcome = verify.Tasks.Single(o => o.TaskId == unmerged.Id);
        Assert.False(unmergedOutcome.Promoted);
        Assert.Contains("Active", unmergedOutcome.Reason);

        using var ctx2 = db.CreateContext();
        Assert.Equal(TaskStatus.Done, (await new TaskRepository(ctx2).GetByIdAsync(merged.Id))!.Status);
        Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx2).GetByIdAsync(unmerged.Id))!.Status);
    }

    [Fact]
    public async Task RunListVerifyAsync_Fails_NothingPromoted()
    {
        if (!GitRepoFixture.IsGitAvailable()) return;

        var repo = NewRepo();
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");

        var wtMgr = BuildWorktreeManager(db);
        var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
        _wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
        File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "batch.txt"), "x\n");
        await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);

        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "3 failed") };
        var (svc, _) = BuildService(db, fakeVerify);
        var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);

        await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
            commitMessage: "Merge", leaveConflictsInTree: false, CancellationToken.None, skipVerify: true);

        var verify = await svc.RunListVerifyAsync(list.Id, new[] { task.Id }, CancellationToken.None);

        Assert.Equal(TaskMergeService.StatusVerifyFailed, verify.Status);
        Assert.Contains("3 failed", verify.ErrorMessage);
        Assert.All(verify.Tasks, o => Assert.False(o.Promoted));

        using var ctx = db.CreateContext();
        Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
    }

    [Fact]
    public async Task ApproveAndMergeAsync_NoWorktree_SkipVerify_StaysPendingAndPromotesViaListVerify()
    {
        var db = NewDb();
        var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
        await SeedVerifyCommand(db, list.Id, "dotnet test");
        var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
        var (svc, _) = BuildService(db, fakeVerify);

        var result = await svc.ApproveAndMergeAsync(task.Id, "", leaveConflictsInTree: false,
            CancellationToken.None, skipVerify: true);

        Assert.Equal(TaskMergeService.StatusMergedVerifyPending, result.Status);
        Assert.Equal(0, fakeVerify.CallCount);
        using (var ctx = db.CreateContext())
            Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);

        // A worktree-less task is a valid batch-verify candidate (there was no branch to land).
        var verify = await svc.RunListVerifyAsync(list.Id, new[] { task.Id }, CancellationToken.None);
        Assert.Equal(TaskMergeService.StatusVerifyPassed, verify.Status);
        Assert.Equal(1, fakeVerify.CallCount);
        using (var ctx = db.CreateContext())
            Assert.Equal(TaskStatus.Done, (await new TaskRepository(ctx).GetByIdAsync(task.Id))!.Status);
    }
}

#region Test doubles

internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
{
    public VerifyCommandResult Result { get; set; } = new(0, false, "");
    public int CallCount { get; private set; }
    public string? CapturedWorkingDir { get; private set; }
    public string? CapturedCommand { get; private set; }

    // Set before the call to snapshot, at call time, whether this file exists in workingDir --
    // lets a preview-verify test prove the scratch worktree actually contains the merged content.
    public string? FileToCheck { get; set; }
    public bool? WorkingDirHadFile { get; private set; }

    // Simulates a slow build/test run so a progress-reporting test can observe at least one
    // report without the real verify command ever taking that long.
    public TimeSpan? Delay { get; set; }

    public async Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
    {
        CallCount++;
        CapturedWorkingDir = workingDir;
        CapturedCommand = command;
        if (FileToCheck is not null)
            WorkingDirHadFile = File.Exists(Path.Combine(workingDir, FileToCheck));
        if (Delay is { } delay)
            await Task.Delay(delay, ct);
        return Result;
    }
}

internal sealed record MergeHubCall(string Method, object?[] Args);

internal sealed class MergeRecordingClientProxy : IClientProxy
{
    public readonly List<MergeHubCall> Calls = new();
    public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
    {
        Calls.Add(new MergeHubCall(method, args));
        return Task.CompletedTask;
    }
}

internal sealed class MergeRecordingHubClients : IHubClients
{
    public MergeRecordingClientProxy AllProxy { get; } = new();
    public IClientProxy All => AllProxy;
    public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
    public IClientProxy Client(string connectionId) => AllProxy;
    public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
    public IClientProxy Group(string groupName) => AllProxy;
    public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
    public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
    public IClientProxy User(string userId) => AllProxy;
    public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}

internal sealed class MergeRecordingHubContext : IHubContext<ClaudeDo.Worker.Hub.WorkerHub>
{
    private readonly MergeRecordingHubClients _clients = new();
    public MergeRecordingClientProxy Proxy => _clients.AllProxy;
    public IHubClients Clients => _clients;
    public IGroupManager Groups => throw new NotImplementedException();
}

#endregion
