import Foundation
import Testing

@testable import NucleicCore

@Suite("WorktreeManager — real git on temp repos")
struct WorktreeManagerTests {
    let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)

    func manager() -> GitWorktreeManager {
        GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
    }

    // MARK: create

    @Test func createMakesWorktreeAndBranchOffBase() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let baseSHA = try await repo.revParse("HEAD")

        let wt = try await manager.create(
            for: .generate(), in: repo.project(), base: "main", slug: "Add Feature X")

        #expect(wt.branch == "nucleic/add-feature-x")
        #expect(wt.baseSHA == baseSHA)
        #expect(FileManager.default.fileExists(atPath: wt.path))
        // The branch exists and points at base.
        let branchSHA = try await repo.revParse("refs/heads/nucleic/add-feature-x")
        #expect(branchSHA == baseSHA)
        // The user's main checkout is untouched.
        let mainStatus = try await repo.run(["status", "--porcelain"]).stdout
        #expect(mainStatus.isEmpty)
    }

    @Test func createBasesOffRemoteOnlyBranchInAClone() async throws {
        // Regression: a fresh clone (e.g. a Nucleic Control repo) has only its checked-out
        // default branch as a local ref; another branch like `dev` exists only as `origin/dev`,
        // so `git rev-parse dev^{commit}` fails with "Needed a single revision". Basing a chat
        // off such a branch must still work by materializing a local tracking branch.
        let origin = try await GitTestRepo()
        defer { origin.cleanup() }
        try await origin.run(["checkout", "-b", "dev"])
        try origin.write("ondev.txt", "on dev\n")
        try await origin.run(["add", "-A"])
        try await origin.run(["commit", "-m", "dev work"])
        let devSHA = try await origin.revParse("HEAD")
        try await origin.run(["checkout", "main"])

        // Clone it: the clone has `main` local and `dev` only as `origin/dev`.
        let cloneParent = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-clone-\(UUID().uuidString)")
        defer { try? FileManager.default.removeItem(atPath: cloneParent) }
        try FileManager.default.createDirectory(atPath: cloneParent, withIntermediateDirectories: true)
        let clonePath = (cloneParent as NSString).appendingPathComponent("repo")
        try await GitRunner().checked(["clone", origin.root, clonePath], in: cloneParent)
        let git = GitRunner()
        #expect(try await git.run(["rev-parse", "--verify", "--quiet", "dev^{commit}"], in: clonePath).ok == false)

        let project = Project(name: "clone", rootPath: clonePath, defaultBranch: "main")
        let wt = try await manager().create(for: .generate(), in: project, base: "dev", slug: "off dev")

        // The worktree was created off origin/dev's tip…
        #expect(wt.baseSHA == devSHA)
        #expect(FileManager.default.fileExists(
            atPath: (wt.path as NSString).appendingPathComponent("ondev.txt")))
        // …and a local `dev` branch now exists (so a later merge into it also resolves).
        #expect(try await git.run(["rev-parse", "--verify", "--quiet", "dev^{commit}"], in: clonePath).ok == true)
    }

    @Test func createDedupesSlugOnCollision() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()

        let first = try await manager.create(for: .generate(), in: project, base: "main", slug: "dup")
        let second = try await manager.create(for: .generate(), in: project, base: "main", slug: "dup")

        #expect(first.branch == "nucleic/dup")
        #expect(second.branch == "nucleic/dup-2")
        #expect(first.path != second.path)
    }

    @Test func createInitializesRepoWhenDirectoryIsNotAGitRepo() async throws {
        // A plain directory that was never `git init`-ed — what a brand-new project
        // folder looks like before its first session.
        let container = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-tests-\(UUID().uuidString)")
        let root = (container as NSString).appendingPathComponent("fresh")
        try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(atPath: container) }
        let project = Project(name: "fresh", rootPath: root, defaultBranch: "main")

        let wt = try await manager().create(
            for: .generate(), in: project, base: "main", slug: "First Session")

        // The directory is now a git repo on `main` with a seed commit to branch from.
        let git = GitRunner()
        #expect(try await git.run(["rev-parse", "--show-toplevel"], in: root).ok)
        let head = try await git.checked(["rev-parse", "HEAD"], in: root)
        #expect(!head.isEmpty)
        // The worktree exists and branches off the seeded base.
        #expect(wt.branch == "nucleic/first-session")
        #expect(wt.baseSHA == head)
        #expect(FileManager.default.fileExists(atPath: wt.path))
    }

    @Test func createRejectsUnknownBase() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        await #expect(throws: GitError.self) {
            _ = try await manager().create(
                for: .generate(), in: repo.project(), base: "no-such-branch", slug: "x")
        }
    }

    // MARK: status & diff

    @Test func diffCapturesUncommittedTrackedAndUntracked() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let wt = try await manager.create(for: .generate(), in: repo.project(), base: "main", slug: "edits")

        // Modify a tracked file and add an untracked one — NO commit.
        try repo.write("README.md", "# test repo\nmore\n", in: wt.path)
        try repo.write("new.txt", "alpha\nbeta\n", in: wt.path)

        let status = try await manager.status(of: wt)
        #expect(status.isDirty)
        #expect(status.ahead == 0)  // nothing committed yet
        #expect(status.diffStat.filesChanged == 2)

        let diff = try await manager.diff(of: wt, DiffOptions())
        let paths = Set(diff.files.map(\.path))
        #expect(paths == ["README.md", "new.txt"])
        let untracked = diff.files.first { $0.path == "new.txt" }
        #expect(untracked?.status == .untracked)
        #expect(untracked?.added == 2)
        #expect(diff.patch.contains("new.txt"))
        #expect(diff.patch.contains("+more"))
    }

    @Test func statusReportsAheadAfterCommit() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let wt = try await manager.create(for: .generate(), in: repo.project(), base: "main", slug: "commitwork")

        try repo.write("a.txt", "x\n", in: wt.path)
        try await repo.run(["add", "-A"], in: wt.path)
        try await repo.run(["commit", "-m", "work"], in: wt.path)

        let status = try await manager.status(of: wt)
        #expect(status.ahead == 1)
        #expect(status.behind == 0)
        #expect(!status.isDirty)
        #expect(status.conflicts.isEmpty)
    }

    // MARK: finalize

    @Test func finalizeAutoCommitsDirtyTree() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let wt = try await manager.create(for: .generate(), in: repo.project(), base: "main", slug: "fin")

        try repo.write("b.txt", "hi\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "snapshot"))

        let status = try await manager.status(of: wt)
        #expect(!status.isDirty)
        #expect(status.ahead == 1)
    }

    @Test func finalizeFiresOnCommitOnlyWhenItCommits() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let wt = try await manager.create(for: .generate(), in: repo.project(), base: "main", slug: "cb")

        // Nothing changed → no commit → the callback (which drives the transcript note)
        // must not fire, so autoship never logs a "committed" line for a no-op turn.
        let noop = CallbackFlag()
        try await manager.finalize(wt, commit: .auto(message: "noop"), onCommit: { await noop.fire() })
        #expect(await noop.fired == false)

        // A real change commits, so the callback fires exactly once.
        try repo.write("b.txt", "hi\n", in: wt.path)
        let real = CallbackFlag()
        try await manager.finalize(wt, commit: .auto(message: "snapshot"), onCommit: { await real.fire() })
        #expect(await real.count == 1)
    }

    @Test func finalizeManualRejectsDirtyTree() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let wt = try await manager.create(for: .generate(), in: repo.project(), base: "main", slug: "man")
        try repo.write("c.txt", "hi\n", in: wt.path)

        await #expect(throws: GitError.self) {
            try await manager.finalize(wt, commit: .manual)
        }
    }

    // MARK: integrate

    @Test func integrateMergeLandsIntoMain() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "merge-me")

        try repo.write("feature.txt", "shipped\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "add feature"))

        let result = try await manager.integrate(wt, in: project, into: "main", strategy: .merge, message: "land")
        guard case .clean(let into, let commit) = result else {
            Issue.record("expected clean merge, got \(result)")
            return
        }
        #expect(into == GitRef("main"))
        // main now contains the file in its working tree (synced), and HEAD == commit.
        #expect(try await repo.revParse("HEAD") == commit)
        #expect(repo.read("feature.txt") == "shipped\n")
        // --no-ff creates a merge commit (2 parents).
        let parents = try await repo.git.checked(["rev-list", "--parents", "-n", "1", "HEAD"], in: repo.root)
        #expect(parents.split(separator: " ").count == 3)
        let mainStatus = try await repo.run(["status", "--porcelain"]).stdout
        #expect(mainStatus.isEmpty)
    }

    @Test func integrateSquashMakesSingleCommit() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "squash-me")

        // Two commits on the branch.
        try repo.write("s1.txt", "1\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "c1"))
        try repo.write("s2.txt", "2\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "c2"))

        let before = try await repo.revParse("HEAD")
        let result = try await manager.integrate(wt, in: project, into: "main", strategy: .squash, message: "squashed")
        guard case .clean(_, let commit) = result else {
            Issue.record("expected clean squash, got \(result)")
            return
        }
        // Single new commit on main, fast-forward parent == old main, both files present.
        #expect(commit != before)
        let parents = try await repo.git.checked(["rev-list", "--parents", "-n", "1", "HEAD"], in: repo.root)
        #expect(parents.split(separator: " ").count == 2)  // one parent
        #expect(repo.read("s1.txt") == "1\n")
        #expect(repo.read("s2.txt") == "2\n")
    }

    @Test func integrateRebaseIsLinear() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "rebase-me")

        // Advance main independently so a rebase actually replays.
        try repo.write("on-main.txt", "m\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "main moves"])

        try repo.write("on-branch.txt", "b\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "branch work"))

        let result = try await manager.integrate(wt, in: project, into: "main", strategy: .rebase)
        guard case .clean = result else {
            Issue.record("expected clean rebase, got \(result)")
            return
        }
        // Linear: HEAD has a single parent and both files exist.
        let parents = try await repo.git.checked(["rev-list", "--parents", "-n", "1", "HEAD"], in: repo.root)
        #expect(parents.split(separator: " ").count == 2)
        #expect(repo.read("on-main.txt") == "m\n")
        #expect(repo.read("on-branch.txt") == "b\n")
    }

    @Test func integrateReportsConflictAndLeavesMainClean() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "conflict")

        // Both sides edit the same line of README.
        try repo.write("README.md", "# test repo\nFROM BRANCH\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "branch edit"))
        try repo.write("README.md", "# test repo\nFROM MAIN\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "main edit"])
        let mainBefore = try await repo.revParse("HEAD")

        let result = try await manager.integrate(wt, in: project, into: "main", strategy: .merge)
        guard case .conflicted(let paths) = result else {
            Issue.record("expected conflict, got \(result)")
            return
        }
        #expect(paths.map(\.path) == ["README.md"])
        // Merge aborted: main HEAD unmoved and the working tree is clean again.
        #expect(try await repo.revParse("HEAD") == mainBefore)
        let mainStatus = try await repo.run(["status", "--porcelain"]).stdout
        #expect(mainStatus.isEmpty)
    }

    // MARK: unmergedFiles (conflict footprint / "borrowed" files)

    @Test func unmergedFilesReleasesAfterMerge() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "borrow")

        // Uncommitted edit + untracked file: both held.
        try repo.write("README.md", "# test repo\nedit\n", in: wt.path)
        try repo.write("brand-new.txt", "x\n", in: wt.path)
        #expect(try await manager.unmergedFiles(of: wt, into: "main") == ["README.md", "brand-new.txt"].sorted())

        // Committing doesn't release the lock — the work still isn't in main.
        try await manager.finalize(wt, commit: .auto(message: "work"))
        #expect(try await manager.unmergedFiles(of: wt, into: "main") == ["README.md", "brand-new.txt"].sorted())

        // Merging into main releases everything: the files are now reflected in target.
        guard case .clean = try await manager.integrate(wt, in: project, into: "main", strategy: .merge) else {
            Issue.record("expected clean merge")
            return
        }
        #expect(try await manager.unmergedFiles(of: wt, into: "main").isEmpty)
    }

    @Test func unmergedFilesReleasesAfterSquashAndIgnoresOtherSessions() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "squash-borrow")

        try repo.write("mine.txt", "mine\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "mine"))

        // Another session lands an unrelated file into main directly. It must NOT show up
        // as this session's footprint (it never touched it).
        try repo.write("theirs.txt", "theirs\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "other session"])
        #expect(try await manager.unmergedFiles(of: wt, into: "main") == ["mine.txt"])

        // Squash-merge releases the lock even though branch commits aren't in main's history.
        guard case .clean = try await manager.integrate(wt, in: project, into: "main", strategy: .squash) else {
            Issue.record("expected clean squash")
            return
        }
        #expect(try await manager.unmergedFiles(of: wt, into: "main").isEmpty)
    }

    // Regression: a session that merged its work stays alive (e.g. `awaitingInput`) while
    // `target` keeps advancing. Once `main` moves *past* a file the session already merged,
    // a fixed-fork-point footprint would re-lock it forever (it differs from main's new tip
    // yet was "changed since base"). The lock must stay released — the session contributed
    // nothing that isn't already in main.
    @Test func unmergedFilesStayReleasedAfterTargetMovesPastMergedFile() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "shared")

        // Session edits a shared file and merges it into main.
        try repo.write("shared.txt", "v1\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "v1"))
        guard case .clean = try await manager.integrate(wt, in: project, into: "main", strategy: .merge) else {
            Issue.record("expected clean merge")
            return
        }
        #expect(try await manager.unmergedFiles(of: wt, into: "main").isEmpty)

        // main moves on past the same file (another session lands v2). The first session
        // merged its work long ago, so it must NOT be reported as still holding shared.txt.
        try repo.write("shared.txt", "v2\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "v2 from another session"])
        #expect(try await manager.unmergedFiles(of: wt, into: "main").isEmpty)

        // A fresh uncommitted edit in the worktree re-acquires the lock, as expected.
        try repo.write("shared.txt", "v3\n", in: wt.path)
        #expect(try await manager.unmergedFiles(of: wt, into: "main") == ["shared.txt"])
    }

    // A dirty target checkout (the common autoship failure: a stray untracked file, or
    // uncommitted work, in the shared `dev`/main checkout) must no longer abort the merge.
    // The integration sets the dirt aside, lands the branch, then restores it untouched.
    @Test func integrateAutoStashesDirtyTargetAndRestoresIt() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "dirtymain")
        try repo.write("x.txt", "x\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "x"))

        // Dirty the user's main checkout two ways: a stray untracked file and an
        // uncommitted edit to a tracked file.
        try repo.write("scratch.txt", "uncommitted\n")
        try repo.write("README.md", "# test repo\nlocal wip\n")

        // The merge succeeds despite the dirty target.
        guard case .clean(_, let commit) = try await manager.integrate(
            wt, in: project, into: "main", strategy: .merge)
        else {
            Issue.record("expected clean merge over a dirty target")
            return
        }
        #expect(try await repo.revParse("HEAD") == commit)
        // The branch's work landed…
        #expect(repo.read("x.txt") == "x\n")
        // …and the user's set-aside dirt is restored exactly as it was.
        #expect(repo.read("scratch.txt") == "uncommitted\n")
        #expect(repo.read("README.md") == "# test repo\nlocal wip\n")
        // Nothing left languishing in the stash — it was popped back.
        #expect(try await repo.run(["stash", "list"]).stdout.isEmpty)
    }

    @Test func integrateFiresOnStashOnlyWhenTargetDirty() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()

        // Clean target: nothing is set aside, so the "set aside…" note never fires.
        let cleanWT = try await manager.create(for: .generate(), in: project, base: "main", slug: "cleantgt")
        try repo.write("a.txt", "a\n", in: cleanWT.path)
        try await manager.finalize(cleanWT, commit: .auto(message: "a"))
        let cleanFlag = CallbackFlag()
        _ = try await manager.integrate(
            cleanWT, in: project, into: "main", strategy: .merge, onStash: { await cleanFlag.fire() })
        #expect(await cleanFlag.fired == false)

        // Dirty target: the stray file is stashed before the merge, so the callback fires.
        let dirtyWT = try await manager.create(for: .generate(), in: project, base: "main", slug: "dirtytgt")
        try repo.write("b.txt", "b\n", in: dirtyWT.path)
        try await manager.finalize(dirtyWT, commit: .auto(message: "b"))
        try repo.write("scratch.txt", "wip\n")
        let dirtyFlag = CallbackFlag()
        _ = try await manager.integrate(
            dirtyWT, in: project, into: "main", strategy: .merge, onStash: { await dirtyFlag.fire() })
        #expect(await dirtyFlag.count == 1)
    }

    // After a clean ship, the session's own branch is brought back in sync with the freshly
    // updated target — so its next turn starts from what it just shipped (plus any sibling work
    // that landed in target), not from the stale fork point it would otherwise keep forking off.
    @Test func integrateResyncsSessionBranchFromTarget() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "resync")

        // The session does its work…
        try repo.write("mine.txt", "mine\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "mine"))

        // …while a sibling lands an unrelated file straight into main, moving the target ahead
        // of the session's fork point.
        try repo.write("theirs.txt", "theirs\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "sibling work"])

        let synced = CallbackFlag()
        guard case .clean = try await manager.integrate(
            wt, in: project, into: "main", strategy: .squash, onSync: { await synced.fire() })
        else {
            Issue.record("expected clean squash")
            return
        }

        // The resync fired once and pulled the sibling's file into the session's own worktree.
        #expect(await synced.count == 1)
        #expect(repo.read("theirs.txt", in: wt.path) == "theirs\n")
        // The session branch now contains main's tip — it's no longer behind what it shipped.
        let behind = try await repo.run(["rev-list", "--count", "\(wt.branch)..main"], in: wt.path)
            .stdout.trimmingCharacters(in: .whitespacesAndNewlines)
        #expect(behind == "0")
        // Its worktree is left clean and still on its own branch, ready for the next turn.
        #expect(try await repo.run(["status", "--porcelain"], in: wt.path).stdout.isEmpty)
        let branch = try await repo.git.checked(["symbolic-ref", "--short", "HEAD"], in: wt.path)
        #expect(branch == wt.branch)
    }

    // The resync only fires its note when it actually advances the branch. After a `.rebase`
    // integration the session branch already *is* the target tip, so there's nothing to pull
    // back and no spurious "synced…" note is emitted.
    @Test func integrateDoesNotFireOnSyncWhenBranchAlreadyCurrent() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "noop-sync")

        try repo.write("only.txt", "only\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "only"))

        let synced = CallbackFlag()
        guard case .clean = try await manager.integrate(
            wt, in: project, into: "main", strategy: .rebase, onSync: { await synced.fire() })
        else {
            Issue.record("expected clean rebase")
            return
        }
        #expect(await synced.fired == false)
    }

    // Autoship surfaces a "signed" note only when the host actually signs the commit the
    // integration creates. With SSH commit signing configured on the repo, the merge commit
    // is signed, so `onSigned` fires once with that commit's SHA. Conditionally skips on a
    // host whose ssh-keygen can't sign, so the test never flakes on missing tooling.
    @Test func integrateFiresOnSignedWhenCommitIsSigned() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()

        // Hermetic SSH commit signing — no GPG keyring needed.
        let keyPath = (repo.container as NSString).appendingPathComponent("sign_ed25519")
        let keygen = GitRunner(executable: "ssh-keygen")
        let gen = try await keygen.run(
            ["-t", "ed25519", "-N", "", "-C", "nucleic-test", "-f", keyPath], in: repo.container)
        guard gen.ok else { return }  // host can't generate keys — skip
        try await repo.run(["config", "gpg.format", "ssh"])
        try await repo.run(["config", "user.signingkey", keyPath + ".pub"])
        try await repo.run(["config", "commit.gpgsign", "true"])
        // Pin the signer to OpenSSH so this runs on a host whose global `gpg.ssh.program` is a
        // third-party signer (e.g. 1Password's `op-ssh-sign`, which can't sign with this ad-hoc
        // key) — otherwise the probe below would fail to sign and the test would skip there.
        try await repo.run(["config", "gpg.ssh.program", "ssh-keygen"])

        // Probe: confirm this host actually produces a signed commit before asserting. Detect the
        // stored `gpgsig` header rather than `%G?` — verification needs an allowed-signers file the
        // host needn't have, and would report a genuinely signed commit as "N".
        try repo.write("probe.txt", "p\n")
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "probe"])
        guard await Self.hasSignatureHeader(repo, "HEAD") else { return }  // signing unsupported — skip

        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "signed")
        try repo.write("work.txt", "w\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "work"))

        let signed = SignedCapture()
        guard case .clean(_, let commit) = try await manager.integrate(
            wt, in: project, into: "main", strategy: .merge,
            onSigned: { sha in await signed.capture(sha) })
        else {
            Issue.record("expected clean merge")
            return
        }
        // Fired exactly once, carrying the SHA of the commit this integration created…
        #expect(await signed.count == 1)
        #expect(await signed.sha == commit)
        // …and git really recorded a signature on it.
        #expect(await Self.hasSignatureHeader(repo, commit))
    }

    /// Whether the commit `ref` carries a `gpgsig` header — the verification-independent signal
    /// `WorktreeManager.commitIsSigned` uses (see its doc on why not `%G?`/`%GG`).
    private static func hasSignatureHeader(_ repo: GitTestRepo, _ ref: String) async -> Bool {
        guard let obj = try? await repo.run(["cat-file", "commit", ref]), obj.ok else { return false }
        for line in obj.stdout.split(separator: "\n", omittingEmptySubsequences: false) {
            if line.isEmpty { break }
            if line.hasPrefix("gpgsig ") { return true }
        }
        return false
    }

    // On the common path — no commit signing configured — the merge commit is unsigned, so
    // `onSigned` never fires and autoship logs no spurious "signed" note.
    @Test func integrateDoesNotFireOnSignedWhenUnsigned() async throws {
        let repo = try await GitTestRepo()  // commit.gpgsign=false by construction
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "unsigned")
        try repo.write("u.txt", "u\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "u"))

        let signed = CallbackFlag()
        guard case .clean = try await manager.integrate(
            wt, in: project, into: "main", strategy: .merge, onSigned: { _ in await signed.fire() })
        else {
            Issue.record("expected clean merge")
            return
        }
        #expect(await signed.fired == false)
    }

    // When the set-aside dirt collides with what was merged (a stray untracked file sharing
    // a merged file's name), the merge still lands cleanly and the user's bytes are retained
    // safely in the stash rather than left as conflict markers in the target tree.
    @Test func integrateRetainsDirtInStashWhenItCollidesWithMerge() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "collide")

        // The branch adds a new file…
        try repo.write("collide.txt", "from branch\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "branch adds collide"))

        // …whose name a stray untracked file in the target already occupies.
        try repo.write("collide.txt", "stray local copy\n")

        guard case .clean = try await manager.integrate(
            wt, in: project, into: "main", strategy: .squash)
        else {
            Issue.record("expected clean merge despite the colliding stray")
            return
        }
        // The merged version wins in the working tree; the target is clean (no markers).
        #expect(repo.read("collide.txt") == "from branch\n")
        let status = try await repo.run(["status", "--porcelain"]).stdout
        #expect(status.isEmpty)
        // The user's stray bytes are preserved in the stash for manual recovery.
        #expect(!(try await repo.run(["stash", "list"]).stdout.isEmpty))
    }

    @Test func integrateLandsIntoTargetCheckedOutInAnotherWorktree() async throws {
        // Gated flow: the main checkout sits on `main` while `dev` lives in a *separate*
        // worktree. Merging into `dev` must land in dev's worktree, not `git checkout dev`
        // in main (which git rejects: "'dev' is already checked out at ...").
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()

        // Put `dev` in its own worktree, leaving the main checkout on `main`.
        let devPath = (repo.container as NSString).appendingPathComponent("dev-wt")
        try await repo.run(["worktree", "add", "-b", "dev", devPath, "main"])

        // A session branched off dev does some work.
        let wt = try await manager.create(for: .generate(), in: project, base: "dev", slug: "into-dev")
        try repo.write("from-session.txt", "shipped\n", in: wt.path)
        try await manager.finalize(wt, commit: .auto(message: "work"))

        let result = try await manager.integrate(wt, in: project, into: "dev", strategy: .merge, message: "land into dev")
        guard case .clean(let into, let commit) = result else {
            Issue.record("expected clean merge into dev, got \(result)")
            return
        }
        #expect(into == GitRef("dev"))
        // dev's worktree is on the new commit, file present and tree clean.
        #expect(try await repo.revParse("HEAD", in: devPath) == commit)
        #expect(repo.read("from-session.txt", in: devPath) == "shipped\n")
        #expect(try await repo.run(["status", "--porcelain"], in: devPath).stdout.isEmpty)
        // The main checkout (still on `main`) is untouched and never moved to dev.
        let mainBranch = try await repo.git.checked(["symbolic-ref", "--short", "HEAD"], in: repo.root)
        #expect(mainBranch == "main")
    }

    // MARK: discard

    @Test func discardRemovesWorktreeAndBranch() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "trash")
        try repo.write("junk.txt", "junk\n", in: wt.path)

        try await manager.discard(wt, in: project, force: true)

        #expect(!FileManager.default.fileExists(atPath: wt.path))
        let branch = try await repo.run(["rev-parse", "--verify", "--quiet", "refs/heads/\(wt.branch)"])
        #expect(!branch.ok)  // branch gone
    }

    // MARK: reclaim / restore

    @Test func reclaimRemovesCheckoutButKeepsBranchAndCommitsWIP() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "reclaim")
        // Uncommitted work the cleanup must preserve, not lose.
        try repo.write("wip.txt", "in progress\n", in: wt.path)
        let tipBefore = try await repo.revParse("refs/heads/\(wt.branch)")

        try await manager.reclaim(wt, in: project)

        // The checkout is gone (disk reclaimed) but the branch survives…
        #expect(!FileManager.default.fileExists(atPath: wt.path))
        let branch = try await repo.run(["rev-parse", "--verify", "--quiet", "refs/heads/\(wt.branch)"])
        #expect(branch.ok)
        // …with the WIP committed onto it (a new tip beyond what create left).
        let tipAfter = try await repo.revParse("refs/heads/\(wt.branch)")
        #expect(tipAfter != tipBefore)
    }

    @Test func restoreReCreatesCheckoutWithPreservedWork() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "round-trip")
        try repo.write("wip.txt", "in progress\n", in: wt.path)

        try await manager.reclaim(wt, in: project)
        #expect(!FileManager.default.fileExists(atPath: wt.path))
        // Restore re-checks-out the same branch at the same path; the WIP returns intact.
        try await manager.restore(wt, in: project)

        #expect(FileManager.default.fileExists(atPath: wt.path))
        #expect(repo.read("wip.txt", in: wt.path) == "in progress\n")
        let onBranch = try await repo.run(
            ["symbolic-ref", "--quiet", "--short", "HEAD"], in: wt.path)
        #expect(onBranch.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == wt.branch)
    }

    @Test func reclaimOnAlreadyCleanWorktreeAddsNoCommit() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "clean")
        let tipBefore = try await repo.revParse("refs/heads/\(wt.branch)")

        try await manager.reclaim(wt, in: project)

        #expect(!FileManager.default.fileExists(atPath: wt.path))
        let tipAfter = try await repo.revParse("refs/heads/\(wt.branch)")
        #expect(tipAfter == tipBefore)  // nothing to commit → branch tip unchanged
    }

    // MARK: reconcile

    @Test func reconcileDetectsOrphansBothWays() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = manager()
        let project = repo.project()

        // A real worktree that the DB doesn't know about (in git, not in DB).
        let real = try await manager.create(for: .generate(), in: project, base: "main", slug: "real")
        // A DB record whose worktree never existed (in DB, not in git).
        let phantom = Worktree(
            sessionID: .generate(), path: (repo.container as NSString).appendingPathComponent("ghost"),
            branch: "nucleic/ghost", baseSHA: "deadbeef", createdAt: Date())

        let orphans = try await manager.reconcile(project, expected: [phantom])

        let inGit = orphans.filter { $0.kind == .inGitNotDB }
        let inDB = orphans.filter { $0.kind == .inDBNotGit }
        #expect(inGit.contains { GitWorktreeManager.canonical($0.path ?? "") == GitWorktreeManager.canonical(real.path) })
        #expect(inDB.contains { $0.branch == "nucleic/ghost" })
    }
}

