import Foundation
import SwiftGitX
import Testing
@testable import Kanban

/// **The auto-commit engine** (06-history-undo.md ▸ Rules ▸ Auto-commit; ▸ Interaction with external
/// writers) — the debounce, the stage-around, the two-commit split, the abnormal-state hold, and the
/// contention posture.
///
/// Every repository here is a **real** one, built through the app's own add-git over bundled
/// libgit2, and every commit is read back through libgit2 rather than through the engine that made
/// it. Nothing shells out to `git`: there is no `/usr/bin/git` in the promise this feature makes, so
/// there is none in its tests either (`HistoryStoreTests`' rule, kept).

// MARK: - Fixtures

private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, plain(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First"))
    return fixture
}

/// A board with a repository and a root commit already in it — the state every board is in a
/// moment after add-git, and the state the engine actually runs in.
///
/// The ledger is the test's own and is handed to `compose`, exactly as `AppModel.beginSession` hands
/// it the session store's: a test that wants to say "the app wrote this" drops a receipt into it the
/// way a `performWrite` bracket would, rather than reaching into private state.
@MainActor
private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) {
    let fixture = try makeBoard()
    let ledger = EchoLedger()
    let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: ledger))
    #expect(await git.addGit())
    return (fixture, git, ledger)
}

/// The engine, dialled down to milliseconds — `CardBodyEditSession.debounceInterval`'s precedent: a
/// production default on the property, and the suite spending none of it.
@MainActor
private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter {
    let committer = try #require(git.committer)
    committer.debounceInterval = .milliseconds(20)
    committer.lockRetryDelay = .milliseconds(5)
    committer.holdRecheckInterval = .milliseconds(20)
    return committer
}

// MARK: Reading commits back

private struct CommitRecord: Equatable {
    let subject: String
    let authorName: String
    let authorEmail: String
    let committerName: String
}

/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the committer.
private func history(at boardRoot: URL, limit: Int = 32) throws -> [CommitRecord] {
    let repository = try Repository.open(at: boardRoot)
    guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }

    var records: [CommitRecord] = []
    var current: Commit? = tip
    while let commit = current, records.count < limit {
        records.append(CommitRecord(
            subject: commit.summary,
            authorName: commit.author.name,
            authorEmail: commit.author.email,
            committerName: commit.committer.name
        ))
        current = (try? commit.parents)?.first
    }
    return records
}

private func headSubject(at boardRoot: URL) throws -> String? {
    try history(at: boardRoot).first?.subject
}

/// Whether the working tree has anything uncommitted — the clean-tree claim, asked of git.
private func isClean(at boardRoot: URL) -> Bool {
    GitCommitOperation.changedPaths(at: boardRoot).isEmpty
}

// MARK: - The debounce

@MainActor
@Suite("Auto-commit ▸ the debounce")
struct AutoCommitDebounceTests {

    @Test("A settled change commits, and the tree comes back clean")
    func aSettledChangeCommits() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(committer.commitCount == 1)
        #expect(isClean(at: fixture.root), "a flush leaves nothing dirty — branch switch depends on it")
        // The message is the semantic composer's, end to end — no placeholder anywhere in the path.
        #expect(try headSubject(at: fixture.root) == "Add card 'Second'")
    }

    @Test("A burst of changes inside one window is one commit, not one per change")
    func aBurstIsOneCommit() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // The cadence constraint (06 ▸ Rules): "one commit per drag is fine for a local undo trail
        // but noisy as a shared log". Five signals, one quiet moment, one commit.
        for index in 2...6 {
            try fixture.item("\(Ident.lane1)/card-\(index)", plain(order: "\(index * 1024)", title: "Card \(index)"))
            committer.noteReloadLanded(sawForeignChange: false)
        }
        await committer.flushNow()

        #expect(committer.commitCount == 1)
        #expect(try history(at: fixture.root).count == 2, "the root commit and one more")
    }

    @Test("The debounce fires on its own, without anyone asking for a flush")
    func theDebounceFiresOnItsOwn() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)

        try await waitUntil { committer.commitCount == 1 }
        #expect(isClean(at: fixture.root))
    }

    @Test("A clean tree is the happy path — the debounce fires and silently does nothing")
    func aCleanTreeNoOps() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // "When the debounce fires and the tree has nothing to commit — the agent already committed
        // its own work — the auto-committer no-ops silently" (06 ▸ Interaction with external
        // writers). No commit, no failure, no banner.
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(committer.commitCount == 0)
        #expect(committer.lastFailure == nil)
        #expect(try history(at: fixture.root).count == 1)
    }

    @Test("A stray-only window commits — the condition is the tree, not the snapshot")
    func aStrayOnlyWindowCommits() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // 06 ▸ Commit messages ▸ Non-snapshot files commit too: "a permanently dirty stray would
        // break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for
        // every file the model can't see".
        try fixture.file("NOTES.txt", Data("scratch\n".utf8))
        try fixture.file("CLAUDE.md", Data("# Agent guide\n".utf8))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(committer.commitCount == 1)
        #expect(isClean(at: fixture.root))
        #expect(GitRepository.trackedPaths(at: fixture.root).contains("NOTES.txt"))
    }

    @Test("A launch catch-up commits what was found pending at open")
    func launchCatchUpCommits() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }

        // The blind window: changes made while the app was not running, so nothing vouches for them.
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Landed while away"))

        let committer = try quickCommitter(git)
        committer.start()
        try await waitUntil { committer.commitCount == 1 }

        #expect(isClean(at: fixture.root))
        // "the app never vouches for changes it didn't witness" — the launch-catch-up doctrine.
        #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail)
    }
}

// MARK: - Attribution

@MainActor
@Suite("Auto-commit ▸ attribution and the split")
struct AutoCommitAttributionTests {

    @Test("An app-mediated change is authored by the user")
    func appMediatedIsTheUser() async throws {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        let card = fixture.url("\(Ident.lane1)/\(Ident.card1)")
        let text = plain(order: "1024", title: "Renamed by the user")
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", text)
        ledger.recordWrite(at: card.appendingPathComponent(BoardLoader.indexFileName), text: text)

        committer.noteWriteBracketClosed()
        await committer.flushNow()

        let identity = GitCommitOperation.userIdentity(at: fixture.root)
        let head = try #require(try history(at: fixture.root).first)
        #expect(head.authorEmail == identity.email)
        #expect(head.authorEmail != CommitAttribution.externalAuthorEmail)
    }

