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") #expect(try headSubject(at: fixture.root) == InterimCommitMessage.fallbackSubject) } @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)) } @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 Edit sessions") 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.beginEditSession(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.beginEditSession(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 body 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.beginEditSession(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 Edit→Preview flip — "the effective Save button". committer.endEditSession(token) try await waitUntil { committer.commitCount == 1 } #expect(committer.commitCount == 1, "exactly one body commit per 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.beginEditSession(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.beginEditSession(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") } } // 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) } @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 Edit-session boundary @MainActor @Suite("Auto-commit ▸ the Edit-session boundary") struct EditSessionBoundaryTests { @Test("Entering and leaving Edit announces the session exactly once each way") func theBoundaryIsAnnouncedOnce() { let session = CardBodyEditSession() let presentation = CardBodyPresentation() presentation.beginEdits = { session.beginEditSession() } presentation.flushEdits = { session.endEditSession() } var events: [Bool] = [] session.editSessionDidChange = { events.append($0) } presentation.setMode(.edit) presentation.setMode(.edit) // a re-published focus value, a menu validation pass session.beginEditSession() // idempotent presentation.setMode(.preview) presentation.setMode(.preview) #expect(events == [true, false]) #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) } @Test("A window closing from Preview announces nothing") func closingFromPreviewIsSilent() { let session = CardBodyEditSession() var events: [Bool] = [] session.editSessionDidChange = { events.append($0) } // `CardWindowSession.endSession()` calls this on every close, in Edit or not. session.endEditSession() #expect(events.isEmpty) } @Test("The session's last keystrokes are on disk before the committer is nudged") func theFlushPrecedesTheNudge() { let session = CardBodyEditSession() var landed: [String] = [] var textAtNudge: String? session.save = { text in landed.append(text) return .written } session.editSessionDidChange = { isEditing in if !isEditing { textAtNudge = landed.last } } session.beginEditSession() session.adopt(diskBody: "before") session.edited("after") session.endEditSession() // A nudge that arrived before the flush would arm a commit carrying the file as it stood one // keystroke ago. #expect(textAtNudge == "after") } } // 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 ").email == "bot--x@agents.lanework.invalid") #expect(CommitAttribution.agentIdentity(named: "bot ").name == "bot ") } @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)) } }