@Suite("WorktreeManager — pure parsing & slugs")
struct WorktreeParsingTests {
    @Test func parseNumstatHandlesBinaryAndText() {
        let out = "3\t1\tsrc/a.swift\n-\t-\timg.png\n"
        let parsed = GitWorktreeManager.parseNumstat(out)
        #expect(parsed["src/a.swift"]?.added == 3)
        #expect(parsed["src/a.swift"]?.removed == 1)
        #expect(parsed["img.png"]?.added == 0)  // binary "-" → 0
    }

    @Test func parseNameStatusHandlesRenames() {
        let out = "M\ta.txt\nA\tb.txt\nD\tc.txt\nR096\told.txt\tnew.txt\n"
        let entries = GitWorktreeManager.parseNameStatus(out)
        #expect(entries.contains { $0.status == .modified && $0.path == "a.txt" })
        #expect(entries.contains { $0.status == .added && $0.path == "b.txt" })
        #expect(entries.contains { $0.status == .deleted && $0.path == "c.txt" })
        let rename = entries.first { $0.status == .renamed }
        #expect(rename?.path == "new.txt")
        #expect(rename?.oldPath == "old.txt")
    }

    @Test func parseWorktreeListExcludesMain() {
        let out = """
            worktree /repo/main
            HEAD abc
            branch refs/heads/main

            worktree /repo/.nucleic/wt-a
            HEAD def
            branch refs/heads/nucleic/a
            """
        let paths = GitWorktreeManager.parseWorktreeList(out, excluding: GitWorktreeManager.canonical("/repo/main"))
        #expect(paths == [GitWorktreeManager.canonical("/repo/.nucleic/wt-a")])
    }

