The stack comes out — Kanban/Git/ deleted wholesale, ten thousand lines into history

Step 5 of strategy/01-git-excision.md: the seventeen dead engine files and the five remaining git test suites go (InertGitTests stays — the naming footgun is a Storage keeper). Two rescues ride ahead of the delete: HarvestedReceipt relocates to EchoLedger (the harvest surface outlives its git consumer; foundation for the deferred journal), and commentTimestamps joins the narrator it always served. The provider-swap purge test re-expresses over a git-free fake; the duplicate-id ladder keeps every pure historyRank pin and loses only the two ranker-driven ones. Resurrection point: tag pre-git-excision. 2,707 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 11:38:16 -04:00
parent adbc5ddd89
commit edaf5be706
27 changed files with 99 additions and 10020 deletions
File diff suppressed because it is too large Load Diff
-278
View File
@@ -1,278 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **Nearest-`.git`-wins, freshly at every open** (06-history-undo.md Rules Detection) the one
/// question every git surface starts from, pinned against real directories rather than against a
/// mocked filesystem, because what it is *about* is what is on disk.
///
/// The rule has four claims and this file is one test per claim: root wins, an ancestor is nested,
/// neither is `none`, and the answer is re-derived rather than remembered "a board can therefore
/// change mode between opens (e.g. the user ran `git init` in a terminal) the app just reflects
/// what it finds." `BoardGitEntryProbeTests` and `BoardGitModeDenialTests` below pin the
/// 2026-08-06 axis on top of it: "Denial is not absence" a check the sandbox refuses must read as
/// `.unverifiable`, never as `.none`.
// MARK: - Fixtures
/// A `.git` **directory** with a plausible ref inside what `git init` leaves.
private func makeGitDirectory(at parent: URL) throws {
let gitDirectory = parent.appendingPathComponent(".git", isDirectory: true)
try FileManager.default.createDirectory(at: gitDirectory, withIntermediateDirectories: true)
try Data("ref: refs/heads/main\n".utf8).write(to: gitDirectory.appendingPathComponent("HEAD"))
}
/// A `.git` **file** what a linked worktree or a submodule leaves. Still a repository, and the
/// reason detection asks `fileExists` rather than `isDirectory`.
private func makeGitPointerFile(at parent: URL, to target: String) throws {
try Data("gitdir: \(target)\n".utf8).write(to: parent.appendingPathComponent(".git"))
}
/// A board folder inside the fixture, so an *ancestor* can carry the repository.
private func makeSubfolder(_ fixture: WriterFixture, named name: String) throws -> URL {
let url = fixture.root.appendingPathComponent(name, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
// MARK: - Detection
@Suite("Board git mode ▸ detection")
struct BoardGitModeTests {
@Test("A `.git` at the board root is git mode")
func rootRepositoryIsGitMode() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
}
@Test("A `.git` *file* is a repository too — a worktree is not mode none")
func aWorktreePointerIsGitMode() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitPointerFile(at: fixture.root, to: "/somewhere/else/.git/worktrees/board")
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
}
@Test("No `.git` at the root and none above it is mode none")
func aPlainFolderIsModeNone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
}
@Test("A `.git` at an ancestor and none at the root is repo-nested")
func anEnclosingRepositoryIsRepoNested() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
let board = try makeSubfolder(fixture, named: "project/docs/board")
#expect(BoardGitMode.detect(boardRoot: board) == .repoNested)
#expect(BoardGitMode.enclosingRepositoryRoot(above: board)?.standardizedFileURL
== fixture.root.standardizedFileURL)
}
@Test("Nearest wins: a board with its own `.git` inside a repository is git mode, not nested")
func theNearestRepositoryWins() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
let board = try makeSubfolder(fixture, named: "board")
try makeGitDirectory(at: board)
#expect(BoardGitMode.detect(boardRoot: board) == .git)
}
@Test("The walk terminates above a board at the filesystem root, finding nothing")
func theAncestorWalkTerminates() {
// The one hazard this walk has ever had: NSURL-bridged URLs whose
// `deletingLastPathComponent` grows "/.." forever instead of settling at "/". The temp
// directory has no repository above it, so the honest answer is `nil` reached, not hung.
#expect(BoardGitMode.enclosingRepositoryRoot(above: URL(fileURLWithPath: "/")) == nil)
}
// MARK: Freshness
@Test("Detection is re-derived at every open: a board that gains a `.git` opens in git mode next time")
func modeChangesBetweenOpens() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none, "the first open")
// What a user does in a terminal under a closed board.
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git, "the next open reflects what it finds")
}
@Test("And a board that loses its `.git` opens back in mode none")
func modeChangesBackBetweenOpens() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
try FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".git"))
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
}
}
// MARK: - Entry probe classification
/// **The errno-aware probe underneath detection** (06-history-undo.md Rules Detection, "Denial
/// is not absence", ruled 2026-07-31) pinned directly, one test per classification, before the
/// walk that builds on it is asked to prove anything.
///
/// The `denied` cases chmod a real directory to `0o000` tests run unprivileged, so `EACCES` is
/// genuinely reachable this way and restore it with an explicit `defer` declared *after* the
/// fixture's own teardown defer, so it runs first (Swift's LIFO defer order):
/// `BoardDuplicatorTests.aFailedWalkRemovesThePartialSibling` is the precedent this mirrors, so a
/// failed assertion can never leave an unremovable temp directory behind.
@Suite("Board git mode ▸ entry probe")
struct BoardGitEntryProbeTests {
@Test("A `.git` directory probes as exists")
func probesExists() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.probeGitEntry(at: fixture.root) == .exists)
#expect(BoardGitMode.hasGitEntry(at: fixture.root), "the boolean convenience agrees")
}
@Test("A plain folder with no `.git` probes as absent")
func probesAbsent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(BoardGitMode.probeGitEntry(at: fixture.root) == .absent)
#expect(!BoardGitMode.hasGitEntry(at: fixture.root))
}
@Test("A folder somewhere the sandbox denies traversal probes as denied, not absent")
func probesDenied() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let outer = fixture.root.appendingPathComponent("outer", isDirectory: true)
let inner = outer.appendingPathComponent("inner", isDirectory: true)
try FileManager.default.createDirectory(at: inner, withIntermediateDirectories: true)
// Chmod the *parent*, not the probed folder itself: resolving `inner/.git` needs search
// permission on `outer`, which a plain unix permission bit can deny for the test's own
// unprivileged user exactly as the sandbox denies an ungranted ancestor.
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: outer.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: outer.path) }
#expect(BoardGitMode.probeGitEntry(at: inner) == .denied)
#expect(!BoardGitMode.hasGitEntry(at: inner), "the boolean convenience collapses denied to false, like absent")
}
}
// MARK: - Denial-aware detection
/// **The walk semantics denial adds** (06 Rules Detection): a denied ancestor never ends the
/// walk early, because a farther ancestor's `.git` still makes repo-nested certain; only a walk that
/// finds nothing at all *and* saw a denial along the way reads `.unverifiable`.
@Suite("Board git mode ▸ denial-aware detection")
struct BoardGitModeDenialTests {
@Test("A denied board-root probe is unverifiable outright — the walk never runs")
func deniedRootProbeIsUnverifiable() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let boardRoot = fixture.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: boardRoot.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: boardRoot.path) }
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
}
@Test("A denied ancestor with nothing found anywhere else reads unverifiable")
func deniedAncestorWithNothingFoundIsUnverifiable() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let blocked = fixture.root.appendingPathComponent("blocked", isDirectory: true)
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
let walk = BoardGitMode.ancestorWalk(above: boardRoot)
#expect(walk.root == nil)
#expect(walk.sawDenial)
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
}
@Test("A denied nearer ancestor never hides a `.git` on a farther one — repo-nested is certain")
func deniedAncestorWithARepositoryFartherUpIsRepoNested() throws {
// **A note on what chmod can and cannot simulate**: the sandbox denies a *specific path*
// independently of the filesystem's own permission bits an ancestor above the board's
// grant can be denied while the board root itself, inside the grant, stays fully readable.
// POSIX `chmod`, in contrast, cascades: removing search permission from a real ancestor
// directory denies resolving *everything* beneath it, board root included, which is a
// strictly stronger (and still individually honest) denial than the sandbox's. So this test
// proves the walk's own claim directly `ancestorWalk(above:)` never touches `boardRoot`
// itself, only the candidates above it, and is unaffected by that cascade.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
let blocked = fixture.root.appendingPathComponent("blocked", isDirectory: true)
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
// "A farther ancestor showing `.git` makes repo-nested certain regardless of the denied
// nearer one nearest-wins only affects which root you'd name, not whether one exists."
let walk = BoardGitMode.ancestorWalk(above: boardRoot)
#expect(walk.root?.standardizedFileURL == fixture.root.standardizedFileURL)
#expect(walk.sawDenial, "the denial is still recorded, even though it didn't decide the outcome")
// `detect(boardRoot:)` itself reads `.unverifiable` here not `.repoNested` but for the
// cascade reason above, not because the walk's certainty claim is false: `blocked` sits
// between the filesystem root and `boardRoot`, so chmoding it also denies **`boardRoot`'s
// own** `.git` probe, and `detect` answers that denial before the ancestor walk ever runs
// (06 Rules: "probe the board root's `.git` first denied `.unverifiable`"). A real
// sandboxed board, whose own root sits inside the grant, would not hit this path its own
// probe would succeed and the walk above is what would then run and find `.repoNested`.
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
}
@Test("All-clean paths are unaffected: none, git, and repo-nested still read as before")
func cleanPathsAreUnaffected() throws {
let plain = try WriterFixture()
defer { plain.tearDown() }
try plain.item("", Item.board)
#expect(BoardGitMode.detect(boardRoot: plain.root) == .none)
let gitBoard = try WriterFixture()
defer { gitBoard.tearDown() }
try makeGitDirectory(at: gitBoard.root)
#expect(BoardGitMode.detect(boardRoot: gitBoard.root) == .git)
let nested = try WriterFixture()
defer { nested.tearDown() }
try makeGitDirectory(at: nested.root)
let board = try makeSubfolder(nested, named: "project/docs/board")
#expect(BoardGitMode.detect(boardRoot: board) == .repoNested)
}
}
+25 -5
View File
@@ -16,6 +16,25 @@ import Testing
/// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider
/// grammar is `HistoryProviderTests`'.
// MARK: - A substrate that keeps no steps
/// **A provider that retires every step on arrival** the minimal fake `HistoryProviding.backedContent`'s
/// own doc names ("a substrate that keeps no steps... and a test fake's"). `register(_:)` runs the
/// step's retirement immediately and keeps nothing, which is what makes "purge rides the close flush"
/// true over such a substrate with no tier check anywhere in the call path.
@MainActor
private final class NoBackingHistoryProvider: HistoryProviding {
var canUndo = false
var canRedo = false
var undoActionName: String?
var redoActionName: String?
func register(_ step: HistoryStep) { step.retirement?.run() }
func undo() {}
func redo() {}
func clear() {}
}
// MARK: - The window under test
@MainActor
@@ -735,7 +754,7 @@ struct CardSessionPurgeTests {
defer { fixture.tearDown() }
let (window, card) = try await closedWithADeletedComment(fixture)
// `AppModel`'s teardown, and the add-git swap, both do exactly this.
// `AppModel`'s teardown does exactly this.
window.board.clear()
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
@@ -823,10 +842,11 @@ struct CardSessionPurgeTests {
try fixture.item(path, commentText())
let window = try makeWindow(fixture)
window.comments.reload()
// The git provider drops every registration (its substrate is the commit trail) and retires
// it on the way past which is what makes "purge rides the close flush" true on Pro with no
// tier check at any call site. Bound directly here: `register` reads no repository.
window.store.history = GitHistoryProvider(boardRoot: fixture.root)
// A substrate that keeps no steps drops every registration and retires it on the way past
// which is what makes "purge rides the close flush" true structurally, with no tier check at
// any call site. `history` is weak, so the fake is held locally for the assertion's duration.
let noBackingProvider = NoBackingHistoryProvider()
window.store.history = noBackingProvider
window.comments.delete(ItemID(rawValue: CommentIdent.one))
await window.session.endSession()
+3 -3
View File
@@ -46,7 +46,7 @@ private func files(under root: URL) -> [String: Data] {
return found
}
/// What `GitCommitOperation.surveyChangedPaths` would have reported for these two trees a plain
/// The changed-path list a repository survey would have reported for these two trees a plain
/// content comparison, since nothing here has a repository to ask.
private func changedPaths(from before: URL, to after: URL) -> [ChangedPath] {
let old = files(under: before)
@@ -113,7 +113,7 @@ private func compose(
// Resolved the way a flush resolves it off the "after" tree, through the committer's own
// reader rather than hand-assembled, for the same reason both snapshots are loaded rather
// than built: a map the flush could never produce would prove nothing about the flush.
commentTimestamps: GitAutoCommitter.commentTimestamps(for: paths, boardRoot: after.root)
commentTimestamps: ChangeNarrator.commentTimestamps(for: paths, boardRoot: after.root)
))
}
@@ -993,6 +993,6 @@ struct CommitMessageRootCommitTests {
snapshot: try fixture.snapshot(),
previousSnapshot: nil
))
#expect(message == GitRepository.initialCommitSubject)
#expect(message == ChangeNarrator.rootSubject)
}
}
-44
View File
@@ -549,26 +549,6 @@ struct DuplicateIdentityDetectionTests {
#expect(result.duplicateIdentities.map(\.winner) == ["\(Ident.lane1)/\(Dup.lower)"])
}
/// The same straddle with git in the picture: an -drag restore leaves the *tracked* path in the
/// trash, so the ghost is the one history knows and still loses.
@Test("A tracked ghost still loses to the untracked live card")
func trackedGhostStillLoses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(live, date(1000))
try setBirth(ghost, date(0))
let ranker = BoardLoader.IdentityHistoryRanker { path in
path == ".trash/\(Dup.lower)" ? 1 : nil
}
let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: ranker)
#expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"])
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
}
/// **The same preference governs a trashed lane sharing a live lane's UUID** the ruling says so
/// explicitly, and it needs no special case: a trashed lane is a `.trash/` entry like any other.
@Test("A trashed lane loses to the live lane sharing its UUID")
@@ -706,30 +686,6 @@ struct DuplicateIdentityDetectionTests {
])
}
// MARK: The history seam
/// The seam base can never fill: an injected ranker decides the winner ahead of the birth dates.
@Test("An injected history ranker outranks the filesystem")
func historyRankerDecides() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(original, date(0))
try setBirth(copy, date(1000))
// Without a ranker the older folder wins
#expect(try BoardLoader.load(boardRoot: fixture.root).duplicateIdentities.map(\.path)
== ["\(Ident.lane2)/\(Dup.lower)"])
// and with one that says the newer path entered history first, it does not.
let ranker = BoardLoader.IdentityHistoryRanker { path in
path == "\(Ident.lane2)/\(Dup.lower)" ? 1 : nil
}
#expect(try BoardLoader.load(boardRoot: fixture.root, historyRanker: ranker).duplicateIdentities.map(\.path)
== ["\(Ident.lane1)/\(Dup.lower)"])
}
/// Three copies of one card: two are withheld, one renders and the notice folds.
@Test("Three occurrences leave one standing")
func threeCopiesLeaveOne() throws {
-365
View File
@@ -1,365 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **Where the app's commits get their author from** (06-history-undo.md Interaction with external
/// writers "Where the user's git identity comes from"): repo-local `.git/config` when it names
/// one, the derived `Full Name <shortname@hostname>` default when it doesn't.
///
/// Both halves are pure functions here on purpose. The derivation takes its three strings as
/// arguments rather than reading the machine, so the *shape* is provable on any machine including
/// one whose account has no full name, which is the case the fallbacks exist for. And the config
/// read is a parse over text, so the format's edges (comments, quoting, subsections, a `[user]`
/// section that never appears) are pinned without a repository. The write side (`GitConfigFile
/// .applying`) is pinned the same way: a pure function over text, so every rule in 06's "Writes
/// append, reads take the last" paragraph is provable without a repository either.
@Suite("Git identity ▸ the derived default")
struct GitIdentityDerivationTests {
@Test("The shape is the account's full name plus shortname@hostname")
func theDerivedShape() {
let identity = GitIdentity.derived(fullName: "Ada Lovelace", accountName: "ada", hostName: "analytical.local")
#expect(identity.name == "Ada Lovelace")
#expect(identity.email == "[email protected]")
}
@Test("An account with no full name falls back to its short name rather than committing as \"\"")
func anEmptyFullNameFallsBack() {
let identity = GitIdentity.derived(fullName: " ", accountName: "ada", hostName: "analytical.local")
#expect(identity.name == "ada")
#expect(identity.email == "[email protected]")
}
@Test("Characters an address may not carry are collapsed, not passed to libgit2")
func addressComponentsAreSanitized() {
// libgit2 refuses a signature carrying a space or an angle bracket outright the commit
// fails rather than looking odd so this is a correctness fallback, not cosmetics.
let identity = GitIdentity.derived(
fullName: "Ada Lovelace",
accountName: "ada lovelace",
hostName: "Ada's <Mac>.local"
)
#expect(!identity.email.contains(" "))
#expect(!identity.email.contains("<"))
#expect(!identity.email.contains(">"))
#expect(identity.email == "[email protected]")
}
@Test("A machine with no name reads localhost, and an account with none reads user")
func emptyComponentsHaveHonestFallbacks() {
let identity = GitIdentity.derived(fullName: "", accountName: "", hostName: "")
#expect(identity.name == "Lanework")
#expect(identity.email == "user@localhost")
}
@Test("A trailing dot on a fully-qualified host name is dropped")
func aTrailingDotIsDropped() {
let identity = GitIdentity.derived(fullName: "Ada", accountName: "ada", hostName: "host.example.com.")
#expect(identity.email == "[email protected]")
}
@Test("This machine's derived default is well-formed, whatever this machine is called")
func theMachineDefaultIsWellFormed() {
let identity = GitIdentity.derivedDefault()
#expect(!identity.name.isEmpty)
#expect(identity.email.contains("@"))
#expect(!identity.email.contains(" "))
}
}
@Suite("Git identity ▸ repo-local config wins")
struct GitConfigFileTests {
@Test("A `[user]` section supplies both halves")
func bothKeysAreRead() {
let text = """
[core]
\trepositoryformatversion = 0
[user]
\tname = Ada Lovelace
\temail = [email protected]
"""
let identity = GitConfigFile.identity(inConfigText: text)
#expect(identity.name == "Ada Lovelace")
#expect(identity.email == "[email protected]")
}
@Test("Config wins over the derived default, key by key")
func resolutionPrefersConfigPerKey() {
let derived = GitIdentity(name: "Machine Owner", email: "[email protected]")
let both = GitIdentity.resolve(repoLocal: (name: "Ada", email: "[email protected]"), derived: derived)
#expect(both == GitIdentity(name: "Ada", email: "[email protected]"))
// Half-configured is a real state it is what a `git config user.email` typo leaves and
// git resolves each key on its own.
let nameOnly = GitIdentity.resolve(repoLocal: (name: "Ada", email: nil), derived: derived)
#expect(nameOnly == GitIdentity(name: "Ada", email: "[email protected]"))
let neither = GitIdentity.resolve(repoLocal: (name: nil, email: " "), derived: derived)
#expect(neither == derived, "a blank value is not a value")
}
@Test("Comments and quoting are read the way git reads them")
func theParseHandlesTheFormatsEdges() {
let text = """
# a comment
; another
[user]
\tname = "Ada # Lovelace"
\temail = [email protected] # trailing comment
"""
let identity = GitConfigFile.identity(inConfigText: text)
#expect(identity.name == "Ada # Lovelace", "a `#` inside quotes is content")
#expect(identity.email == "[email protected]", "an unquoted trailing comment is not")
}
@Test("Reads take the last plain-section value, and no subsection's")
func readsTakeTheLastPlainSectionValue() {
// **Writes append, reads take the last** (06 Interaction with external writers, blessed
// 2026-07-31): "the reader like git itself takes the last plain-section value, which is
// exactly what an append produces."
let appended = """
[user]
\tname = Old Ada
\temail = [email protected]
[user]
\tname = New Ada
\temail = [email protected]
"""
#expect(GitConfigFile.identity(inConfigText: appended).name == "New Ada")
#expect(GitConfigFile.identity(inConfigText: appended).email == "[email protected]")
// A subsection is a *different key* in git's model `user.work.name`, not `user.name` so
// it is not an answer to this question however late in the file it sits. Signing the user's
// commits with an identity they filed under a name this app never asked about would be the
// worse error, and 06 says plain-section for exactly that reason.
let subsectioned = """
[user]
\tname = Ada
\temail = [email protected]
[user "work"]
\tname = Work Ada
\temail = [email protected]
"""
#expect(GitConfigFile.identity(inConfigText: subsectioned).name == "Ada")
#expect(GitConfigFile.identity(inConfigText: subsectioned).email == "[email protected]")
// A file with *only* a subsection names nobody, and falls through to the derived default.
let onlySubsection = "[user \"work\"]\n\tname = Work Ada\n\temail = [email protected]\n"
#expect(GitConfigFile.identity(inConfigText: onlySubsection) == (nil, nil))
}
@Test("A config with no `[user]` section, or no config at all, names nobody")
func absentConfigNamesNobody() throws {
let empty = GitConfigFile.identity(inConfigText: "[core]\n\tbare = false\n")
#expect(empty.name == nil)
#expect(empty.email == nil)
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let missing = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
#expect(missing.name == nil)
#expect(missing.email == nil)
}
@Test("The file on disk is what is read — the board root's own `.git/config`")
func theFileIsRead() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.file(".git/config", Data("[user]\n\tname = Ada\n\temail = [email protected]\n".utf8))
let identity = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
#expect(identity.name == "Ada")
#expect(identity.email == "[email protected]")
}
}
/// **The write side** (06-history-undo.md Interaction with external writers, "Where the user's
/// git identity comes from"; the clear rule ruled 2026-08-06): a set is append-only it never edits
/// or deletes an existing line, appending one new plain `[user]` section instead, even over an
/// already-populated file and a clear is the one sanctioned in-place edit, deleting every matching
/// line in every plain `[user]` section and dropping any header left empty. Both halves resolve
/// against the current parse first, so a call that would change nothing is a true no-op.
@Suite("Git identity ▸ writing repo-local config")
struct GitConfigFileWriteTests {
@Test("A set never edits an existing line — it appends a new section, and the old line survives verbatim")
func setAppendsRatherThanEditing() {
// Weird indentation and an inline comment: exactly the shape a set must leave untouched.
let original = "[user]\n name = Old Name # keep me, weird spacing and all\n"
let written = GitConfigFile.applying(name: "New Name", email: "[email protected]", to: original)
#expect(
written.contains(" name = Old Name # keep me, weird spacing and all"),
"the original line survives byte-for-byte"
)
#expect(written.contains("\tname = New Name"), "the set lands in a freshly appended section")
#expect(written.contains("\temail = [email protected]"))
#expect(
written.components(separatedBy: "[user]").count - 1 == 2,
"a second `[user]` header was appended, not merged into the first"
)
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "New Name", "last-wins reading is what makes the appended value win")
#expect(read.email == "[email protected]")
}
@Test("Setting a key to its already-current value is a true no-op — byte-identical, no growth")
func settingTheCurrentValueIsANoOp() {
let text = "[user]\n\tname = Ada Lovelace\n\temail = [email protected]\n"
let written = GitConfigFile.applying(name: "Ada Lovelace", email: "[email protected]", to: text)
#expect(written == text)
// Whitespace around an unchanged value still resolves to the same target, so it is still a
// no-op the comparison is on trimmed content, not on the caller's exact bytes.
let paddedTarget = GitConfigFile.applying(name: " Ada Lovelace ", email: " [email protected] ", to: text)
#expect(paddedTarget == text)
}
@Test("Clearing an absent key returns byte-identical text")
func clearingAnAbsentKeyIsANoOp() {
let text = "[user]\n\tname = Ada\n"
let written = GitConfigFile.applying(name: "Ada", email: nil, to: text)
#expect(written == text)
}
@Test("A clear deletes every occurrence across two plain `[user]` sections, and reads back nil")
func clearDeletesEveryOccurrence() {
let text = """
[user]
\temail = [email protected]
[core]
\tbare = false
[user]
\temail = [email protected]
"""
// `name` is already absent everywhere, so passing `nil` for it is a no-op; only `email` is
// genuine pending work, and it must be cleared from *both* plain sections, not just the last.
let written = GitConfigFile.applying(name: nil, email: "", to: text)
#expect(!written.contains("email"), "no occurrence survives, in either section")
#expect(written.contains("\tbare = false"), "an unrelated section is untouched")
#expect(GitConfigFile.identity(inConfigText: written).email == nil)
}
@Test("Clearing both keys drops every emptied `[user]` header, but keeps one that still has signingkey")
func clearingDropsOnlyTrulyEmptyHeaders() {
let text = """
[user]
\tname = Ada
[user]
\temail = [email protected]
[user]
\tname = Ada C
\temail = [email protected]
\tsigningkey = ABC123
"""
let written = GitConfigFile.applying(name: "", email: nil, to: text)
#expect(!written.contains("name ="), "no name line remains anywhere")
#expect(!written.contains("email ="), "no email line remains anywhere")
#expect(written.contains("\tsigningkey = ABC123"), "a key this app has no opinion about survives")
#expect(
written.components(separatedBy: "[user]").count - 1 == 1,
"the two now-empty headers are dropped; the section keeping signingkey keeps its header"
)
#expect(GitConfigFile.identity(inConfigText: written) == (nil, nil))
}
@Test("A combined set-and-clear call clears in place, then appends the set section")
func combinedSetAndClear() {
let original = """
[user]
\tname = Old Name
\temail = [email protected]
"""
let written = GitConfigFile.applying(name: "New Name", email: "", to: original)
#expect(written.contains("\tname = Old Name"), "the set never deletes the line it is replacing")
#expect(written.contains("\tname = New Name"), "the set lands in an appended section")
#expect(!written.contains("email"), "the clear deletes the email line in place, nothing appended for it")
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "New Name")
#expect(read.email == nil)
}
@Test("A `[user \"work\"]` subsection is untouched by a set or a clear, and never leaks into a read")
func subsectionsAreUntouchable() {
let original = """
[user "work"]
\tname = Work Ada
\temail = [email protected]
[user]
\tname = Home Ada
\temail = [email protected]
"""
#expect(GitConfigFile.identity(inConfigText: original).name == "Home Ada", "the subsection is not read")
let cleared = GitConfigFile.applying(name: "", email: "", to: original)
#expect(cleared.contains("[user \"work\""), "the subsection header survives")
#expect(cleared.contains("\tname = Work Ada"), "the subsection's own keys are untouched by a clear")
#expect(cleared.contains("\temail = [email protected]"))
#expect(!cleared.contains("[user]"), "the plain section is what a clear may empty out")
#expect(GitConfigFile.identity(inConfigText: cleared) == (nil, nil), "the subsection never leaks into a read")
let written = GitConfigFile.applying(name: "New Home Ada", email: "[email protected]", to: original)
#expect(written.contains("[user \"work\""), "the subsection header survives a set too")
#expect(written.contains("\tname = Work Ada"), "the subsection's own keys are untouched by a set")
#expect(written.contains("\tname = Home Ada"), "the old plain section survives verbatim — sets never edit")
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "New Home Ada", "the appended section wins by last-wins, never the subsection")
#expect(read.email == "[email protected]")
}
@Test("Writing into empty text creates just the new `[user]` section")
func writesIntoAnEmptyConfig() {
let written = GitConfigFile.applying(name: "Ada Lovelace", email: "[email protected]", to: "")
#expect(written == "[user]\n\tname = Ada Lovelace\n\temail = [email protected]\n")
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "Ada Lovelace")
#expect(read.email == "[email protected]")
}
@Test("Trailing-newline shape: a clear preserves it, a set's append normalizes it")
func trailingNewlineRoundTrip() {
// Clearing is a pure line deletion it must not add a trailing newline that was never there.
let withoutTrailingNewline = "[user]\n\tname = Ada\n\temail = [email protected]"
let clearedNoTrailingNewline = GitConfigFile.applying(name: "Ada", email: nil, to: withoutTrailingNewline)
#expect(clearedNoTrailingNewline == "[user]\n\tname = Ada", "no trailing newline was introduced")
// ...and must not drop one that was.
let withTrailingNewline = "[user]\n\tname = Ada\n\temail = [email protected]\n[core]\n\tbare = false\n"
let clearedWithTrailingNewline = GitConfigFile.applying(name: "Ada", email: nil, to: withTrailingNewline)
#expect(clearedWithTrailingNewline.hasSuffix("\tbare = false\n"), "the file's own trailing newline survives")
// A set's append always lands the current code's shape (blank-line separator, one trailing
// newline) whether or not the original file ended in one.
let appendedNoTrailingNewline = GitConfigFile.applying(name: "Ada", email: "[email protected]", to: "[core]\n\tbare = false")
let appendedWithTrailingNewline = GitConfigFile.applying(name: "Ada", email: "[email protected]", to: "[core]\n\tbare = false\n")
#expect(appendedNoTrailingNewline == "[core]\n\tbare = false\n\n[user]\n\tname = Ada\n\temail = [email protected]\n")
#expect(appendedWithTrailingNewline == appendedNoTrailingNewline, "the trailing-newline state of the input doesn't change the appended shape")
}
}
-625
View File
@@ -1,625 +0,0 @@
import Foundation
import SwiftGitX
import Testing
@testable import Kanban
/// **The board's git state** (06-history-undo.md Rules; 02-architecture.md Components
/// HistoryStore) composed for every session, detected at open, and changed afterwards by
/// exactly one thing. It was "composed under the tier" until PIVOT 2026-08-07 (12-editions.md git
/// left the paywall); the tier axis is gone from composition and from everything below it.
///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with
/// bundled libgit2 and no external git dependency", and a fixture faked out of hand-written files
/// could not tell whether that happened. Nothing in this file shells out to `git` there is no
/// `/usr/bin/git` in the promise this feature makes, so there is none in its tests either.
// MARK: - Fixtures
/// A board with one lane and one card small, and enough for a tree with three `index.md`s in it.
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
return fixture
}
/// A `.git` that is not a repository a directory with a plausible `HEAD` in it. Enough for
/// *detection*, which asks the filesystem one question, and deliberately not enough for libgit2,
/// which is how a test can tell the two apart.
private func plantGitDirectory(in fixture: WriterFixture, under parent: String = "") throws {
let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
}
/// A second (third, fourth) commit, made the way an external writer makes one SwiftGitX directly,
/// not through the app, which has no commit surface until the auto-commit card.
private func commitEverything(at boardRoot: URL, message: String) throws {
let repository = try Repository.open(at: boardRoot)
try repository.add(paths: [])
_ = try repository.commit(message: message)
}
/// Bytes and mtimes of everything under a subtree `UntouchedGitTests`' instrument, in the shape
/// this file needs it: what proves that *reading* a board's mode touched nothing.
private struct SubtreeEntry: Equatable {
let path: String
let data: Data?
let modified: Date
}
private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] {
let base = root.appendingPathComponent(".git", isDirectory: true)
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: base.path) else { return [] }
var entries: [SubtreeEntry] = []
for case let relative as String in walker {
let url = base.appendingPathComponent(relative)
let attributes = try manager.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else { continue }
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
entries.append(SubtreeEntry(
path: relative,
data: isDirectory ? nil : try Data(contentsOf: url),
modified: modified
))
}
return entries.sorted { $0.path < $1.path }
}
/// **A `.git` file aimed at nothing** the worktree/submodule pointer shape (`gitdir: `), which
/// detection reads as a repository (presence is presence, 06 Rules Detection) and libgit2 cannot
/// open, because the directory it names is not there.
private func plantDanglingGitPointer(in fixture: WriterFixture) throws {
let target = fixture.root.appendingPathComponent("nowhere/.git/worktrees/board").path
try fixture.file(".git", Data("gitdir: \(target)\n".utf8))
}
/// **A SHA-256 repository, by hand** the layout libgit2 validates, plus the two config keys
/// `git init --object-format=sha256` writes (06 Repository hygiene: "SHA-256 repositories are
/// unsupported, safely an adopted SHA-256 repo the engine cannot open takes the corrupt-repo
/// loud-failure path").
///
/// Built by hand rather than by `git init --object-format=sha256` for the file's standing reason:
/// there is no `/usr/bin/git` in this feature's promise, so there is none in its tests. What makes
/// the fixture honest is that nothing here is a mock the bytes are the ones git writes, and the
/// refusal is libgit2's own.
private func plantSHA256Repository(in fixture: WriterFixture) throws {
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
try fixture.file(".git/objects/info/.keep", Data())
try fixture.file(".git/refs/heads/.keep", Data())
try fixture.file(".git/config", Data("""
[core]
\trepositoryformatversion = 1
\tbare = false
[extensions]
\tobjectformat = sha256
""".utf8))
}
// MARK: - Composition
@MainActor
@Suite("HistoryStore ▸ composition and open-time detection")
struct HistoryStoreCompositionTests {
@Test("Composition takes no tier: a board carrying a repository opens in git mode, full stop")
func compositionIsUnconditional() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
// **PIVOT 2026-08-07** (12-editions.md git left the paywall). This was the tier gate's own
// test, and it read the other way: `compose(boardRoot:tier: .free)` answered `nil`, so a free
// session had no git state to consult and never stat'ed a `.git` (the inert posture, made
// structural). The gate is gone the parameter with it and detection now runs on this
// board for every session there is, which is what the assertion below says by having no tier
// to name.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git, "the `.git` at the root is live, not inert")
}
@Test("A plain board is mode none — and opening one never creates a repository")
func aPlainBoardIsModeNone() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none)
// "No silent auto-init, ever" (06 Rules) the deliberate pivot from the pathfinder, which
// initialized a repository under every board it opened. Composing twice is the whole test:
// two opens, no repository.
_ = HistoryStore.compose(boardRoot: fixture.root)
#expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in")
}
@Test("A board whose root already has a repository opens in git mode, silently")
func adoptionNeedsNoStep() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own
// add-git in a previous session), and the repository is simply there.
let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit())
let afterInit = try #require(GitRepository.headCommit(at: fixture.root))
let before = try snapshotGitDirectory(fixture.root)
// The next open. Adoption is not init: no dialog, no confirmation, no second step the mode
// is simply what the filesystem says, and it says git.
let second = HistoryStore.compose(boardRoot: fixture.root)
#expect(second.mode == .git)
// And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes.
// Composition is a `stat`, not an operation.
#expect(GitRepository.headCommit(at: fixture.root)?.oid == afterInit.oid)
#expect(try snapshotGitDirectory(fixture.root) == before)
}
@Test("A board nested inside a repository opens repo-nested")
func nestedBoardsAreDetectedAsNested() throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try plantGitDirectory(in: outer)
let boardRoot = outer.root.appendingPathComponent("docs/board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(git.mode == .repoNested)
}
@Test("Mode is an open-time fact: a `.git` appearing mid-session does not flip the open board")
func noMidSessionDiscoveredFlip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none)
// What a `git init` in a terminal under an open board does which is nothing, until the
// next open (06 Rules: "the running session keeps its mode, and the watcher does not scan
// for `.git` appearing").
try plantGitDirectory(in: fixture)
#expect(git.mode == .none, "the open session keeps the mode it composed with")
let nextOpen = HistoryStore.compose(boardRoot: fixture.root)
#expect(nextOpen.mode == .git, "and the next open reflects what it finds")
}
}
// MARK: - The unreadable repository
/// **A `.git` that isn't a valid repository still reads as git mode and fails loudly**
/// (06-history-undo.md Rules, ruled 2026-07-31).
///
/// The probe is `GitRepository.canOpen(at:)` the same `Repository.open` every read in that file
/// makes run at composition, seeding the committer's pause so the whole git surface is held from
/// the first moment rather than from the first debounce. Every fixture here is a real shape from the
/// wild: a half-made `.git`, a worktree pointer aimed at nothing, and a SHA-256 repository this
/// engine has no support for.
@MainActor
@Suite("HistoryStore ▸ the unreadable repository")
struct HistoryStoreUnreadableRepositoryTests {
@Test("A repository that opens reads readable, and holds nothing")
func aValidRepositoryIsReadable() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit())
// The next open, which is where the probe actually runs.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
#expect(!git.isRepositoryUnreadable)
#expect(git.committer?.pause == nil)
#expect(GitRepository.canOpen(at: fixture.root))
}
@Test("A corrupt `.git` stays git mode, reads unreadable, and holds the surface from the first moment")
func aCorruptGitDirectoryIsUnreadable() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// A `.git` with nothing in it but a plausible HEAD: enough for detection, which asks the
// filesystem one question, and not a repository at all to libgit2.
try plantGitDirectory(in: fixture)
let git = HistoryStore.compose(boardRoot: fixture.root)
// **Never a fall to mode none** "detection is presence-shaped a corrupt or unopenable
// repo never falls to mode none", which is what keeps add-git from ever being offered
// against an existing `.git` ("init into a repairable repo is exactly the never-mutate
// hazard").
#expect(git.mode == .git)
#expect(git.isRepositoryUnreadable)
#expect(!GitRepository.canOpen(at: fixture.root))
// The pause is seeded at *detection*, before anything has been attempted: the surface is
// held and the banner is raised at the open rather than a debounce later.
#expect(git.committer?.pause == .unreadable)
#expect(git.committer?.lastFailure == nil, "a pause is not a failure")
// And the one operation that could make it worse is refused, whatever the mode read.
#expect(await git.addGit() == false)
}
@Test("A worktree pointer aimed at nothing reads unreadable — the file shape, not just the directory one")
func aDanglingPointerIsUnreadable() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantDanglingGitPointer(in: fixture)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence")
#expect(git.isRepositoryUnreadable)
}
@Test("A SHA-256 repository takes the same path, by construction")
func aSHA256RepositoryIsUnreadable() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantSHA256Repository(in: fixture)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
// 06 Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the
// corrupt-repo loud-failure path never a silent fall to mode-none".
#expect(git.isRepositoryUnreadable)
}
@Test("Probing an unreadable repository touches nothing")
func theProbeIsARead() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let before = try snapshotGitDirectory(fixture.root)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.isRepositoryUnreadable)
// "Lanework leaves the repository untouched" the same bytes and the same mtimes, on the
// one path where a repair instinct would be most tempting.
#expect(try snapshotGitDirectory(fixture.root) == before)
}
@Test("The identity write is refused against a repository the app cannot open")
func identityWritesAreRefused() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let git = HistoryStore.compose(boardRoot: fixture.root)
await git.writeIdentity(name: "Ada", email: "[email protected]")
#expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open")
#expect(git.identityFailure == nil, "and nothing was attempted, so there is nothing to report")
}
/// The seam every git operation consults before it runs (`GitCommitOperation.reading`), asked
/// directly: one word is what holds the auto-commit flush, skips housekeeping, disables Undo/Redo
/// and the branch controls, and defers the interrupted-operation recovery.
@Test("The repository reading reports the pause every operation gates on")
func theReadingReportsThePause() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let reading = GitCommitOperation.reading(at: fixture.root)
#expect(reading.pause == .unreadable)
#expect(!reading.isUnborn)
#expect(!reading.isIndexLocked)
// Optional work simply does not happen (06 Repository hygiene: skipped under a pause).
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held))
// And the app never aborts its own leftover against a repository it cannot open the stamp
// is kept, not cleared, so the leftover stays recognizable as this app's.
let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: nil)
#expect(GitOperationRecovery.decide(stamp: stamp, pause: .unreadable) == .nothingToDo)
#expect(GitOperationRecovery.decide(stamp: stamp, pause: .merge) == .abort(stamp),
"every other pause still means the app's own leftover")
}
}
// MARK: - Add git
@MainActor
@Suite("HistoryStore ▸ add-git")
struct HistoryStoreAddGitTests {
@Test("Add-git initializes a repository at the board root and commits the whole tree")
func addGitInitializesAndCommits() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
#expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install")
#expect(git.mode == .git, "the one commanded mid-session flip")
let head = try #require(GitRepository.headCommit(at: fixture.root))
// "The root commit has its own subject" (06 Rules Abnormal repo states) never a folded
// diff-from-empty, because there is nothing to diff against and forty Adds would bury it.
#expect(head.subject == "Initial board state")
#expect(head.parentCount == 0, "it is the root commit")
// The whole tree, not a hand-picked set: the board, the lane, the card.
let tracked = GitRepository.trackedPaths(at: fixture.root)
#expect(tracked.contains("index.md"))
#expect(tracked.contains("\(Ident.lane1)/index.md"))
#expect(tracked.contains("\(Ident.lane1)/\(Ident.card1)/index.md"))
}
@Test("Add-git seeds a `.gitignore` into the initial commit")
func addGitSeedsTheIgnoreFile() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The one file the app ever writes into a board because of git, and the one moment it writes
// it (06 Repository hygiene). `RepositoryHygieneTests` carries the rest of the rule the
// untouched existing file, the `.DS_Store` that never enters history, adoption seeding
// nothing; here it is only the fact that add-git's tree includes it.
#expect(fixture.exists(".gitignore"))
#expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
}
@Test("The root commit is authored by the derived default when the repo names nobody")
func theRootCommitCarriesTheDerivedIdentity() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
let derived = GitIdentity.derivedDefault()
let head = try #require(GitRepository.headCommit(at: fixture.root))
#expect(head.authorName == derived.name)
#expect(head.authorEmail == derived.email)
}
@Test("The branch is deterministic, and the popover's display line reads it")
func theBranchIsMainAndReadable() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// Deterministic rather than inherited: libgit2's initial branch comes from an
// `init.defaultBranch` this app cannot read in the sandbox (`GitRepository.initialBranchName`).
#expect(git.branch == "main")
#expect(GitRepository.branchName(at: fixture.root) == "main")
await git.refreshBranch()
#expect(git.branch == "main")
}
@Test("An unborn HEAD still has a branch name — a repository with no commits is normal git mode")
func anUnbornHeadIsNormal() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// `git init` and nothing else: the shape an adopted repository can genuinely be in
// (06 Rules Abnormal repo states: "an unborn HEAD is normal git mode").
_ = try Repository.create(at: fixture.root)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
#expect(GitRepository.branchName(at: fixture.root) != nil)
#expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine")
}
@Test("Add-git refuses a board that already has a repository, and changes nothing")
func addGitRefusesAnAdoptedBoard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit())
let head = try #require(GitRepository.headCommit(at: fixture.root))
let second = HistoryStore.compose(boardRoot: fixture.root)
#expect(await second.addGit() == false, "there is nothing to add")
#expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized")
}
@Test("Add-git refuses a repo-nested board — no nested repository, ever")
func addGitRefusesANestedBoard() async throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try plantGitDirectory(in: outer)
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(await git.addGit() == false)
#expect(git.mode == .repoNested)
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
}
@Test("A refused add-git says why, in libgit2's words where it has any")
func aRefusalIsExplained() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
// Mode `git` is refused by `HistoryStore` before libgit2 is reached, so the failure the
// popover would show comes from the layer that *would* have run it.
let failure = GitRepository.create(at: fixture.root)
guard case .failure(let reason) = failure else {
Issue.record("initializing over an existing repository must be refused")
return
}
#expect(reason.operation == "Adding git to this board")
#expect(!reason.message.isEmpty)
}
@Test("Create re-runs full detection and refuses a board that became repo-nested")
func createRefusesAStaleModeNone() async throws {
// **The hardening** (06 Rules Detection, ruled 2026-07-31): "add-git's create re-runs full
// detection and refuses unless it reads clean none, so the forbidden nested init is impossible
// even on a raced or stale read."
let outer = try WriterFixture()
defer { outer.tearDown() }
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
// Composed while the enclosing folder is still a plain one: the store's mode is `none`, and
// that is the reading that goes stale.
let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(git.mode == .none)
// A terminal `git init` one level up, after the detection the store is holding.
try plantGitDirectory(in: outer)
#expect(await git.addGit() == false, "a root-only check would have let this through")
#expect(
!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
"no nested repository, ever"
)
#expect(git.mode == .none, "a refused add-git changes nothing, mode included")
}
@Test("Create refuses a board that a fresh detection reads unverifiable — a denied ancestor")
func createRefusesUnverifiable() throws {
// **The tightened guard** (06 Rules Detection): "only a genuinely clean `.none` reading
// proceeds" a stale `.none` that has since become unverifiable is refused exactly like one
// that has since become repo-nested (`createRefusesAStaleModeNone` above).
let outer = try WriterFixture()
defer { outer.tearDown() }
let blocked = outer.root.appendingPathComponent("blocked", isDirectory: true)
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable, "the fixture is set up correctly")
let failure = GitRepository.create(at: boardRoot)
guard case .failure(let reason) = failure else {
Issue.record("initializing where detection cannot rule out a repository must be refused")
return
}
#expect(reason.operation == "Adding git to this board")
#expect(!reason.message.isEmpty)
#expect(
!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
"no repository created on an unverifiable read"
)
}
@Test("A failure answers at the form when it is up, and at the banner when it is not")
@MainActor
func aFailureAnswersAtTheFormOrTheBanner() async throws {
// **Form-anchored operations answer at the form first** (06 Interaction with external
// writers, ruled 2026-07-31) "inline is the primary surface, never a silence trap".
let fixture = try makeBoard()
defer { fixture.tearDown() }
// Mode is read once, at composition so a store composed before a `.git` appeared still says
// `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the
// question is where the answer lands.
let git = HistoryStore.compose(boardRoot: fixture.root)
try plantGitDirectory(in: fixture)
var banners: [String] = []
git.reportFailure = { banners.append($0.message) }
// The form is up: inline, and nothing on the strip.
git.noteFormVisible(true)
#expect(await git.addGit() == false)
#expect(git.lastFailure != nil)
#expect(banners.isEmpty, "the user is looking at the form the answer belongs in")
// Dismissing it dismisses the stale error.
git.noteFormVisible(false)
#expect(git.lastFailure == nil)
// Asked again with no form on screen, the answer takes the banner instead of nobody.
#expect(await git.addGit() == false)
#expect(git.lastFailure == nil)
#expect(banners.count == 1)
}
}
// MARK: - The loader's history ranker
@MainActor
@Suite("HistoryStore ▸ the git-backed identity ranker")
struct GitPathHistoryTests {
@Test("A path that entered history earlier ranks lower; an untracked one has no rank")
func ranksFollowHistory() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// A second card, arriving in a later commit the whole point of the rung.
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try commitEverything(at: fixture.root, message: "Add card 'Second'")
// A third, on disk but never committed.
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
let ranker = try #require(git.identityHistoryRanker)
let first = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card1)"))
let second = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card2)"))
#expect(first < second, "lower is earlier")
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card3)") == nil, "untracked is no rank at all")
#expect(ranker.rank(Ident.lane1) == first, "a folder ranks with the first file that landed in it")
}
@Test("No ranker where the app manages no git")
func noRankerWithoutARepository() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let plain = HistoryStore.compose(boardRoot: fixture.root)
#expect(plain.identityHistoryRanker == nil, "mode none injects nothing")
// And the free tier has no `HistoryStore` to ask in the first place pinned in the
// composition suite above.
}
@Test("Git history decides a real duplicate-id collision through the loader")
func historyDecidesTheDuplicateWinner() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The same identity, arriving later in a second lane the copy the duplicate rule is about.
try fixture.item("\(Ident.lane2)/\(Ident.card1)", Item.rich(order: "1024", title: "First (copy)"))
try commitEverything(at: fixture.root, message: "Copy the card")
let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: git.identityHistoryRanker)
let duplicates: [DuplicateIdentity] = result.defects.compactMap {
if case .duplicateIdentity(let duplicate) = $0 { return duplicate }
return nil
}
let duplicate = try #require(duplicates.first)
#expect(duplicate.winner == "\(Ident.lane1)/\(Ident.card1)", "the path that entered history first")
#expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld")
}
}
-786
View File
@@ -1,786 +0,0 @@
import Foundation
import SwiftGitX
import Testing
import libgit2
@testable import Kanban
/// **Repository hygiene** (06-history-undo.md Repository hygiene) the behaviours that keep a
/// board's noise out of the way without ever rewriting anything: the `.gitignore` **every board**
/// carries (re-ruled 2026-07-31 the file outgrew git, so it is seeded at creation and healed in at
/// open, git or not), and the periodic repack that packs loose objects and touches nothing else.
///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2, and every assertion is read off the filesystem or out of the object database rather than
/// through a mock: a housekeeping bug corrupts repositories, so the only tests worth having are the
/// ones a corrupt repository would fail.
///
/// Nothing here shells out to `git`.
// MARK: - Fixtures
/// A board with one lane and one card enough for a tree with three `index.md`s in it.
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
return fixture
}
/// A commit made the way an external writer makes one SwiftGitX directly, so the objects under
/// test are ordinary git objects and not something the app's own path produced.
private func commitEverything(at boardRoot: URL, message: String) throws {
let repository = try Repository.open(at: boardRoot)
try repository.add(paths: [])
_ = try repository.commit(message: message)
}
/// Enough commits that the repository has a non-trivial pile of loose objects to pack.
private func churn(_ fixture: WriterFixture, commits: Int) throws {
for step in 1...commits {
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
Item.rich(order: "\(step)024", title: "Second, take \(step)")
)
try fixture.file("notes.txt", Data(String(repeating: "\(step)", count: 64).utf8))
try commitEverything(at: fixture.root, message: "Change \(step)")
}
}
// MARK: - Filesystem instruments
/// One entry under a subtree: path, bytes (nil for directories), and mtime `UntouchedGitTests`'
/// instrument. Bytes alone would pass a rewrite with identical content; the mtime is the assertion
/// that nothing opened the file for writing at all.
private struct SubtreeEntry: Equatable, CustomStringConvertible {
let path: String
let data: Data?
let modified: Date
var description: String {
"\(path) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))"
}
}
/// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `skip` prunes
/// whole branches how the working tree is snapshotted without `.git`, and `.git` without
/// `objects/`.
private func snapshot(
_ root: URL,
_ subtree: String,
skipping skip: Set<String> = []
) throws -> [SubtreeEntry] {
let base = subtree.isEmpty ? root : root.appendingPathComponent(subtree, isDirectory: true)
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: base.path) else { return [] }
var entries: [SubtreeEntry] = []
for case let relative as String in walker {
let head = relative.split(separator: "/").first.map(String.init) ?? relative
if skip.contains(head) {
walker.skipDescendants()
continue
}
let url = base.appendingPathComponent(relative)
let attributes = try manager.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else { continue }
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
entries.append(SubtreeEntry(
path: relative,
data: isDirectory ? nil : try Data(contentsOf: url),
modified: modified
))
}
return entries.sorted { $0.path < $1.path }
}
// MARK: - Object-database instruments
/// **Every object the repository can answer for**, loose and packed alike, by oid.
///
/// This is the "nothing was forgotten" instrument, and `git_odb_foreach` is the only honest way to
/// ask it: it enumerates the whole database through every backend, so a repack that packed some
/// objects and dropped others shows up as a set that shrank. Read through a repository opened
/// *after* the pass, so the answer comes from what is on disk rather than from a cached view of what
/// used to be.
private func everyObject(at boardRoot: URL) -> Set<String> {
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return [] }
defer { git_repository_free(repository) }
var database: OpaquePointer?
guard git_repository_odb(&database, repository) == 0, let database else { return [] }
defer { git_odb_free(database) }
var found = Set<String>()
withUnsafeMutablePointer(to: &found) { payload in
_ = git_odb_foreach(database, { oid, payload in
guard let oid, let payload else { return 0 }
var value = oid.pointee
var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
git_oid_fmt(&buffer, &value)
payload.assumingMemoryBound(to: Set<String>.self).pointee.insert(String(cString: buffer))
return 0
}, payload)
}
return found
}
/// The oid git would give a file's bytes as a blob `git hash-object`, computed rather than looked
/// up, so a test can ask "is *this content* still in the database" without walking a tree to find it.
private func blobOID(of data: Data) -> String? {
var oid = git_oid()
let status = data.withUnsafeBytes { buffer in
git_odb_hash(&oid, buffer.baseAddress, buffer.count, GIT_OBJECT_BLOB)
}
guard status == 0 else { return nil }
var value = oid
var text = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
git_oid_fmt(&text, &value)
return String(cString: text)
}
/// HEAD's first-parent ancestry, oldest last the history walk, as oids and subjects, so "identical"
/// means identical rather than "HEAD still resolves".
private func historyWalk(at boardRoot: URL) throws -> [String] {
let repository = try Repository.open(at: boardRoot)
guard var commit = try repository.HEAD.target as? Commit else { return [] }
var trail = ["\(commit.id.hex) \(commit.summary)"]
while let parent = try commit.parents.first {
commit = parent
trail.append("\(commit.id.hex) \(commit.summary)")
}
return trail
}
// MARK: - .gitignore seeding
/// **The add-git half.** Since 2026-07-31 the seed belongs to the *board* rather than to git (the
/// suite below this one), and what survives here is the last-chance check in front of the initial
/// commit: whatever else happened, the tree that becomes "Initial board state" carries a
/// `.gitignore`, because a `.DS_Store` that enters history can never be got out again (06 Deleting
/// never forgets).
@MainActor
@Suite("Repository hygiene ▸ the seeded .gitignore")
struct GitignoreSeedTests {
@Test("Add-git guarantees a .gitignore inside the initial commit")
func addGitSeedsTheIgnoreFile() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The file, and the whole of the file the one seed text, shared with board creation and
// the open-time heal (06 Repository hygiene: "`.DS_Store` plus the writer's temp pattern").
#expect(try fixture.data(".gitignore") == Data(BoardWriter.gitignoreSeed.utf8))
// **In "Initial board state", not after it.** Seeding after the commit would put the app's
// own file into the board's first *foreign* commit; seeding before makes it part of the
// board's beginning, which is what it is.
let head = try #require(GitRepository.headCommit(at: fixture.root))
#expect(head.subject == "Initial board state")
#expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
}
@Test("A .DS_Store already under the board never enters history at all")
func theSeedTakesEffectFromTheFirstCommit() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// What the Finder leaves behind: one per folder the user has looked at.
try fixture.file(".DS_Store", Data([0x00, 0x01, 0x42]))
try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x43]))
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// Not "removed from history later" never in it. The app has no history-rewriting operation
// and never will (06 Deleting never forgets), so the only moment this could be got right
// is the first one.
let tracked = GitRepository.trackedPaths(at: fixture.root)
#expect(!tracked.contains { $0.hasSuffix(".DS_Store") })
#expect(fixture.exists(".DS_Store"), "and the file itself is left exactly where it is")
}
@Test("A board that already has a .gitignore is left byte-for-byte alone")
func anExistingIgnoreFileIsUntouched() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let mine = Data("# mine\nbuild/\n*.tmp\n".utf8)
try fixture.file(".gitignore", mine)
let before = try snapshot(fixture.root, ".gitignore")
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// Not merged, not appended to, not reordered and not even opened for writing, which is
// what the mtime says (06: "the app never edits an existing one").
#expect(try fixture.data(".gitignore") == mine)
#expect(try snapshot(fixture.root, ".gitignore") == before)
#expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
}
@Test("The app never manages the file afterwards — commits and housekeeping leave it alone")
func theFileIsTheUsersFromThenOn() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The user edits it including deleting the line the app seeded, which is their business.
let theirs = Data("*.log\n".utf8)
try fixture.file(".gitignore", theirs)
let before = try snapshot(fixture.root, ".gitignore")
try churn(fixture, commits: 3)
_ = GitHousekeeping.run(at: fixture.root, threshold: 1)
await git.committer?.flushNow()
#expect(try fixture.data(".gitignore") == theirs, "nothing in the app re-seeds it")
#expect(try snapshot(fixture.root, ".gitignore") == before)
}
/// **The second consumer of the one noise definition** (01-storage-format.md § Fractal layout
/// Rules: "On Pro boards the same file governs the committer, so ignored noise neither relocates
/// nor commits one definition of noise, two consumers"). The committer's own condition is
/// `changedPaths`, which stages through libgit2 with ignores respected; this pins that the file
/// the loose-file gate reads is the file that decides what commits.
@Test("The committer obeys the same file — ignored noise never becomes a changed path")
func theCommitterObeysTheSameFile() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The user fine-tunes their own noise definition, which is exactly what the file is for.
try fixture.file(".gitignore", Data((BoardWriter.gitignoreSeed + "*.tmp\n").utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.tmp", Data("noise".utf8))
try fixture.file("\(Ident.lane1)/notes.txt", Data("a real stray".utf8))
try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x42]))
let changed = GitCommitOperation.changedPaths(at: fixture.root).map(\.path)
#expect(!changed.contains { $0.hasSuffix("scratch.tmp") })
#expect(!changed.contains { $0.hasSuffix(".DS_Store") })
#expect(changed.contains { $0.hasSuffix("notes.txt") }, "and an ordinary stray still commits")
}
/// Composing history over somebody else's repository writes nothing at all adoption is not an
/// init, and no *git* path seeds. (The board's own heal is what gives such a board its
/// `.gitignore`, at open, and it is exercised in the suite below.)
@Test("Adoption writes nothing — an adopted repository is somebody else's init")
func adoptionSeedsNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// `git init` run outside the app, exactly the shape a cloned or hand-inited board arrives in.
_ = try Repository.create(at: fixture.root)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
#expect(!fixture.exists(".gitignore"), "composing history is not a write")
}
/// A repo-nested board gets no *git* of the app's, so no git path can seed it and the
/// enclosing repository is never written into either. What such a board does get is the ordinary
/// board-level seed at open (06's "Repo-nested boards are seeded too"), which is the suite below.
@Test("The git paths never touch a repo-nested board, or its enclosing repo")
func repoNestedBoardsGetNothingFromGit() async throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(await git.addGit() == false)
#expect(git.mode == .repoNested)
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".gitignore").path))
#expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(".gitignore").path))
}
}
// MARK: - The .gitignore every board carries
/// **"`.gitignore` seeded on every board, never touched after"** (06-history-undo.md Repository
/// hygiene, re-ruled 2026-07-31 "the file outgrew git: it is the one noise definition the
/// loose-file relocation heal obeys so every board carries it, git or not").
///
/// Three claims, and they are the whole ruling: **creation writes it**, **a board missing it gains
/// it by scheduled heal at open**, and **the app never edits an existing one** an empty file
/// included, which is the ruling's own escape hatch. The gate it feeds is
/// `LooseFileRelocationTests` the noise gate; the pattern semantics are `GitignoreRulesTests`.
@MainActor
@Suite("Repository hygiene ▸ the .gitignore every board carries")
struct BoardGitignoreSeedTests {
private func seedURL(in fixture: WriterFixture) -> URL {
fixture.root.appendingPathComponent(IntegrityRules.gitignoreFileName)
}
private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else {
throw NSError(domain: "BoardGitignoreSeedTests", code: 1)
}
return (try Data(contentsOf: url), modified)
}
@Test("Board creation writes the seed beside index.md")
func creationSeeds() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = fixture.url("New Board.kanban")
try BoardWriter.createBoard(at: root, title: "New Board")
#expect(try Data(contentsOf: root.appendingPathComponent(IntegrityRules.gitignoreFileName))
== Data(BoardWriter.gitignoreSeed.utf8))
}
@Test("A board missing the file gains it at open, silently")
func healSeedsAtOpen() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
#expect(!fixture.exists(IntegrityRules.gitignoreFileName))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8))
// A courtesy file the user did not create and may not know exists the guide's posture.
#expect(store.banners.losses.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
/// The heal's memo, doing its two jobs: a picture already acted on is not acted on again (no
/// second write), and a picture that comes *back* a foreign deletion heals again, because the
/// memo was cleared on success.
@Test("Seeding twice writes once, and a deleted file comes back")
func memoIsArmedAndCleared() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let store = try BoardStore(rootURL: fixture.root)
store.seedGitignore()
let first = try stat(seedURL(in: fixture))
#expect(store.heals.memo(for: .missingGitignore) == nil, "cleared on success")
store.seedGitignore()
#expect(try stat(seedURL(in: fixture)) == first, "not rewritten — not even opened")
// What a foreign deletion looks like: the picture "missing" is restored, and a standing memo
// would have made that deletion the one thing this could not heal.
try FileManager.default.removeItem(at: seedURL(in: fixture))
store.seedGitignore()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8))
}
@Test("An existing .gitignore is left byte-for-byte alone, mtime included")
func existingFileIsNeverRewritten() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let theirs = Data("# mine\nbuild/\n*.tmp\n".utf8)
try fixture.file(IntegrityRules.gitignoreFileName, theirs)
let before = try stat(seedURL(in: fixture))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == theirs)
#expect(try stat(seedURL(in: fixture)) == before, "never merged, never appended to, never opened")
}
/// "The escape hatch for wanting no exclusions is an *empty* file, which the app honors and never
/// rewrites" the one case where re-seeding would look most reasonable and is most wrong.
@Test("An empty .gitignore is honored and never rewritten")
func emptyFileIsHonored() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file(IntegrityRules.gitignoreFileName, Data())
let before = try stat(seedURL(in: fixture))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data())
#expect(try stat(seedURL(in: fixture)) == before)
}
/// "Repo-nested boards are seeded too (re-ruling the old no-app-`.gitignore` posture): the file
/// serves the heal there, not any app-managed git" so there is no repo-detection gate on this
/// heal, and the enclosing repository is still never written into.
@Test("A repo-nested board is seeded like any other")
func repoNestedBoardsAreSeeded() throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
try Data(AgentGuide.content.utf8).write(to: boardRoot.appendingPathComponent(AgentGuide.filename))
let store = try BoardStore(rootURL: boardRoot)
store.runScheduledHeals()
#expect(try Data(contentsOf: boardRoot.appendingPathComponent(IntegrityRules.gitignoreFileName))
== Data(BoardWriter.gitignoreSeed.utf8))
#expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(IntegrityRules.gitignoreFileName).path))
}
/// **The claimed name that does not displace** (`IntegrityRules.claimedRootNames`): a wrong-kind
/// node wearing `.gitignore` is left exactly where it is, because a board with no readable noise
/// definition simply excludes nothing nothing breaks while the name is held, so nothing of the
/// user's is moved to buy a courtesy file.
@Test("A folder wearing the name is left alone, and nothing is written through it")
func squatterIsLeftAlone() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file("\(IntegrityRules.gitignoreFileName)/inside.txt", Data("mine".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data("\(IntegrityRules.gitignoreFileName)/inside.txt") == Data("mine".utf8))
#expect(store.banners.oneShots.isEmpty, "and no failure is reported for work nobody asked for")
#expect(store.banners.losses.isEmpty)
}
/// A board whose location cannot be written to defers rather than failing the engine's gate,
/// stated here because this heal runs at every open of every board and is the one most likely to
/// meet a read-only volume.
@Test("An unwritable board root is skipped silently")
func unwritableRootIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let store = try BoardStore(rootURL: fixture.root)
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.root.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) }
store.seedGitignore()
#expect(!fixture.exists(IntegrityRules.gitignoreFileName))
#expect(store.banners.oneShots.isEmpty)
#expect(store.heals.memo(for: .missingGitignore) == nil, "deferred, never remembered")
}
}
// MARK: - The housekeeping pass
@MainActor
@Suite("Repository hygiene ▸ periodic housekeeping")
struct GitHousekeepingTests {
@Test("A pass repacks loose objects and alters no commit, no ref, and no reachable content")
func repackingChangesNothing() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 4)
let looseBefore = GitHousekeeping.looseObjectCount(at: fixture.root)
#expect(looseBefore > 0, "the fixture has to have something to pack")
let objectsBefore = everyObject(at: fixture.root)
let walkBefore = try historyWalk(at: fixture.root)
let trackedBefore = GitRepository.trackedPaths(at: fixture.root)
let refsBefore = try snapshot(fixture.root, ".git/refs")
let headFileBefore = try snapshot(fixture.root, ".git/HEAD")
let workingTreeBefore = try snapshot(fixture.root, "", skipping: [".git"])
let outcome = GitHousekeeping.run(at: fixture.root, threshold: 1)
guard case let .repacked(repack) = outcome else {
Issue.record("expected a repack, got \(outcome)")
return
}
// It did something
#expect(repack.looseBefore == looseBefore)
#expect(repack.packedAway > 0)
#expect(repack.packedAway == repack.inserted, "every inserted object was proved and removed")
#expect(GitHousekeeping.looseObjectCount(at: fixture.root) < looseBefore)
// and it forgot nothing. Loose objects moved into a pack are the *same* objects: the whole
// database answers for exactly the set it answered for before (06 Repository hygiene:
// "it rewrites nothing").
#expect(everyObject(at: fixture.root) == objectsBefore)
// No commit, no ref, no reachable content.
#expect(try historyWalk(at: fixture.root) == walkBefore)
#expect(GitRepository.trackedPaths(at: fixture.root) == trackedBefore)
#expect(try snapshot(fixture.root, ".git/refs") == refsBefore)
#expect(try snapshot(fixture.root, ".git/HEAD") == headFileBefore)
// And the working tree never came into it housekeeping is a fact about `.git/objects` and
// nothing else.
#expect(try snapshot(fixture.root, "", skipping: [".git"]) == workingTreeBefore)
}
@Test("Every object is still readable after a pass, one oid at a time")
func everyObjectSurvives() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
let before = everyObject(at: fixture.root)
#expect(!before.isEmpty)
_ = GitHousekeeping.run(at: fixture.root, threshold: 1)
// The set comparison above is the same claim in aggregate; this is it per object, which is
// the shape a corruption bug would actually take one blob that went nowhere.
let after = everyObject(at: fixture.root)
for oid in before {
#expect(after.contains(oid), "object \(oid) stopped being readable")
}
}
@Test("Two passes in a row are stable — the second finds nothing left to do")
func aSecondPassIsANoOp() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
_ = GitHousekeeping.run(at: fixture.root, threshold: 1)
let objects = everyObject(at: fixture.root)
let loose = GitHousekeeping.looseObjectCount(at: fixture.root)
// Nothing re-packs what is already packed, so the second pass reads below any threshold the
// first one left it under and a repository that keeps being repacked would be growth, not
// hygiene.
#expect(GitHousekeeping.run(at: fixture.root, threshold: max(1, loose + 1)) == .belowThreshold(loose: loose))
#expect(everyObject(at: fixture.root) == objects)
}
@Test("Below the threshold, the pass reads the count and does nothing at all")
func belowThresholdTouchesNothing() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
let before = try snapshot(fixture.root, ".git")
let loose = GitHousekeeping.looseObjectCount(at: fixture.root)
#expect(loose > 0)
#expect(GitHousekeeping.run(at: fixture.root, threshold: loose + 1) == .belowThreshold(loose: loose))
#expect(try snapshot(fixture.root, ".git") == before, "a gate that closed wrote nothing")
}
@Test("The default threshold is git's own gc.auto, so an ordinary board is never repacked at open")
func theDefaultThresholdIsGitsOwn() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
#expect(GitHousekeeping.defaultLooseObjectThreshold == 6700)
let loose = GitHousekeeping.looseObjectCount(at: fixture.root)
#expect(GitHousekeeping.run(at: fixture.root) == .belowThreshold(loose: loose))
}
@Test("A paused repository is skipped in silence, and stays untouched")
func aPausedRepositoryIsSkipped() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
// The marker file libgit2's own `git_repository_state` reads an outside-the-app merge, in
// progress. The committer holds for this (06 Rules Abnormal repo states); optional work
// simply does not happen.
let head = try #require(GitRepository.headCommit(at: fixture.root))
try fixture.file(".git/MERGE_HEAD", Data("\(head.oid)\n".utf8))
let before = try snapshot(fixture.root, ".git")
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held))
#expect(try snapshot(fixture.root, ".git") == before)
}
@Test("A held index.lock is skipped in silence, and stays untouched")
func aHeldLockIsSkipped() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
// Another writer, mid-operation. The lock is never removed, whatever the app is doing
// it isn't the app's (06 Interaction with external writers).
try fixture.file(".git/index.lock", Data())
let before = try snapshot(fixture.root, ".git")
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.indexLocked))
#expect(try snapshot(fixture.root, ".git") == before)
#expect(fixture.exists(".git/index.lock"), "and the lock is still somebody else's")
}
@Test("Deleting a card leaves every prior commit touching its folder fully intact")
func deletingNeverForgets() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
let cardPath = "\(Ident.lane1)/\(Ident.card1)/index.md"
let birth = try #require(GitRepository.headCommit(at: fixture.root))
let content = try fixture.data(cardPath)
let contentOID = try #require(blobOID(of: content))
#expect(everyObject(at: fixture.root).contains(contentOID), "the card's bytes are in the repository")
// A delete, in both of its shapes: into `.trash/`, then gone for good.
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)")
try commitEverything(at: fixture.root, message: "Delete card 'First'")
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card1)"))
try commitEverything(at: fixture.root, message: "Permanently delete card 'First'")
// Off the live board, and out of the working tree
#expect(!GitRepository.trackedPaths(at: fixture.root).contains(cardPath))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
// and every version of its content still reachable in the repository, with the commit that
// introduced it exactly where it was. "Deleting never forgets" (06 Repository hygiene) is
// stated design, and there is no code path in the app that could take it back: nothing
// rewrites history, and housekeeping packs rather than prunes.
_ = GitHousekeeping.run(at: fixture.root, threshold: 1)
let objects = everyObject(at: fixture.root)
#expect(objects.contains(contentOID), "the deleted card's bytes are still in the object database")
#expect(objects.contains(birth.oid), "and so is the commit that introduced them")
#expect(try historyWalk(at: fixture.root).last == "\(birth.oid) \(birth.subject)")
}
@Test("A board with no repository is skipped, and no repository appears")
func aBoardWithNoRepositoryIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.noRepository))
#expect(!fixture.exists(".git"))
}
}
// MARK: - The scheduler
@MainActor
@Suite("Repository hygiene ▸ when housekeeping runs")
struct GitHousekeeperSchedulingTests {
@Test("A git-mode store composes a housekeeper; a mode-none one composes none")
func compositionFollowsMode() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let plain = HistoryStore.compose(boardRoot: fixture.root)
#expect(plain.housekeeper == nil, "no repository, nothing to maintain")
#expect(await plain.addGit())
#expect(plain.housekeeper != nil, "the mid-session flip maintains itself like any git board")
let reopened = HistoryStore.compose(boardRoot: fixture.root)
#expect(reopened.housekeeper != nil)
// The mode is the *whole* condition. A companion test used to sit beside this one pinning
// that the free tier maintained nothing anywhere structurally, since `compose` answered
// `nil` off Pro and PIVOT 2026-08-07 (12-editions.md) retired both the gate and the claim:
// there is no tier to compose under, so this suite's one axis is the one above.
}
@Test("A commit in flight defers the pass entirely — it is never retried")
func aCommitInFlightDefersThePass() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
let housekeeper = try #require(git.housekeeper)
housekeeper.threshold = 1
housekeeper.isCommitInFlight = { true }
let before = try snapshot(fixture.root, ".git")
await housekeeper.runNow()
#expect(housekeeper.lastOutcome == nil, "it did not run, and recorded no verdict")
#expect(try snapshot(fixture.root, ".git") == before)
// And with the engine quiet it runs the same pass, one board-open later.
housekeeper.isCommitInFlight = { false }
await housekeeper.runNow()
guard case .repacked = housekeeper.lastOutcome else {
Issue.record("expected a repack once the committer was quiet, got \(String(describing: housekeeper.lastOutcome))")
return
}
}
@Test("Activating auto-commit arms the pass, and teardown cancels it")
func activationArmsAndTeardownCancels() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit())
try churn(fixture, commits: 3)
let git = HistoryStore.compose(boardRoot: fixture.root)
let housekeeper = try #require(git.housekeeper)
housekeeper.threshold = 1
housekeeper.delay = .milliseconds(20)
git.committer?.debounceInterval = .seconds(60)
git.activateAutoCommit { _ in }
try await Task.sleep(for: .milliseconds(400))
guard case .repacked = housekeeper.lastOutcome else {
Issue.record("board open arms one pass, got \(String(describing: housekeeper.lastOutcome))")
return
}
// Teardown cancels an armed one, so a closed board's maintenance cannot fire against a store
// that has gone.
let second = HistoryStore.compose(boardRoot: fixture.root)
let secondKeeper = try #require(second.housekeeper)
secondKeeper.threshold = 1
secondKeeper.delay = .milliseconds(200)
second.activateAutoCommit { _ in }
second.stopAutoCommit()
try await Task.sleep(for: .milliseconds(500))
#expect(secondKeeper.lastOutcome == nil)
}
@Test("The wired gate is the committer's own in-flight flag")
func theGateIsTheCommittersOwnFlag() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
let housekeeper = try #require(git.housekeeper)
let committer = try #require(git.committer)
#expect(committer.isCommitInFlight == false, "a quiet engine")
#expect(housekeeper.isCommitInFlight?() == false, "and the housekeeper reads it, not a copy")
}
}