    @Test("A foreign change is authored by the pinned synthetic identity")
    func foreignIsLaneworkExternal() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // Nobody vouched for this: no receipt, so the ledger cannot speak for it.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Edited by an agent"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        let head = try #require(try history(at: fixture.root).first)
        // The strings are API (06): they change with the deliberateness of a schema change.
        #expect(head.authorName == "Lanework External")
        #expect(head.authorEmail == "external@lanework.invalid")
    }

    @Test("The committer is always this machine's user, even on a foreign commit")
    func theCommitterIsAlwaysTheUser() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Foreign"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        let head = try #require(try history(at: fixture.root).first)
        #expect(head.committerName == GitCommitOperation.userIdentity(at: fixture.root).name)
    }

    @Test("A window where every changed file carries one modified-by authors as that agent")
    func modifiedByRefinesAttribution() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", stamped("First", by: "claude"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", stamped("Second", by: "claude"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        let head = try #require(try history(at: fixture.root).first)
        // "display name verbatim, local part slugified; the domain marks self-reported identity" (06).
        #expect(head.authorName == "claude")
        #expect(head.authorEmail == "claude@agents.lanework.invalid")
    }

    @Test("Disagreeing stamps fall back to Lanework External")
    func disagreementFallsBack() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", stamped("First", by: "claude"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", stamped("Second", by: "codex"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail)
    }

    @Test("One unstamped changed file demotes the whole window")
    func anUnstampedFileDemotes() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", stamped("First", by: "claude"))
        // A stray has no frontmatter to stamp, so it is an unstamped changed file.
        try fixture.file("scratch.txt", Data("notes\n".utf8))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail)
    }

    @Test("A true deletion demotes the window — a deletion leaves no file to stamp")
    func aTrueDeletionDemotes() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", stamped("Second", by: "claude"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", stamped("First", by: "claude"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail)
    }

    @Test("A stamped agent move attributes by its stamp, not by its departure")
    func aStampedMoveKeepsItsAttribution() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item(Ident.lane2, stamped("Doing", by: "claude"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        // What a well-behaved agent does: move the folder *and* re-stamp it (08-agent-integration.md
        // teaches exactly this, because "a bare `mv` rewrites nothing … and demotes the window under
        // the unstamped-file rule").
        try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
        try fixture.item("\(Ident.lane2)/\(Ident.card1)", stamped("First", by: "claude"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        // "**A folder move is not a deletion**" — the departure must not demote the window, which it
        // only cannot do if libgit2's rename detection actually pairs the two ends.
        let head = try #require(try history(at: fixture.root).first)
        #expect(head.authorEmail == "claude@agents.lanework.invalid")
        #expect(isClean(at: fixture.root))
    }

    @Test("A bare mv with no re-stamp demotes, exactly as the guide warns")
    func aBareMoveDemotes() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item(Ident.lane2, stamped("Doing", by: "claude"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        // The card's `index.md` still carries whatever the app last wrote — no stamp.
        try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail)
    }

    @Test("A window holding both kinds splits into two commits, foreign first")
    func aMixedWindowSplits() async throws {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // The app's own write, vouched for by a receipt.
        let mine = "\(Ident.lane1)/\(Ident.card1)"
        let text = plain(order: "1024", title: "Mine")
        try fixture.item(mine, text)
        ledger.recordWrite(at: fixture.url(mine).appendingPathComponent(BoardLoader.indexFileName), text: text)
        committer.noteWriteBracketClosed()

        // Somebody else's, in the same window.
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Theirs"))
        committer.noteReloadLanded(sawForeignChange: true)

        await committer.flushNow()

        #expect(committer.commitCount == 2, "never mixed — a window containing both kinds is two commits")
        let log = try history(at: fixture.root)
        // Newest first, so the user's commit is on top and the foreign one is its parent: "foreign
        // first, then the user's overwrite" (06).
        #expect(log[0].authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email)
        #expect(log[1].authorEmail == CommitAttribution.externalAuthorEmail)
        #expect(isClean(at: fixture.root))
    }

    @Test("Heal-marked paths commit separately from everyone else's")
    func healPathsSplitOut() async throws {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // The integrity service's own write, heal-marked (ruled 2026-07-29): "a window holding a
        // scheduled heal's changes alongside anyone else's splits the heal-receipted paths into
        // their own commit".
        let healed = "\(Ident.lane1)/\(Ident.card1)"
        let healedText = plain(order: "1024", title: "Repaired")
        try fixture.item(healed, healedText)
        let healedFile = fixture.url(healed).appendingPathComponent(BoardLoader.indexFileName)
        ledger.recordWrite(at: healedFile, text: healedText)
        ledger.markHeal(at: healedFile)

        // An ordinary app write beside it.
        let ordinary = "\(Ident.lane1)/\(Ident.card2)"
        let ordinaryText = plain(order: "2048", title: "Ordinary")
        try fixture.item(ordinary, ordinaryText)
        ledger.recordWrite(
            at: fixture.url(ordinary).appendingPathComponent(BoardLoader.indexFileName),
            text: ordinaryText
        )
        committer.noteWriteBracketClosed()

        await committer.flushNow()

        #expect(committer.commitCount == 2, "the heal's paths commit separately — the split's third class")
        #expect(isClean(at: fixture.root))

        // **And it is authored by the third pinned synthetic** (06 ▸ Commit messages ▸ Healing
        // mutations commit separately, ruled 2026-07-31): "a heal is a third origin — not the user's
        // gesture, not a foreign writer — and the separation exists for audit, so the trail filters by
        // author like every origin; the committer stays the user."
        let log = try history(at: fixture.root)
        let user = GitCommitOperation.userIdentity(at: fixture.root)
        #expect(log[0].authorEmail == user.email, "the user's own write stays the user's")
        #expect(log[1].authorName == CommitAttribution.integrityAuthorName)
        #expect(log[1].authorEmail == CommitAttribution.integrityAuthorEmail)
        #expect(log[1].committerName == user.name, "the committer is always the user")
    }

    @Test("The app's own delete is the user's, not an agent's")
    func anAppMediatedDeleteIsTheUsers() async throws {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // The receipt for a delete sits on the *folder*, and git reports the `index.md` inside it —
        // so only a walk up the folders can tell the user's own delete from an agent's `rm`.
        let folder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
        try FileManager.default.removeItem(at: folder)
        ledger.recordDeletion(at: folder)
        committer.noteWriteBracketClosed()

        await committer.flushNow()

        #expect(try history(at: fixture.root).first?.authorEmail
            == GitCommitOperation.userIdentity(at: fixture.root).email)
    }
}

// MARK: - The stage-around

@MainActor
@Suite("Auto-commit ▸ staging around open card windows")
struct AutoCommitStageAroundTests {

    @Test("A lane move mid-session commits the move without touching the session card's folder")
    func aLaneMoveSkipsTheSessionFolder() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
        committer.beginCardSession(UUID()) { sessionFolder }

        // The editor's ~700 ms save lands on disk, uncommitted…
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First", body: "half-typed"))
        // …and a board change lands beside it.
        try fixture.item("\(Ident.lane2)", plain(order: "2048", title: "Doing"))
        committer.noteReloadLanded(sawForeignChange: false)
        await committer.flushNow()

        #expect(committer.commitCount == 1)
        // The move is in history…
        #expect(GitRepository.trackedPaths(at: fixture.root)
            .contains("\(Ident.lane2)/\(BoardLoader.indexFileName)"))
        // …and the half-typed body is not: the tree is still dirty, by exactly one folder.
        let stillPending = GitCommitOperation.changedPaths(at: fixture.root).map(\.path)
        #expect(stillPending == ["\(Ident.lane1)/\(Ident.card1)/\(BoardLoader.indexFileName)"])
    }

    @Test("Whole-root staging widens what commits — it never overrides the exclusion")
    func straysInsideTheSessionFolderWait() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
        committer.beginCardSession(UUID()) { sessionFolder }

        try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/diagram.txt", Data("x\n".utf8))
        try fixture.file("elsewhere.txt", Data("y\n".utf8))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(GitRepository.trackedPaths(at: fixture.root).contains("elsewhere.txt"))
        #expect(!GitRepository.trackedPaths(at: fixture.root)
            .contains("\(Ident.lane1)/\(Ident.card1)/attachments/diagram.txt"))
    }

    @Test("Ending the session produces exactly one commit for it")
    func endingTheSessionCommitsOnce() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        let token = UUID()
        let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
        committer.beginCardSession(token) { sessionFolder }

        // Three debounced saves inside one session — each a real write, none of them a commit
        // ("the body editor's ~700 ms disk saves … stay uncommitted").
        for tick in 1...3 {
            try fixture.item("\(Ident.lane1)/\(Ident.card1)",
                             plain(order: "1024", title: "First", body: "draft \(tick)"))
            committer.noteWriteBracketClosed()
            await committer.flushNow()
        }
        #expect(committer.commitCount == 0, "no save tick may become a commit")

        // The window close — "window close flushes the session as one commit" (06 ▸ Rules
        // ▸ Auto-commit, widened 2026-07-31: the unit is the window, not the Edit→Preview flip).
        committer.endCardSession(token)
        try await waitUntil { committer.commitCount == 1 }

        #expect(committer.commitCount == 1, "exactly one commit per card-window session")
        #expect(isClean(at: fixture.root))
    }

    @Test("A card that moved mid-session is staged around at wherever it now is")
    func theExclusionFollowsTheCard() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // The registry holds a resolver, not a URL, so a lane move under an open session keeps the
        // right folder excluded rather than the one Edit was entered in.
        var lane = Ident.lane1
        committer.beginCardSession(UUID()) { fixture.url("\(lane)/\(Ident.card1)") }

        try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing"))
        try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
        lane = Ident.lane2

        try fixture.item("\(Ident.lane2)/\(Ident.card1)",
                         plain(order: "1024", title: "First", body: "still typing"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(!GitRepository.trackedPaths(at: fixture.root)
            .contains("\(Ident.lane2)/\(Ident.card1)/\(BoardLoader.indexFileName)"))
    }

    @Test("A window whose whole change set is staged around commits nothing at all")
    func anAllExcludedWindowIsANoOp() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        committer.beginCardSession(UUID()) { fixture.url("\(Ident.lane1)/\(Ident.card1)") }
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First", body: "typing"))
        committer.noteWriteBracketClosed()
        await committer.flushNow()

        #expect(committer.commitCount == 0)
        #expect(committer.lastFailure == nil, "an empty window is a no-op, never a failure")
    }

    /// **A close flush queues behind an in-flight flush rather than skipping it** (06 ▸ Rules
    /// ▸ Auto-commit: "nothing settled is ever left unsaved or uncommitted by closing").
    ///
    /// The interleaving is the close sequence's own, forced rather than waited for. `endCardSession`
    /// releases the stage-around **and arms a fresh debounce**, and `CloseFlushCoordinator` then
    /// spends its card-drain deadline before reaching `committerFlush` — two intervals that are both
    /// two seconds, so in practice the debounce fired into the drain's last moments about half the
    /// time. What made that a defect rather than a coin toss is what the debounced flush had already
    /// planned: a commit whose exclusion list still held the session's folder. Skipping behind it left
    /// the session uncommitted *permanently* — teardown stops the committer, and there is no later
    /// flush anywhere.
    ///
    /// So the flush in flight here is deliberately one that planned **with** the exclusion, and the
    /// release happens while it is still running. Before the fix this test's `flushNow()` returned
    /// having done nothing and the card's body stayed dirty forever.
    @Test("A flush asked for while one is in flight waits for it, and commits what it was asked to")
    func anExplicitFlushIsNeverDroppedBehindAnInFlightOne() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        // The one point inside a flush that is both off the main actor and injectable: composing.
        // It holds the flush open long enough for the close to arrive underneath it.
        committer.composer = SlowComposer(delay: 0.4)

        let token = UUID()
        committer.beginCardSession(token) { fixture.url("\(Ident.lane1)/\(Ident.card1)") }

        // The session's uncommitted work, held by the stage-around…
        try fixture.item("\(Ident.lane1)/\(Ident.card1)",
                         plain(order: "1024", title: "First", body: "typed and never committed"))
        // …and a board change beside it, so the debounced flush has something to compose slowly about
        // rather than answering `nothingToCommit` before it ever reaches the composer.
        try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing"))

        // Arm the debounce and let it fire: from here until the composer returns, a flush is in
        // flight, and it planned its commit while the session folder was still excluded.
        committer.noteReloadLanded(sawForeignChange: true)
        try await waitUntil { committer.isCommitInFlight }

        // The close sequence, arriving underneath it: the session ends, its folder is released, and
        // the coordinator asks for the flush that must not be lost.
        committer.endCardSession(token)
        await committer.flushNow()

        #expect(isClean(at: fixture.root),
                "the close flush waited its turn and committed the session it was asked to")
        #expect(GitRepository.trackedPaths(at: fixture.root)
            .contains("\(Ident.lane1)/\(Ident.card1)/\(BoardLoader.indexFileName)"))
    }
}

