Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.
Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.
2444 tests in 422 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
1190 lines
53 KiB
Swift
1190 lines
53 KiB
Swift
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 == "[email protected]")
|
|
}
|
|
|
|
@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 == "[email protected]")
|
|
}
|
|
|
|
@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 == "[email protected]")
|
|
#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 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")
|
|
}
|
|
}
|
|
|
|
// 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: - 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: - 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: "[email protected]"))
|
|
// Display name verbatim; the address is what gets sanitized.
|
|
#expect(CommitAttribution.agentIdentity(named: "Claude Code")
|
|
== GitIdentity(name: "Claude Code", email: "[email protected]"))
|
|
// 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
|
|
== "[email protected]")
|
|
#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))
|
|
}
|
|
}
|