import Foundation
import Testing

@testable import NucleicCore

@Suite("NvrsionTrunk — real git on temp repos (NVRSION §2–3)")
struct NvrsionTrunkTests {
    let trunkBranch = "nucleic/trunk"

    /// Create a repo + ensure its trunk; returns (repo, trunk, trunkPath).
    func makeTrunk() async throws -> (GitTestRepo, NvrsionTrunk, String) {
        let repo = try await GitTestRepo()
        let trunk = NvrsionTrunk()
        let trunkPath = repo.project().resolvedTrunkPath
        try await trunk.ensureTrunk(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
        return (repo, trunk, trunkPath)
    }

    @Test func ensureTrunkCreatesWorktreeOnBranchOffBase() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }

        #expect(FileManager.default.fileExists(atPath: trunkPath))
        // The trunk branch exists and starts at the base (main) HEAD.
        let mainHead = try await repo.revParse("HEAD")
        let trunkHead = try await repo.revParse("refs/heads/\(trunkBranch)")
        #expect(trunkHead == mainHead)
        // The checkout in the trunk dir is on the trunk branch.
        let onBranch = try await repo.run(["rev-parse", "--abbrev-ref", "HEAD"], in: trunkPath).stdout
            .trimmingCharacters(in: .whitespacesAndNewlines)
        #expect(onBranch == trunkBranch)

        // Idempotent: a second ensure is a no-op and doesn't throw.
        try await trunk.ensureTrunk(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
        #expect(try await repo.revParse("refs/heads/\(trunkBranch)") == trunkHead)
    }

    @Test func landsEditAsScopedCommitWithTrailer() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let session = SessionID.generate()

        // Agent "writes" a new file into the shared trunk dir; the host lands it.
        try repo.write("src/a.swift", "let a = 1\n", in: trunkPath)
        let before = try await repo.revParse("refs/heads/\(trunkBranch)")
        let result = await trunk.land(
            trunkPath: trunkPath, session: session, paths: ["src/a.swift"], message: "add a")