/// A composer that takes its time, so a test can hold a flush open and drive the close sequence into
/// the gap. Everything else about it is the real one — this suite asserts *when* a commit exists, and
/// a fake message would make the commits it reads back unrecognisable.
private struct SlowComposer: CommitMessageComposing {
    let delay: TimeInterval

    func message(for request: CommitMessageRequest) -> String {
        // Blocking, deliberately: this runs on the flush's own detached task, and what the test needs
        // held open is that task rather than the actor the close sequence is running on.
        Thread.sleep(forTimeInterval: delay)
        return CommitMessageEngine.message(for: request)
    }
}

// MARK: - Contention, holds, and failure

@MainActor
@Suite("Auto-commit ▸ contention and abnormal states")
struct AutoCommitContentionTests {

    @Test("A held index.lock never surfaces as a failure, and the change lands on the next debounce")
    func aHeldLockIsNeverAFailure() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        var reportedFailures = 0
        committer.reportFailure = { _ in reportedFailures += 1 }

        // An agent's commit in flight.
        let lock = fixture.root.appendingPathComponent(".git/index.lock")
        try Data().write(to: lock)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(committer.commitCount == 0)
        #expect(reportedFailures == 0, "a held lock is another writer doing its job — no banner")
        #expect(committer.lastFailure == nil)