    @Test func parseWorktreeBranchesMapsBranchesAndSkipsDetached() {
        let out = """
            worktree /repo/main
            HEAD abc
            branch refs/heads/staging

            worktree /repo/dev-wt
            HEAD def
            branch refs/heads/dev

            worktree /repo/detached
            HEAD 123
            detached
            """
        let branches = GitWorktreeManager.parseWorktreeBranches(out)
        #expect(branches["staging"] == "/repo/main")
        #expect(branches["dev"] == "/repo/dev-wt")
        #expect(branches["dev"] != nil && branches.count == 2)  // detached worktree contributes nothing
    }

    @Test func slugSanitizesAndFallsBack() {
        #expect(Slug.sanitize("Add Feature X!") == "add-feature-x")
        #expect(Slug.sanitize("  Fix__the///bug  ") == "fix-the-bug")
        #expect(Slug.sanitize("résumé café") == "résumé-café")
        #expect(Slug.sanitize("!!!", fallback: "session") == "session")
    }

    // MARK: update-on-grant — re-ground detection (LOCKING §4.5)

    /// Advance `main` by rewriting `file` and committing it in the repo root.
    private func advanceMain(_ repo: GitTestRepo, _ file: String, _ contents: String) async throws {
        try repo.write(file, contents)
        try await repo.run(["add", "-A"])
        try await repo.run(["commit", "-m", "advance \(file)"])
    }