        guard case .landed = result else { Issue.record("expected .landed, got \(result)"); return }
        let after = try await repo.revParse("refs/heads/\(trunkBranch)")
        #expect(after != before)  // trunk advanced
        // The file is committed on the trunk branch...
        let names = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
        #expect(names.contains("src/a.swift"))
        // ...with the session attribution trailer (NVRSION §3).
        let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
        #expect(body.contains("Nucleic-Session: \(session.rawValue)"))
        // ...and Nucleic recorded as co-author (author/committer stay the human's git identity).
        #expect(body.contains(NvrsionTrunk.coauthorTrailer))
    }

    @Test func landIsPathScopedAndDoesNotSweepOtherSessionsFiles() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()
        let b = SessionID.generate()

        // Both sessions have in-flight writes in the one shared trunk dir at once.
        try repo.write("a.txt", "from A\n", in: trunkPath)
        try repo.write("b.txt", "from B\n", in: trunkPath)

        // A lands only a.txt — the commit must contain a.txt and NOT b.txt (b stays uncommitted).
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A") else {
            Issue.record("A land failed"); return
        }
        let firstNames = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
        #expect(firstNames.contains("a.txt"))
        #expect(!firstNames.contains("b.txt"))

        // B then lands b.txt as its own commit.
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: b, paths: ["b.txt"], message: "B") else {
            Issue.record("B land failed"); return
        }
        let secondNames = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
        #expect(secondNames.contains("b.txt"))
        #expect(!secondNames.contains("a.txt"))
        // Both files now live in the trunk tree.
        #expect(repo.read("a.txt", in: trunkPath) == "from A\n")
        #expect(repo.read("b.txt", in: trunkPath) == "from B\n")
    }

    @Test func landIsNoopWhenContentUnchanged() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let session = SessionID.generate()

        try repo.write("c.txt", "v1\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: session, paths: ["c.txt"], message: "c") else {
            Issue.record("first land failed"); return
        }
        // Landing again with identical content commits nothing.
        let again = await trunk.land(
            trunkPath: trunkPath, session: session, paths: ["c.txt"], message: "c again")
        #expect(again == .noop)
    }

    @Test func prelandHookGatesTheCommit() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let s = SessionID.generate()

        // A failing hook rejects the edit: nothing is committed and the file stays on disk.
        try repo.write("gated.txt", "v1\n", in: trunkPath)
        let before = try await repo.revParse("refs/heads/\(trunkBranch)")
        let rejected = await trunk.land(
            trunkPath: trunkPath, session: s, paths: ["gated.txt"], message: "g", prelandHook: "exit 1")
        guard case .rejected = rejected else { Issue.record("expected .rejected, got \(rejected)"); return }
        #expect(try await repo.revParse("refs/heads/\(trunkBranch)") == before)  // trunk unchanged
        let dirty = try await repo.run(["status", "--porcelain"], in: trunkPath).stdout
        #expect(dirty.contains("gated.txt"))  // the agent's work is still on disk, not lost

        // A passing hook lets the same edit land.
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: s, paths: ["gated.txt"], message: "g", prelandHook: "exit 0") else {
            Issue.record("expected .landed after passing hook"); return
        }
        #expect(try await repo.revParse("refs/heads/\(trunkBranch)") != before)
    }

    @Test func prelandHookReceivesEditedPaths() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let s = SessionID.generate()
        try repo.write("p.txt", "x\n", in: trunkPath)
        // The hook passes only when NUCLEIC_NVR_PATHS carries the edited path.
        let result = await trunk.land(
            trunkPath: trunkPath, session: s, paths: ["p.txt"], message: "p",
            prelandHook: #"[ "$NUCLEIC_NVR_PATHS" = "p.txt" ]"#)
        guard case .landed = result else { Issue.record("expected .landed, got \(result)"); return }
    }

    @Test func promoteSquashesTrunkIntoBase() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }

        // Two sessions land work on the trunk.
        try repo.write("a.txt", "from A\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["a.txt"], message: "A")
        try repo.write("b.txt", "from B\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["b.txt"], message: "B")

        let baseBefore = try await repo.revParse("refs/heads/main")
        let result = await trunk.promote(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            message: "promote")
        guard case .promoted = result else { Issue.record("expected .promoted, got \(result)"); return }

        // The real branch (main, checked out at root) advanced and now carries both files.
        #expect(try await repo.revParse("refs/heads/main") != baseBefore)
        #expect(repo.read("a.txt", in: repo.root) == "from A\n")
        #expect(repo.read("b.txt", in: repo.root) == "from B\n")
        let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
        #expect(body.contains("Nucleic-Promote: 1"))
        #expect(body.contains(NvrsionTrunk.coauthorTrailer))

        // Re-promoting with no new trunk work is a no-op (trunk was resynced onto the new base).
        #expect(await trunk.promote(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            message: "promote again") == .nothingToPromote)
    }

    @Test func promoteSessionShipsOneChatAndLeavesSiblingsOnTrunk() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()
        let b = SessionID.generate()

        // Two chats land interleaved work on DISJOINT files (the common case): A edits a.txt twice,
        // B edits b.txt twice, in alternation — exactly the shared-trunk history per-session promote
        // must untangle by attribution trailer.
        try repo.write("a.txt", "a1\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A1")
        try repo.write("b.txt", "b1\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: b, paths: ["b.txt"], message: "B1")
        try repo.write("a.txt", "a1\na2\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A2")
        try repo.write("b.txt", "b1\nb2\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: b, paths: ["b.txt"], message: "B2")

        let baseBefore = try await repo.revParse("refs/heads/main")
        // Promote ONLY chat A — B is still "working".
        let result = await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: a, message: "promote A")
        guard case .promoted = result else { Issue.record("expected .promoted, got \(result)"); return }

        // Base advanced and carries A's *cumulative* work (its latest trunk content) and NOT B's.
        #expect(try await repo.revParse("refs/heads/main") != baseBefore)
        #expect(repo.read("a.txt", in: repo.root) == "a1\na2\n")
        #expect(repo.read("b.txt", in: repo.root) == nil)  // B's file never reached base
        // One commit, attributed to A and tagged as a promote (with the co-author trailer).
        let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
        #expect(body.contains("Nucleic-Promote: 1"))
        #expect(body.contains("Nucleic-Session: \(a.rawValue)"))
        #expect(body.contains(NvrsionTrunk.coauthorTrailer))

        // B's work is untouched — still on the trunk, ready to ship later.
        #expect(repo.read("b.txt", in: trunkPath) == "b1\nb2\n")
        // Re-promoting A is now a no-op (its files already match base), so it can't double-ship.
        #expect(await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: a, message: "promote A again") == .nothingToPromote)
        // And B can still be promoted independently afterwards.
        guard case .promoted = await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: b, message: "promote B") else {
            Issue.record("expected B to promote"); return
        }
        #expect(repo.read("b.txt", in: repo.root) == "b1\nb2\n")
    }

    @Test func promoteSessionShipsAFileTheChatMostRecentlyAuthored() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()
        let b = SessionID.generate()

        // B edits shared.txt (a completed, lock-released edit), then A edits the SAME file on top of
        // it — so A is the file's latest author. Promoting A ships the file *whole* (B's already-landed
        // line included): under the lock invariant every earlier edit to it was complete before A took
        // it, so there's no half-written content — and there's no coherent way to ship A's line without
        // the lines it was written against. This is the nvrsion-native behavior, NOT a conflict.
        try repo.write("shared.txt", "L1\nfrom B\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: b, paths: ["shared.txt"], message: "B")
        try repo.write("shared.txt", "L1\nfrom B\nfrom A\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: a, paths: ["shared.txt"], message: "A")

        guard case .promoted = await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: a, message: "promote A") else {
            Issue.record("expected A (latest author) to promote shared.txt"); return
        }
        #expect(repo.read("shared.txt", in: repo.root) == "L1\nfrom B\nfrom A\n")
        // The file is now shipped, so B — which A superseded as latest author — has nothing left of
        // its own to promote (its contribution went out as part of the file's current content).
        #expect(await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: b, message: "promote B") == .nothingToPromote)
    }

    @Test func promoteSessionDeclinesWhenBaseDivergedOutsideTheTrunk() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()

        // A lands an edit to f.txt on the trunk.
        try repo.write("f.txt", "L1\nfrom A\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: a, paths: ["f.txt"], message: "A")

        // Meanwhile the real branch grows a *different* f.txt — a hand commit made outside the trunk.
        // Overwriting it with the trunk's content would silently clobber that edit, so per-session
        // promote must decline, exactly like the whole-trunk promote's conflict guard.
        try repo.write("f.txt", "L1\nhand edit on base\n")
        try await repo.run(["add", "f.txt"])
        try await repo.run(["-c", "commit.gpgsign=false", "commit", "-m", "hand edit"])
        let baseBefore = try await repo.revParse("refs/heads/main")

        let result = await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: a, message: "promote A")
        guard case .conflicted(let files) = result else {
            Issue.record("expected .conflicted, got \(result)"); return
        }
        #expect(files == ["f.txt"])
        // Base is left exactly as it was (the hand edit survives) and nothing is staged or dirty.
        #expect(try await repo.revParse("refs/heads/main") == baseBefore)
        #expect(repo.read("f.txt", in: repo.root) == "L1\nhand edit on base\n")
        let dirty = try await repo.run(["status", "--porcelain", "--untracked-files=no"]).stdout
            .trimmingCharacters(in: .whitespacesAndNewlines)
        #expect(dirty.isEmpty)
    }

    @Test func promoteSessionIsNothingWhenChatHasNoTrunkWork() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        // A chat with no landed commits has nothing to promote — even when a *sibling* has work.
        let lander = SessionID.generate()
        let idle = SessionID.generate()
        try repo.write("a.txt", "from lander\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: lander, paths: ["a.txt"], message: "L")
        #expect(await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: idle, message: "promote idle") == .nothingToPromote)
        // The sibling's work is left fully intact on the trunk.
        #expect(repo.read("a.txt", in: trunkPath) == "from lander\n")
    }

    @Test func promoteIsNothingWhenTrunkMatchesBase() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        // Fresh trunk == base → nothing to promote.
        #expect(await trunk.promote(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            message: "promote") == .nothingToPromote)
    }

    @Test func changeSummaryRoundTripsAndClears() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }

        // Empty before anything is recorded.
        #expect(await trunk.changeSummary(trunkPath: trunkPath) == "")
        // Recorded text round-trips (trimmed).
        await trunk.recordChangeSummary(trunkPath: trunkPath, "  Add login button; fix token refresh\n")
        #expect(await trunk.changeSummary(trunkPath: trunkPath) == "Add login button; fix token refresh")
        // An empty/blank record clears it (deletes the sidecar).
        await trunk.recordChangeSummary(trunkPath: trunkPath, "   ")
        #expect(await trunk.changeSummary(trunkPath: trunkPath) == "")
    }

    @Test func promoteCommitsTheSummaryMessageAndClearsIt() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }

        // A running summary accumulated as work landed, plus the work itself on the trunk.
        await trunk.recordChangeSummary(trunkPath: trunkPath, "Add a; tidy b")
        try repo.write("a.txt", "from A\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["a.txt"], message: "A")

        // Promoting uses the caller-supplied (summary-derived) message verbatim as the commit subject…
        guard case .promoted = await trunk.promote(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            message: "nvrsion: Add a; tidy b") else {
            Issue.record("promote failed"); return
        }
        let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
        #expect(body.contains("nvrsion: Add a; tidy b"))
        // …and a successful promotion consumes the running summary, so the next cycle starts fresh.
        #expect(await trunk.changeSummary(trunkPath: trunkPath) == "")
    }

    @Test func hasPendingWorkTracksTheTrunkAgainstBase() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }

        // Fresh trunk == base → nothing to integrate (the sidebar shows the checkmark).
        #expect(await trunk.hasPendingWork(root: repo.root, trunkBranch: trunkBranch, base: "main") == false)

        // A landed edit puts work on the trunk → there's something to integrate (the up-arrow).
        try repo.write("a.txt", "from A\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: .generate(), paths: ["a.txt"], message: "A") else {
            Issue.record("land failed"); return
        }
        #expect(await trunk.hasPendingWork(root: repo.root, trunkBranch: trunkBranch, base: "main") == true)

        // Promoting squashes it into base and resyncs the trunk → back to nothing pending.
        guard case .promoted = await trunk.promote(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            message: "promote") else {
            Issue.record("promote failed"); return
        }
        #expect(await trunk.hasPendingWork(root: repo.root, trunkBranch: trunkBranch, base: "main") == false)
    }

    @Test func hasPendingWorkIsFalseWhenTrunkBranchAbsent() async throws {
        // A project whose trunk branch was never created (no session yet) has nothing to integrate —
        // the check must report false rather than treating the bad revision as a diff.
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let trunk = NvrsionTrunk()
        #expect(await trunk.hasPendingWork(
            root: repo.root, trunkBranch: "nucleic/trunk", base: "main") == false)
    }

    @Test func sessionDiffStatCountsOnlyThisChatsUnshippedFiles() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()
        let b = SessionID.generate()

        // A and B land disjoint work on the shared trunk: A adds a.txt (2 lines), B adds b.txt (3).
        try repo.write("a.txt", "a1\na2\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A")
        try repo.write("b.txt", "b1\nb2\nb3\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: b, paths: ["b.txt"], message: "B")

        // Each chat's badge reflects only the files it is the latest author of — never the sibling's.
        let aStat = await trunk.sessionDiffStat(
            root: repo.root, trunkBranch: trunkBranch, base: "main", session: a)
        #expect(aStat == DiffStat(filesChanged: 1, added: 2, removed: 0))
        let bStat = await trunk.sessionDiffStat(
            root: repo.root, trunkBranch: trunkBranch, base: "main", session: b)
        #expect(bStat == DiffStat(filesChanged: 1, added: 3, removed: 0))
    }

    @Test func sessionDiffStatIsEmptyWithNoWorkOrAfterShipping() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()
        let idle = SessionID.generate()

        // No trunk branch yet ⇒ nothing for any chat (mirrors hasPendingWork's absent-branch guard).
        let bare = try await GitTestRepo()
        defer { bare.cleanup() }
        #expect(await NvrsionTrunk().sessionDiffStat(
            root: bare.root, trunkBranch: trunkBranch, base: "main", session: a) == .empty)

        // A has landed work; an idle sibling that authored nothing shows an empty badge.
        try repo.write("a.txt", "a1\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A")
        #expect(await trunk.sessionDiffStat(
            root: repo.root, trunkBranch: trunkBranch, base: "main", session: idle) == .empty)

        // Once A's work ships to base, A's badge drains to empty too (the files now match base).
        guard case .promoted = await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: a, message: "promote A") else {
            Issue.record("promote A failed"); return
        }
        #expect(await trunk.sessionDiffStat(
            root: repo.root, trunkBranch: trunkBranch, base: "main", session: a) == .empty)
    }

    @Test func recoverCommitsDirtyTrunkResidueOnLaunch() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }

        // Simulate a crash mid-edit: a file written into the trunk but never committed.
        try repo.write("half.swift", "// written, never landed\n", in: trunkPath)
        let before = try await repo.revParse("refs/heads/\(trunkBranch)")

        await trunk.recover(root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")

        // The residue is now committed (a recovery commit), so the trunk is clean.
        let status = try await repo.run(["status", "--porcelain"], in: trunkPath).stdout
            .trimmingCharacters(in: .whitespacesAndNewlines)
        #expect(status.isEmpty)
        let after = try await repo.revParse("refs/heads/\(trunkBranch)")
        #expect(after != before)
        let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
        #expect(body.contains("recovered uncommitted work"))
        #expect(body.contains("Nucleic-Recovery: 1"))
        #expect(body.contains(NvrsionTrunk.coauthorTrailer))

        // Idempotent: a second recovery on a clean trunk commits nothing.
        await trunk.recover(root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
        #expect(try await repo.revParse("refs/heads/\(trunkBranch)") == after)
    }

    @Test func promoteShipsEvenWhenPrimaryCheckoutIsParkedOffBase() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }

        // Land work on the trunk.
        try repo.write("a.txt", "from A\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["a.txt"], message: "A")

        // Park the PRIMARY checkout on a *different* branch — the situation that used to fail promotion
        // outright ("root checkout is on feature, not main"). Promotion must no longer depend on it.
        try await repo.run(["checkout", "-b", "feature"])

        let baseBefore = try await repo.revParse("refs/heads/main")
        let result = await trunk.promote(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            message: "promote")
        guard case .promoted = result else { Issue.record("expected .promoted, got \(result)"); return }

        // main advanced and carries the work, even though no checkout was parked on it.
        #expect(try await repo.revParse("refs/heads/main") != baseBefore)
        #expect(try await repo.run(["show", "main:a.txt"]).stdout == "from A\n")
        let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
        #expect(body.contains("Nucleic-Promote: 1"))
        #expect(body.contains(NvrsionTrunk.coauthorTrailer))

        // The primary checkout is untouched — still on `feature` — and `main` is NOT held by the
        // dedicated promote worktree (it's detached), so the user can freely check `main` out in the
        // primary. This is the property the whole fix turns on.
        #expect(try await repo.run(["rev-parse", "--abbrev-ref", "HEAD"]).stdout
            .trimmingCharacters(in: .whitespacesAndNewlines) == "feature")
        #expect(try await repo.run(["checkout", "main"]).status == 0)
        #expect(repo.read("a.txt", in: repo.root) == "from A\n")

        // Re-promoting with no new trunk work is a no-op (the trunk was resynced onto the new base),
        // exercising the reuse path of the detached promote worktree.
        try await repo.run(["checkout", "feature"])  // park off base again
        #expect(await trunk.promote(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            message: "promote again") == .nothingToPromote)
    }

    @Test func promoteSessionShipsEvenWhenPrimaryCheckoutIsParkedOffBase() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()
        let b = SessionID.generate()

        // Two chats land disjoint work on the shared trunk.
        try repo.write("a.txt", "a1\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A1")
        try repo.write("b.txt", "b1\n", in: trunkPath)
        _ = await trunk.land(trunkPath: trunkPath, session: b, paths: ["b.txt"], message: "B1")

        // Park the primary checkout off base — per-session promote must work regardless.
        try await repo.run(["checkout", "-b", "feature"])

        let baseBefore = try await repo.revParse("refs/heads/main")
        let result = await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: a, message: "promote A")
        guard case .promoted = result else { Issue.record("expected .promoted, got \(result)"); return }

        // main carries A's file and NOT B's, and the commit is attributed to A.
        #expect(try await repo.revParse("refs/heads/main") != baseBefore)
        #expect(try await repo.run(["show", "main:a.txt"]).stdout == "a1\n")
        #expect((try? await repo.run(["cat-file", "-e", "main:b.txt"]))?.ok != true)  // B never shipped
        let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
        #expect(body.contains("Nucleic-Session: \(a.rawValue)"))
        #expect(body.contains("Nucleic-Promote: 1"))

        // Primary still on `feature`, untouched; B's work still on the trunk.
        #expect(try await repo.run(["rev-parse", "--abbrev-ref", "HEAD"]).stdout
            .trimmingCharacters(in: .whitespacesAndNewlines) == "feature")
        #expect(repo.read("b.txt", in: trunkPath) == "b1\n")

        // Re-promoting A is a no-op (idempotent); B still ships independently afterwards.
        #expect(await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: a, message: "promote A again") == .nothingToPromote)
        guard case .promoted = await trunk.promoteSession(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            session: b, message: "promote B") else {
            Issue.record("expected B to promote"); return
        }
        #expect(try await repo.run(["show", "main:b.txt"]).stdout == "b1\n")
    }

    @Test func regroundFiresOnlyAfterAFileMovesUnderASession() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()
        let b = SessionID.generate()

        // A lands x.txt.
        try repo.write("x.txt", "one\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: a, paths: ["x.txt"], message: "x1") else {
            Issue.record("A land failed"); return
        }

        // B's FIRST acquaintance with x.txt is clean (matches LOCKING §4.5: re-ground only when a
        // file the session already had advances). It now records B has seen the current content.
        #expect(await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"]) == .clean)

        // A changes x.txt under B.
        try repo.write("x.txt", "two\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: a, paths: ["x.txt"], message: "x2") else {
            Issue.record("A reland failed"); return
        }

        // Now B is granted x.txt again → it moved since B last saw it → re-ground.
        let moved = await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"])
        #expect(moved == .changed(files: ["x.txt"], diff: ""))
        // And once B has re-read (state updated), a further grant with no movement is clean again.
        #expect(await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"]) == .clean)
    }

    // MARK: Trunk never left mid-merge (the "cannot do a partial commit during a merge" wedge)

    /// The reported failure: a trunk left with a live `MERGE_HEAD` (an interrupted/cancelled resync)
    /// breaks every later per-edit `land`, because a land is a path-scoped (partial) commit and git
    /// refuses those during a merge. `recover` must abort that stuck merge on launch — NOT commit it,
    /// which would bake conflict markers into trunk history — so the trunk is landable again with no
    /// committed work lost.
    @Test func recoverClearsAStuckMergeSoTheTrunkIsLandableAgain() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()

        // Land real trunk work: a.txt = "base\n".
        try repo.write("a.txt", "base\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "a1") else {
            Issue.record("seed land failed"); return
        }
        let tr1 = try await repo.revParse("refs/heads/\(trunkBranch)")

        // A side branch off TR1 that edits a.txt differently — the device that forces a real merge
        // conflict (its history is discarded by the abort; it is never "landed" trunk work).
        let sidePath = (repo.container as NSString).appendingPathComponent("side")
        try await repo.run(["worktree", "add", "-b", "side", sidePath, tr1], in: repo.root)
        try repo.write("a.txt", "side\n", in: sidePath)
        try await repo.run(["add", "-A"], in: sidePath)
        try await repo.run(["-c", "commit.gpgsign=false", "commit", "-m", "side"], in: sidePath)

        // Advance the trunk past TR1 with a CONFLICTING change, then merge `side` to wedge the trunk
        // into a conflicted merge — exactly the on-disk state the production symptom comes from.
        try repo.write("a.txt", "trunk\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "a2") else {
            Issue.record("second land failed"); return
        }
        let tr2 = try await repo.revParse("refs/heads/\(trunkBranch)")
        #expect(try await repo.run(["merge", "--no-edit", "side"], in: trunkPath).ok == false)
        #expect(try await repo.run(["rev-parse", "-q", "--verify", "MERGE_HEAD"], in: trunkPath).ok)

        // Reproduce the exact reported symptom: a path-scoped land can't commit during the merge.
        let wedged = await trunk.land(trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "a3")
        guard case .failed(let why) = wedged else {
            Issue.record("expected .failed while wedged, got \(wedged)"); return
        }
        #expect(why.contains("partial commit"))  // "cannot do a partial commit during a merge"

        // Recovery aborts the stuck merge rather than committing its markers.
        await trunk.recover(root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")

        // The merge is gone; HEAD is unchanged at the real trunk tip (no markers committed); and the
        // landed work survives intact on disk and in the tree.
        #expect(try await repo.run(["rev-parse", "-q", "--verify", "MERGE_HEAD"], in: trunkPath).ok == false)
        #expect(try await repo.revParse("refs/heads/\(trunkBranch)") == tr2)
        #expect(repo.read("a.txt", in: trunkPath) == "trunk\n")
        let status = try await repo.run(["status", "--porcelain"], in: trunkPath).stdout
            .trimmingCharacters(in: .whitespacesAndNewlines)
        #expect(status.isEmpty)

        // And the trunk lands again — the wedge is cleared.
        try repo.write("c.txt", "after\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: a, paths: ["c.txt"], message: "c") else {
            Issue.record("post-recover land failed"); return
        }
    }

    /// The whole-trunk promote's post-squash resync folds base-only edits back into the trunk (so the
    /// next promote squashes only NEW work). The fix keeps that clean-merge propagation byte-for-byte
    /// and only guards the failure path — it never wedges the trunk, so a land always follows. (A
    /// disjoint base edit merges cleanly here; an overlapping one would trip the squash first and
    /// promote would return `.conflicted` without ever touching the trunk — also non-wedging.)
    @Test func promoteResyncPropagatesBaseEditsAndNeverWedgesTheTrunk() async throws {
        let (repo, trunk, trunkPath) = try await makeTrunk()
        defer { repo.cleanup() }
        let a = SessionID.generate()

        // Trunk lands work on f.txt.
        try repo.write("f.txt", "f-from-trunk\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: a, paths: ["f.txt"], message: "f") else {
            Issue.record("land failed"); return
        }
        // Base grows a DISJOINT hand commit (g.txt) outside the trunk — the realistic divergence the
        // post-promote resync folds back into the trunk.
        try repo.write("g.txt", "g-on-base\n")
        try await repo.run(["add", "g.txt"])
        try await repo.run(["-c", "commit.gpgsign=false", "commit", "-m", "hand g on base"])

        guard case .promoted = await trunk.promote(
            root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
            message: "promote") else {
            Issue.record("promote failed"); return
        }
        // Base carries BOTH the hand edit and the trunk's work.
        #expect(repo.read("f.txt", in: repo.root) == "f-from-trunk\n")
        #expect(repo.read("g.txt", in: repo.root) == "g-on-base\n")
        // The resync folded the base-only edit back into the trunk — the propagation we preserve by
        // aborting only on conflict instead of switching the resync to `-s ours`.
        #expect(repo.read("g.txt", in: trunkPath) == "g-on-base\n")
        // …and left the trunk clean: no stuck merge.
        #expect(try await repo.run(["rev-parse", "-q", "--verify", "MERGE_HEAD"], in: trunkPath).ok == false)

        // So a subsequent edit still lands — the trunk is never wedged by promotion.
        try repo.write("h.txt", "after\n", in: trunkPath)
        guard case .landed = await trunk.land(
            trunkPath: trunkPath, session: a, paths: ["h.txt"], message: "h") else {
            Issue.record("post-promote land failed"); return
        }
    }
}