        // The other writer finishes; the pending changes are still pending.
        try FileManager.default.removeItem(at: lock)
        try await waitUntil { committer.commitCount == 1 }
        #expect(isClean(at: fixture.root))
    }

    @Test("The lock never gets deleted, however long it is held")
    func theLockIsNeverRemoved() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // "a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption
        // is the app's own leftovers" — the pathfinder's stale-lock deletion is deliberately gone.
        let lock = fixture.root.appendingPathComponent(".git/index.lock")
        try Data().write(to: lock)
        try FileManager.default.setAttributes(
            [.modificationDate: Date(timeIntervalSinceNow: -60 * 60 * 24)],
            ofItemAtPath: lock.path
        )

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        await committer.flushNow()

        #expect(FileManager.default.fileExists(atPath: lock.path))
        try FileManager.default.removeItem(at: lock)
    }

    @Test("An in-progress merge holds the engine — and it resumes when the state clears")
    func anInProgressMergeHolds() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // The marker outside-the-app git leaves. `git_repository_state` reads exactly this file, so
        // the hold is the real one rather than a mocked one.
        let mergeHead = fixture.root.appendingPathComponent(".git/MERGE_HEAD")
        try Data("\(String(repeating: "0", count: 40))\n".utf8).write(to: mergeHead)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(committer.pause == .merge)
        #expect(committer.commitCount == 0)
        #expect(committer.lastFailure == nil, "a pause is not a failure")

        // "Edits keep landing on disk — files are the board — and commit as one settled batch when
        // the state clears."
        try fixture.item("\(Ident.lane1)/card-3", plain(order: "3072", title: "Third"))
        try FileManager.default.removeItem(at: mergeHead)
        await committer.flushNow()

        #expect(committer.pause == nil)
        #expect(committer.commitCount == 1, "one settled batch, not one commit per edit made while held")
        #expect(isClean(at: fixture.root))
    }

    @Test("A detached HEAD holds too, and says which state it is in")
    func aDetachedHeadHolds() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        let head = try #require(GitRepository.headCommit(at: fixture.root))
        try Data("\(head.oid)\n".utf8).write(to: fixture.root.appendingPathComponent(".git/HEAD"))

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        await committer.flushNow()

        #expect(committer.pause == .detachedHead)
        #expect(committer.pause?.explanation.contains("detached") == true)
        #expect(committer.commitCount == 0)
    }

    /// **The corrupt-`.git` loud failure's engine half** (06-history-undo.md ▸ Rules, ruled
    /// 2026-07-31): "the board itself loads and edits normally — files are the board — but the
    /// failure is loud … with the whole git surface paused", and "the banner clears when a later
    /// open or reload finds the repo readable".
    @Test("An unreadable repository holds the engine, and heals when it opens again")
    func anUnreadableRepositoryHolds() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        var transitions: [Bool] = []
        committer.reportRepositoryUnreadable = { transitions.append($0) }

        // What a half-copied or half-deleted `.git` looks like to libgit2: the layout no longer
        // validates, so the repository will not open at all. Reversible, which is what makes the
        // heal half of this test the real thing rather than a second fixture.
        let head = fixture.root.appendingPathComponent(".git/HEAD")
        let savedHead = try Data(contentsOf: head)
        try FileManager.default.removeItem(at: head)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(committer.pause == .unreadable)
        #expect(committer.commitCount == 0, "nothing is attempted against a repository the app can't open")
        #expect(committer.lastFailure == nil, "a pause is not a failure")
        #expect(transitions == [true], "the banner is raised once, on the transition")

        // "Edits keep landing on disk — files are the board — and commit as one settled batch when
        // the state clears." Mid-session, the re-read that notices is the paused engine's own.
        try fixture.item("\(Ident.lane1)/card-3", plain(order: "3072", title: "Third"))
        try savedHead.write(to: head)
        await committer.flushNow()

        #expect(committer.pause == nil)
        #expect(committer.commitCount == 1, "one settled batch, exactly as any other pause")
        #expect(transitions == [true, false], "and the banner heals — it is a condition, not an event")
        #expect(isClean(at: fixture.root))
    }

    @Test("The popover's own re-read learns the state without attempting anything")
    func refreshPauseLearnsTheUnreadableRepository() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        let head = fixture.root.appendingPathComponent(".git/HEAD")
        let savedHead = try Data(contentsOf: head)
        try FileManager.default.removeItem(at: head)

        await committer.refreshPause()
        #expect(committer.pause == .unreadable)
        #expect(committer.commitCount == 0)

        try savedHead.write(to: head)
        await committer.refreshPause()
        #expect(committer.pause == nil)
    }

    @Test("An unborn HEAD is normal — the first settled change commits the whole tree")
    func anUnbornHeadIsNormal() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        // An adopted repository: someone ran `git init` in a terminal and never committed.
        _ = try Repository.create(at: fixture.root)
        try "ref: refs/heads/main\n".write(
            to: fixture.root.appendingPathComponent(".git/HEAD"),
            atomically: true,
            encoding: .utf8
        )

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .git)
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        await committer.flushNow()

        #expect(committer.pause == nil, "unborn is normal git mode, never a pause")
        #expect(committer.commitCount == 1)
        // "It commits the whole tree as *Initial board state*, never a folded diff-from-empty."
        let log = try history(at: fixture.root)
        #expect(log.count == 1)
        #expect(log[0].subject == GitRepository.initialCommitSubject)
        #expect(isClean(at: fixture.root))
    }

    @Test("A genuine failure suspends history, and a later success clears it")
    func aGenuineFailureSuspendsHistory() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        var suspensions: [String] = []
        var recoveries = 0
        committer.reportFailure = { suspensions.append($0.message) }
        committer.reportRecovery = { recoveries += 1 }

        // A repository whose object store cannot be written to: the files are safe on disk, history
        // stops advancing, and 02's write-failure posture is what says so.
        let objects = fixture.root.appendingPathComponent(".git/objects")
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
        try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: objects.path)
        await committer.flushNow()
        try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: objects.path)

        #expect(!suspensions.isEmpty, "a genuine failure is surfaced, unlike contention")
        #expect(committer.lastFailure != nil)

        // "retried on the next debounce" — and the suspension clears on the first commit that lands.
        await committer.flushNow()
        #expect(committer.commitCount == 1)
        #expect(committer.lastFailure == nil)
        #expect(recoveries > 0)
    }
}