    /// When the parent hasn't advanced past the worktree, update-on-grant is a no-op (`.clean`).
    @Test func updateFromParentSkipsWhenParentNotAdvanced() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let mgr = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let wt = try await mgr.create(for: .generate(), in: repo.project(), base: "main", slug: "borrow")
        let update = try await mgr.updateFromParent(wt, parentRef: "main", requestedFiles: ["made.txt"])
        #expect(update == .clean)
    }

    /// The parent moved a requested file after the fork: it's detected with a scoped diff, and the
    /// clean merge refreshes the worktree on disk.
    @Test func updateFromParentDetectsMovedRequestedFile() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let mgr = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        try await advanceMain(repo, "made.txt", "v1\n")            // seed made.txt on main
        let wt = try await mgr.create(for: .generate(), in: repo.project(), base: "main", slug: "borrow")
        try await advanceMain(repo, "made.txt", "v2\n")            // parent advances it after the fork

        let update = try await mgr.updateFromParent(wt, parentRef: "main", requestedFiles: ["made.txt"])
        #expect(update.conflicts.isEmpty)
        #expect(update.changedFiles == ["made.txt"])
        #expect(update.diff.contains("v2"))
        #expect(repo.read("made.txt", in: wt.path) == "v2\n")      // worktree refreshed (clean merge)
    }

    /// Detection is scoped to the requested files: a parent move to a file the agent isn't editing
    /// is not reported, even though the merge still brings it into the worktree.
    @Test func updateFromParentScopesToRequestedFiles() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let mgr = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        try await advanceMain(repo, "made.txt", "v1\n")
        try await advanceMain(repo, "other.txt", "o1\n")
        let wt = try await mgr.create(for: .generate(), in: repo.project(), base: "main", slug: "borrow")
        try await advanceMain(repo, "made.txt", "v2\n")
        try await advanceMain(repo, "other.txt", "o2\n")

        let update = try await mgr.updateFromParent(wt, parentRef: "main", requestedFiles: ["made.txt"])
        #expect(update.changedFiles == ["made.txt"])              // other.txt moved too, but isn't requested
        #expect(!update.diff.contains("o2"))
    }

    /// The key dirty-tree case: the agent has an uncommitted edit to an unrelated file the parent
    /// also advanced, so `git merge` refuses — but the commit-to-commit detection still reports the
    /// moved requested file, so the agent is re-grounded anyway.
    @Test func updateFromParentDirtyTreeStillReportsMovement() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let mgr = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        try await advanceMain(repo, "made.txt", "v1\n")
        try await advanceMain(repo, "dirty.txt", "d1\n")
        let wt = try await mgr.create(for: .generate(), in: repo.project(), base: "main", slug: "borrow")
        try await advanceMain(repo, "made.txt", "v2\n")
        try await advanceMain(repo, "dirty.txt", "d2\n")
        try repo.write("dirty.txt", "local edit\n", in: wt.path)  // uncommitted → merge refuses

        let update = try await mgr.updateFromParent(wt, parentRef: "main", requestedFiles: ["made.txt"])
        #expect(update.conflicts.isEmpty)
        #expect(update.changedFiles == ["made.txt"])
        #expect(update.diff.contains("v2"))
        #expect(repo.read("made.txt", in: wt.path) == "v1\n")      // merge refused, worktree NOT refreshed
    }

    /// A bad/missing parent ref fails open to `.clean` — never throws or blocks (e.g. an orphaned
    /// cascade child whose parent branch is gone).
    @Test func updateFromParentMissingParentRefFailsOpen() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let mgr = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let wt = try await mgr.create(for: .generate(), in: repo.project(), base: "main", slug: "borrow")
        let update = try await mgr.updateFromParent(
            wt, parentRef: "no-such-branch", requestedFiles: ["made.txt"])
        #expect(update == .clean)
    }
}

/// Records how many times a progress callback fired, across actor hops.
private actor CallbackFlag {
    private(set) var count = 0
    var fired: Bool { count > 0 }
    func fire() { count += 1 }
}

/// Captures the commit SHA handed to an `onSigned` callback, across actor hops.
private actor SignedCapture {
    private(set) var count = 0
    private(set) var sha: String?
    func capture(_ s: String) { count += 1; sha = s }
}
