Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -4,9 +4,10 @@ import Testing
|
||||
import libgit2
|
||||
@testable import Kanban
|
||||
|
||||
/// **Repository hygiene** (06-history-undo.md ▸ Repository hygiene) — the two behaviours that keep a
|
||||
/// git board's `.git` sane without ever rewriting anything: the `.gitignore` seeded once at init, and
|
||||
/// the periodic repack that packs loose objects and touches nothing else.
|
||||
/// **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
|
||||
@@ -154,11 +155,16 @@ private func historyWalk(at boardRoot: URL) throws -> [String] {
|
||||
|
||||
// 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 seeds a .gitignore containing .DS_Store, inside the initial commit")
|
||||
@Test("Add-git guarantees a .gitignore inside the initial commit")
|
||||
func addGitSeedsTheIgnoreFile() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -166,9 +172,9 @@ struct GitignoreSeedTests {
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await git.addGit())
|
||||
|
||||
// The file, and the whole of the file: one line, because one line is the rule
|
||||
// (06 ▸ Repository hygiene: "a minimal `.gitignore` (`.DS_Store`)").
|
||||
#expect(try fixture.data(".gitignore") == Data(".DS_Store\n".utf8))
|
||||
// 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
|
||||
@@ -236,7 +242,34 @@ struct GitignoreSeedTests {
|
||||
#expect(try snapshot(fixture.root, ".gitignore") == before)
|
||||
}
|
||||
|
||||
@Test("Adoption seeds nothing — an adopted repository is somebody else's init")
|
||||
/// **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 = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#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() }
|
||||
@@ -245,11 +278,14 @@ struct GitignoreSeedTests {
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.mode == .git)
|
||||
#expect(!fixture.exists(".gitignore"), "the seed belongs to the app's own init and nowhere else")
|
||||
#expect(!fixture.exists(".gitignore"), "composing history is not a write")
|
||||
}
|
||||
|
||||
@Test("A repo-nested board gets no seed, because it gets no app-managed git")
|
||||
func repoNestedBoardsGetNothing() async throws {
|
||||
/// 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))
|
||||
@@ -266,6 +302,178 @@ struct GitignoreSeedTests {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user