// MARK: - Flush before overwrite

@MainActor
@Suite("Auto-commit ▸ flush before overwrite")
struct FlushBeforeOverwriteTests {

    @Test("An app write over a pending foreign change commits the external version first")
    func theExternalVersionEntersHistoryFirst() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // An agent rewrote a body; the reload landed and classified it foreign.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)",
                         plain(order: "1024", title: "First", body: "the agent's version"))
        committer.noteReloadLanded(sawForeignChange: true)

        // Now the app is about to overwrite it.
        committer.noteWillWrite()

        #expect(committer.commitCount == 1, "the external version is in history before it is overwritten")
        #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail)

        // …and the app's own write then commits on its own debounce: both versions exist as commits.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)",
                         plain(order: "1024", title: "First", body: "the user's version"))
        committer.noteWriteBracketClosed()
        await committer.flushNow()
        #expect(committer.commitCount == 2)
    }

    @Test("A window of nothing but the app's own writes does not flush per gesture")
    func appOnlyWindowsDoNotFlushEarly() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // The cadence constraint: a five-gesture burst must not become five commits just because
        // each gesture passes through the write gate.
        for index in 2...6 {
            committer.noteWillWrite()
            try fixture.item("\(Ident.lane1)/card-\(index)", plain(order: "\(index * 1024)", title: "C\(index)"))
            committer.noteWriteBracketClosed()
        }
        #expect(committer.commitCount == 0)

        await committer.flushNow()
        #expect(committer.commitCount == 1)
    }
}

// MARK: - Composition and the tier gate

@MainActor
@Suite("Auto-commit ▸ composition")
struct AutoCommitCompositionTests {

    @Test("The free tier composes no committer, because it composes no git state at all")
    func theFreeTierHasNoCommitter() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))

        // The tier gate is one level up: no `HistoryStore` means no committer, nothing to disable,
        // and no path by which a free-tier session could touch `.git` (12-editions.md).
        #expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
    }

    @Test("A Pro board without a repository has no committer either")
    func modeNoneHasNoCommitter() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .none)
        #expect(git.committer == nil, "the committer's existence is exactly mode == .git")
    }

    @Test("Add-git builds a committer for the board it just flipped")
    func addGitBuildsACommitter() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        var wired = 0
        git.activateAutoCommit { _ in wired += 1 }
        #expect(git.committer == nil)

        #expect(await git.addGit())
        #expect(git.committer != nil, "the first auto-commit follows the flip")
        #expect(wired == 1, "the committer a mid-session add-git builds is wired like any other")
    }

    @Test("A board that opens in git mode composes a committer, inert until it is started")
    func adoptionComposesAnInertCommitter() async throws {
        let (fixture, _, _) = try await makeGitBoard()
        defer { fixture.tearDown() }

        let reopened = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        let committer = try #require(reopened.committer)
        // Composition happens on the board-open path, where arming a debounce would be a side effect
        // of *detection*. `activateAutoCommit` is what starts it.
        #expect(committer.commitCount == 0)
        #expect(committer.stagedAroundFolders.isEmpty)
    }
}

// MARK: - The composition root

/// **The wired-at-`beginSession` seams, pinned where they are wired** (02-architecture.md ▸ Layering;
/// 12-editions.md ▸ The provider seam).
///
/// Every suite above composes its own committer by hand, which is what makes them readable and is
/// exactly why they cannot see the defect this suite exists for: `AppModel.beginSession` once composed
/// the committer *without* the store's `EchoLedger` (`HistoryStore.compose`'s default is a fresh one,
/// for the store-less callers), so every unit layer passed while every production commit misattributed
/// — the app's own writes arriving unvouched-for and authored `Lanework External`. It was fixed in
/// `a381fac` by passing `store.echoes`, and nothing but a test that opens a board *through the model*
/// could have caught it or can keep it caught.
///
/// So the assertions here are about the **composition** and never about the units: not "the ledger
/// classifies" (`AutoCommitAttributionTests`) and not "a bracket announces at completion"
/// (`BoardAnnouncerTests`), but that a board opened the way a window opens one has those two wires in
/// it.
@MainActor
@Suite("Auto-commit ▸ the composition root")
struct AutoCommitCompositionRootTests {

    /// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
    /// Support home — `AppModelTests`' own fixture, for its reason.
    private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
        let folder = FileManager.default.temporaryDirectory
            .appendingPathComponent("AutoCommitCompositionTests-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        let model = AppModel(
            registryStorageURL: folder.appendingPathComponent("board-registry.json"),
            clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
        )
        model.currentTier = { .pro }
        return (model, { try? FileManager.default.removeItem(at: folder) })
    }

    /// Opens a board the way `BoardWindowHost` does — record, acquire, flag, begin — so what is under
    /// test is the real `beginSession` and not a hand-assembled session.
    private func openBoard(_ model: AppModel, at url: URL) throws -> AppModel.BoardSession {
        let ref = BoardWindowRef(url: url)
        let recordID = model.boardRegistry.recordOpen(of: url)
        let store = try model.storeRegistry.acquire(url)
        model.boardRegistry.setOpenNow(id: recordID)
        model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
        return try #require(model.session(for: ref))
    }

    /// **(a) The committer is composed with the session store's own ledger** — the same instance the
    /// store's writes drop receipts into (`BoardStore.echoes`).
    ///
    /// Asserted through the one thing the ledger decides: **authorship**. An ordinary app-mediated
    /// write through the store, committed by the session's own committer, is authored by this
    /// machine's user. Composed with any *other* ledger it would be authored `Lanework External` —
    /// which is not a hypothetical shape, it is what `AutoCommitAttributionTests`'
    /// `foreignIsLaneworkExternal` pins for a write nobody vouched for, and what this board's every
    /// commit did before `a381fac`.
    @Test("beginSession composes the committer with the store's own EchoLedger")
    func theCommitterIsComposedWithTheStoresLedger() async throws {
        let (fixture, _, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let (model, tearDown) = try makeModel()
        defer { tearDown() }

        let session = try openBoard(model, at: fixture.root)
        let committer = try #require(session.git?.committer)
        // Only the explicit flush commits, and it does not sit out a watcher that a temp directory may
        // or may not deliver events for: this test is about *who* the commit is by.
        committer.stop()
        committer.debounceInterval = .seconds(30)
        committer.coveringSnapshotDeadline = .milliseconds(50)
        committer.coveringSnapshotPollInterval = .milliseconds(5)

        // An ordinary write through the store — the Writer boundary, receipt and all. Nothing here
        // touches the ledger by hand, which is the whole point: the receipt has to travel from the
        // store's own ledger to the committer's, and there is only one way for that to be true.
        let outcome = session.store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "By the app.\n")
        #expect(outcome == .written)
        await committer.flushNow()

        let head = try #require(try history(at: fixture.root).first)
        #expect(head.authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email)
        #expect(
            head.authorEmail != CommitAttribution.externalAuthorEmail,
            "a committer composed over any other ledger would blame the outside world for this write"
        )
    }

    /// **(b) The announcer outlet is bound** — the undo restore's bracket runs through the store's
    /// `performWholesale(announcing:)`, so its subject reaches `BoardStore.announce`.
    ///
    /// `GitHistoryProvider.runBracketed` is optional and "`nil` runs the work bare, which is what a
    /// repository-level test wants" — so an unwired seam is silent rather than broken, and every
    /// repository-level suite in this file would keep passing over one. What a session owes it is the
    /// store's bracket: the watcher suspension, the reload floor that locks the board if the closing
    /// reload fails, and 10-accessibility.md's one sentence at completion.
    @Test("beginSession binds the restore's bracket to the board's announcer outlet")
    func theRestoreBracketReachesTheAnnouncer() async throws {
        let (fixture, _, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let (model, tearDown) = try makeModel()
        defer { tearDown() }

        let session = try openBoard(model, at: fixture.root)
        session.git?.committer?.stop()
        let store = session.store
        let provider = try #require(session.history as? GitHistoryProvider, "a git board binds the git provider")

        var spoken: [String] = []
        store.announce = { if let phrase = $0 { spoken.append(phrase) } }

        let bracket = try #require(provider.runBracketed, "the restore has a bracket to run inside")
        await bracket("Undid 'Add card'") {
            try? fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Restored"))
        }
        // The bracket's closing reload — the one it armed, whichever way the operation went.
        store.handleWatcherEvent(.treeChanged(.appMediated))
        await store.awaitQuiescence()

        #expect(spoken == ["Undid 'Add card'"], "the trail's sentence and the spoken one are the same one")
        #expect(store.readOnlyLock == nil, "the closing reload succeeded, so nothing is locked")
    }
}

// MARK: - The Edit-session flag

/// What the card body still owes the commit model after the stage-around widened to the whole window
/// (06 ▸ Rules ▸ Auto-commit, 2026-07-31): the **flag**, not an announcement.
///
/// `CardBodyEditSession.editSessionDidChange` was the boundary's announcement, and it went with the
/// widening — the exclusion now opens with the window and releases when the window's session ends,
/// so nothing in production ever wired it (`CardWindowHost.configureSession`). What survives is
/// `isEditing`, which the close path reads as part of "does this window hold unsaved content".
@MainActor
@Suite("Auto-commit ▸ the Edit-session flag")
struct EditSessionBoundaryTests {

    @Test("Entering and leaving Edit moves the flag, and a re-assertion of the mode does not")
    func theBoundaryMovesTheFlagOnce() {
        let session = CardBodyEditSession()
        let presentation = CardBodyPresentation()
        presentation.beginEdits = { session.beginEditSession() }
        presentation.flushEdits = { session.endEditSession() }

        presentation.setMode(.edit)
        presentation.setMode(.edit)          // a re-published focus value, a menu validation pass
        session.beginEditSession()           // idempotent
        #expect(session.isEditing)

        presentation.setMode(.preview)
        presentation.setMode(.preview)
        #expect(!session.isEditing)
    }

    @Test("A window that opens straight into Edit is in a session from the start")
    func anEmptyBodyOpensASession() {
        let session = CardBodyEditSession()
        let presentation = CardBodyPresentation()
        presentation.beginEdits = { session.beginEditSession() }

        // "a card opens in Preview — unless its body is empty, which opens straight into Edit".
        #expect(presentation.openIfNeeded(body: "") == .edit)
        #expect(session.isEditing)
    }
}

// MARK: - Semantic messages, through the whole engine

/// The composer's own vocabulary is proved without a repository in `CommitMessageTests`. What is
/// proved here is the wiring: that a **real commit**, made by the real engine over real libgit2,
/// carries the composed message — HEAD's tree read for the last-committed half, the working tree for
/// the current one, the split's own paths narrowing each message to its own commit.
@MainActor
@Suite("Auto-commit ▸ semantic messages")
struct AutoCommitMessageTests {

    @Test("A foreign change composes identically to an app-mediated one — only the author differs")
    func originIsNotInTheProse() async throws {
        // 06 ▸ The external gap, closed: "Origin lives in the author field (structural attribution),
        // not in message prose." Two boards, the same rename, one vouched for and one not.
        func rename(vouchedFor: Bool) async throws -> CommitRecord {
            let (fixture, git, ledger) = try await makeGitBoard()
            defer { fixture.tearDown() }
            let committer = try quickCommitter(git)

            let text = plain(order: "1024", title: "Renamed")
            try fixture.item("\(Ident.lane1)/\(Ident.card1)", text)
            if vouchedFor {
                ledger.recordWrite(
                    at: fixture.url("\(Ident.lane1)/\(Ident.card1)")
                        .appendingPathComponent(BoardLoader.indexFileName),
                    text: text
                )
                committer.noteWriteBracketClosed()
            } else {
                committer.noteReloadLanded(sawForeignChange: true)
            }
            await committer.flushNow()
            return try #require(try history(at: fixture.root).first)
        }

        let app = try await rename(vouchedFor: true)
        let foreign = try await rename(vouchedFor: false)
        #expect(app.subject == "Rename card 'First' → 'Renamed'")
        #expect(foreign.subject == app.subject, "the message engine is origin-agnostic by design")
        // …and the author is the only thing that differs.
        #expect(app.authorEmail != CommitAttribution.externalAuthorEmail)
        #expect(foreign.authorEmail == CommitAttribution.externalAuthorEmail)
    }

    @Test("Board-open catch-up carries a real composed message, not a placeholder")
    func launchCatchUpComposes() async throws {
        // "Changes found pending at board open diff HEAD's tree against the working tree through the
        // same composer, instead of committing blind" (06). Nothing signals this window: no reload
        // landed, no bracket closed, and no snapshot was ever handed to the committer — the previous
        // board can only have come from HEAD.
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Written while closed"))
        committer.start()

        try await waitUntil { committer.commitCount == 1 }
        #expect(try headSubject(at: fixture.root) == "Add card 'Written while closed'")
        #expect(isClean(at: fixture.root))
    }

    @Test("The guide write auto-commits as 'Update agent guide (vN)'")
    func theGuideComposesItsVersion() async throws {
        // The m10 agent-guide card's deferred git bullet, landing here: N is read from the marker
        // line of the bytes on disk (`AgentGuide.installedVersion`), never tagged at the write site.
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        // An older guide, committed — so the window is a genuine guide *upgrade*: HEAD's bytes carry
        // v1 and the working tree's carry the version this build ships.
        try fixture.file(AgentGuide.filename, Data("<!-- lanework-agent-guide v1 -->\nOld.\n".utf8))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()
        #expect(try headSubject(at: fixture.root) == "Update agent guide (v1)")

        _ = try AgentGuide.install(atBoardRoot: fixture.root)
        committer.noteWriteBracketClosed()
        await committer.flushNow()

        #expect(try headSubject(at: fixture.root) == "Update agent guide (v\(AgentGuide.version))")
    }

    @Test("A split window's two commits each describe only their own paths")
    func eachCommitDescribesItsOwnPaths() async throws {
        // Both messages compose against the same HEAD, so the only thing that can keep them apart is
        // the changed-path list each commit stages — the filter, proved end to end.
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Touched by an agent"))

        let text = plain(order: "2048", title: "Added by the user")
        let card = try fixture.item("\(Ident.lane1)/\(Ident.card2)", text)
        ledger.recordWrite(at: card.appendingPathComponent(BoardLoader.indexFileName), text: text)

        committer.noteWriteBracketClosed()
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        let log = try history(at: fixture.root)
        #expect(committer.commitCount == 2)
        // Newest first: the user's overwrite lands after the foreign version it might have buried.
        #expect(log.first?.subject == "Add card 'Added by the user'")
        #expect(log.dropFirst().first?.subject == "Rename card 'First' → 'Touched by an agent'")
        #expect(log.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail)
    }

    @Test("A comment lands in the trail by its own verb, through real libgit2")
    func commentsComposeTheirFamily() async throws {
        // The one part of the comment family that cannot be proved without a repository: "is this
        // path new" is `GIT_DELTA_ADDED`, read off the real diff — the fact that tells a post from an
        // edit where the snapshot has nothing to say (01-storage-format.md § Enhanced schema).
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        let comment = "\(Ident.lane1)/\(Ident.card1)/comments/cccccccc-0000-4000-8000-000000000001"

        try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nLooks good.\n".utf8))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()
        #expect(try headSubject(at: fixture.root) == "Comment on 'First'")

        try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nOn reflection.\n".utf8))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()
        #expect(try headSubject(at: fixture.root) == "Edit comment on 'First'")
    }

    @Test("A stray-only window names the stray rather than shrugging")
    func straysAreNamed() async throws {
        let (fixture, git, _) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)

        try fixture.file("notes.txt", Data("scratch\n".utf8))
        committer.noteReloadLanded(sawForeignChange: true)
        await committer.flushNow()

        #expect(try headSubject(at: fixture.root) == "Update 'notes.txt'")
    }

    // MARK: The covering snapshot

    /// One card-window session's worth of state, as the close flush meets it: a change on disk that
    /// the app vouched for, and a `store.snapshot` that has not caught up yet.
    ///
    /// The board's two store reads are faked rather than driven through a real `BoardStore`, and
    /// deliberately: what is being pinned is *the order the flush reads them in*, which a real
    /// watcher would settle by racing rather than by rule. `landsAfterReads` is the reload landing —
    /// the generation asked for the nth time is the walk that finally covers the write.
    private func flushRacingItsReload(
        awaitsCoverage: Bool,
        landsAfterReads: Int = 3
    ) async throws -> String? {
        let (fixture, git, ledger) = try await makeGitBoard()
        defer { fixture.tearDown() }
        let committer = try quickCommitter(git)
        // Only the explicit flush runs: a debounce firing mid-wait would be a second flush answering
        // the question this test is asking of the first.
        committer.debounceInterval = .seconds(30)
        committer.coveringSnapshotPollInterval = .milliseconds(1)
        committer.coveringSnapshotDeadline = .milliseconds(500)

        // The board as the app last read it — one card, which is what HEAD's tree also says.
        var current = try fixture.snapshot()
        committer.currentSnapshot = { current }

        // The session's write lands on disk, vouched for, with no reload behind it yet.
        let text = plain(order: "2048", title: "Second")
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", text)
        ledger.recordWrite(
            at: fixture.url("\(Ident.lane1)/\(Ident.card2)").appendingPathComponent(BoardLoader.indexFileName),
            text: text
        )
        committer.noteWriteBracketClosed()

        if awaitsCoverage {
            var generation = 0
            var reads = 0
            committer.awaitReloadQuiescence = {}
            committer.landedReloads = {
                reads += 1
                if reads == landsAfterReads {
                    current = (try? fixture.snapshot()) ?? current
                    generation += 1
                    committer.noteReloadLanded(sawForeignChange: false)
                }
                return generation
            }
        }

        await committer.flushNow()
        return try headSubject(at: fixture.root)
    }

    /// **"The flush awaits the snapshot that covers it"** (06 ▸ Rules ▸ Auto-commit, ruled
    /// 2026-07-31): "the commit's subject can never be outrun by its own reload".
    @Test("A close flush racing a stale snapshot composes from the covering one")
    func theFlushAwaitsItsCoveringSnapshot() async throws {
        #expect(try await flushRacingItsReload(awaitsCoverage: true) == "Add card 'Second'")
    }

    /// The same race with the store's two reads unwired — the storeless configuration, and what the
    /// close flush did before the ruling. The commit still lands (the condition is the *tree*), but
    /// its subject describes a board that has not heard about the card it is committing.
    @Test("Without the await the subject is the one the stale snapshot could compose — the defect, pinned")
    func aStaleSnapshotComposesTheShrug() async throws {
        #expect(try await flushRacingItsReload(awaitsCoverage: false) == CommitMessageEngine.unnamedSubject)
    }

    /// The bound is a bound: a board whose watcher stream never came up has no reload to wait for, and
    /// the close path may not hang on one. The commit lands from the snapshot in hand.
    @Test("A covering reload that never lands ends the wait rather than the app")
    func theWaitIsBounded() async throws {
        // The generation never moves, so the wait runs to its (millisecond) deadline and composes.
        #expect(try await flushRacingItsReload(awaitsCoverage: true, landsAfterReads: .max)
            == CommitMessageEngine.unnamedSubject)
    }
}

// MARK: - Attribution, as a pure function

@Suite("Auto-commit ▸ attribution rules")
struct CommitAttributionRuleTests {

    @Test("An agent identity is the name verbatim and a slugified local part")
    func agentIdentityShape() {
        #expect(CommitAttribution.agentIdentity(named: "claude")
            == GitIdentity(name: "claude", email: "claude@agents.lanework.invalid"))
        // Display name verbatim; the address is what gets sanitized.
        #expect(CommitAttribution.agentIdentity(named: "Claude Code")
            == GitIdentity(name: "Claude Code", email: "claude-code@agents.lanework.invalid"))
        // libgit2 refuses a signature with an angle bracket in it, so the slug has to be total —
        // every disallowed character becomes `-`, and the leading/trailing ones are then trimmed.
        #expect(CommitAttribution.agentIdentity(named: "bot <x>").email
            == "bot--x@agents.lanework.invalid")
        #expect(CommitAttribution.agentIdentity(named: "bot <x>").name == "bot <x>")
    }

    @Test("An empty stamp falls back rather than producing a nameless author")
    func anEmptyStampFallsBack() {
        #expect(CommitAttribution.agentIdentity(named: "   ").name == CommitAttribution.externalAuthorName)
    }

    @Test("A rename's departure is not a true deletion")
    func aRenameDepartureDoesNotDemote() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("lane/card", stamped("Moved", by: "claude"))

        let paths = [
            GitChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true),
            GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: true)
        ]
        // "**A folder move is not a deletion**: items match by id across the whole board."
        #expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root).name == "claude")
    }

    @Test("A window of nothing but rename departures has no stamp to agree on")
    func departuresAloneFallBack() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let paths = [GitChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true)]
        #expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root)
            == CommitAttribution.externalIdentity)
    }

    @Test("Only index.md carries a stamp — every other path is unstamped by construction")
    func onlyIndexFilesCarryStamps() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("lane/card", stamped("Stamped", by: "claude"))
        try fixture.file("lane/card/attachments/note.txt", Data("x\n".utf8))

        #expect(CommitAttribution.modifiedBy(atRelativePath: "lane/card/index.md", under: fixture.root) == "claude")
        #expect(CommitAttribution.modifiedBy(atRelativePath: "lane/card/attachments/note.txt", under: fixture.root) == nil)
    }

    @Test("A satisfied receipt vouches; a receipt disk no longer matches does not")
    func satisfactionDecidesProvenance() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let text = plain(order: "1024", title: "Mine")
        try fixture.item("lane/card", text)

        let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md"))
        let matching = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: false)]
        let stale = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: "other")), isHeal: false)]
        let changed = [GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)]

        #expect(CommitAttribution.split(changed, under: fixture.root, receipts: matching).user == changed)
        // "a foreign edit landing on an app-written path inside the same window misses the hash and
        // classifies foreign (last writer wins the file)".
        #expect(CommitAttribution.split(changed, under: fixture.root, receipts: stale).foreign == changed)
        // And with nothing held at all, the app never vouches.
        #expect(CommitAttribution.split(changed, under: fixture.root, receipts: [:]).foreign == changed)
    }

    @Test("A heal-marked receipt lands its path in the heal class")
    func healMarksSplitOut() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let text = plain(order: "1024", title: "Repaired")
        try fixture.item("lane/card", text)

        let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md"))
        let receipts = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: true)]
        let changed = [GitChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)]

        let split = CommitAttribution.split(changed, under: fixture.root, receipts: receipts)
        #expect(split.heal == changed)
        #expect(split.user.isEmpty)
        // Foreign first, then heal, then the user's — the commit order, made assertable.
        #expect(split.ordered.map(\.kind) == [.heal])
    }
}

// MARK: - Helpers

/// An `index.md` with **no `modified-by`** — what the app itself writes ("Absence of `modified-by`
/// means the board's user, via the app", `BoardWriter`), and what every test that is not about the
/// stamp needs: the shared `Item.rich` fixture carries `modified-by: claude`, which would quietly
/// author half of this file's commits as an agent.
private func plain(order: String, title: String, body: String = "Body.") -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    ---
    \(body)

    """
}

/// A card `index.md` carrying a `modified-by` stamp — what a well-behaved agent writes
/// (08-agent-integration.md; 01-storage-format.md).
private func stamped(_ title: String, by writer: String) -> String {
    """
    ---
    schema: 1
    order: 1024
    title: \(title)
    modified-by: \(writer)
    ---
    Body.

    """
}

/// Spins the run loop until `condition` holds or the deadline expires — the debounce's own testimony
/// without a fixed sleep.
@MainActor
private func waitUntil(
    _ condition: @MainActor () -> Bool,
    within deadline: Duration = .seconds(5),
    sourceLocation: SourceLocation = #_sourceLocation
) async throws {
    let start = ContinuousClock.now
    while !condition() {
        guard ContinuousClock.now - start < deadline else {
            Issue.record("condition never held", sourceLocation: sourceLocation)
            return
        }
        try await Task.sleep(for: .milliseconds(5))
    }
}
