Step 7 of strategy/01-git-excision.md, the companions. The agent guide bumps to v11: the Git section teaches repo-resident etiquette alone (stage only your own paths, commit your own changes, leave app-maintained files to the app) — existing boards heal to the new text on next open. README re-anchors: the four git feature bullets out, tiers say the complete Mac experience is free, and one bullet states the format's git-friendliness promise. The changelog drops the never-shipped git entries. DESIGN re-rules: 06 retired with Undo routing migrated to 13 (now the sole substrate's doc, seam kept open), 07 retired as written pending the ops-service workstream, 14 retired as superseded record, 12 carries the second pivot note, the index reflects all of it; the charter gets a pointer note (the anchors' full re-ruling stays with the user). InertGitTests renames to GitAgnosticStorageTests — the excision restores its original claim app-wide. And the sweep: ~70 comment sites across 36 files adjudicated against the keeper list, every present-tense description of the excised machinery made past tense or repointed, keepers untouched. 2,707 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
346 lines
14 KiB
Swift
346 lines
14 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// **The board's noise definition, as a parser** (01-storage-format.md § Fractal layout ▸ Rules,
|
|
/// ruled 2026-07-31: "`.gitignore` is the noise gate"; 06-history-undo.md ▸ Repository hygiene).
|
|
///
|
|
/// `GitignoreRules` is pure — text in, verdicts out — so this suite is a table and nothing else: no
|
|
/// filesystem, no board, no store. What it pins is that the app's own matcher is **git's**, because
|
|
/// `gitignore(5)` is the format's own semantics and a file a user may also be handing to their own
|
|
/// git — a matcher that read it differently from git itself would make the file lie to one reader or
|
|
/// the other. (Once doubly true while a committer shared this definition too — "on Pro boards the
|
|
/// same file governs the committer, so ignored noise neither relocates nor commits" — before
|
|
/// app-managed git was excised entirely, `strategy/01-git-excision.md`, 2026-08-08; libgit2 is gone,
|
|
/// but the format's own semantics remain the standard regardless.)
|
|
///
|
|
/// The claims are grouped as `gitignore(5)` states them, in its order, plus the seed the app writes
|
|
/// and the three deliberate divergences the type documents.
|
|
|
|
/// One assertion, spelled the way the file reads: patterns on the left, a path on the right.
|
|
private func ignores(_ file: String, _ path: String, isDirectory: Bool = false) -> Bool {
|
|
GitignoreRules(parsing: file).isIgnored(relativePath: path, isDirectory: isDirectory)
|
|
}
|
|
|
|
// MARK: - What is a pattern at all
|
|
|
|
@Suite("Gitignore ▸ the line grammar")
|
|
struct GitignoreLineGrammarTests {
|
|
|
|
@Test("Blank lines and comments match nothing")
|
|
func blanksAndComments() {
|
|
let file = """
|
|
# a comment
|
|
|
|
#notes.txt
|
|
"""
|
|
let rules = GitignoreRules(parsing: file)
|
|
#expect(rules.isEmpty)
|
|
#expect(!rules.isIgnored(relativePath: "notes.txt"))
|
|
#expect(!rules.isIgnored(relativePath: "# a comment"))
|
|
}
|
|
|
|
/// An empty file is 06's own escape hatch — "the escape hatch for wanting no exclusions is an
|
|
/// *empty* file, which the app honors" — and it must read as "excludes nothing", never as
|
|
/// "excludes everything".
|
|
@Test("An empty file excludes nothing, and says so")
|
|
func emptyFile() {
|
|
#expect(GitignoreRules(parsing: "").isEmpty)
|
|
#expect(!ignores("", "notes.txt"))
|
|
#expect(!ignores("", "lane/card/notes.txt"))
|
|
#expect(!ignores("\n\n\n", "notes.txt"))
|
|
}
|
|
|
|
/// `#` only comments when it *begins* the line, and `\#` writes a pattern that starts with one.
|
|
@Test("A hash is escapable, and only leading hashes comment")
|
|
func escapedHash() {
|
|
#expect(ignores("\\#notes.txt", "#notes.txt"))
|
|
#expect(!ignores("\\#notes.txt", "notes.txt"))
|
|
#expect(ignores("notes#1.txt", "notes#1.txt"))
|
|
}
|
|
|
|
/// "Trailing spaces are ignored unless they are quoted with backslash."
|
|
@Test("Trailing spaces are dropped unless escaped")
|
|
func trailingSpaces() {
|
|
#expect(ignores("notes.txt ", "notes.txt"))
|
|
// The escaped one is part of the name — so the bare name no longer matches, and the
|
|
// space-suffixed one does.
|
|
#expect(ignores("notes.txt\\ ", "notes.txt "))
|
|
#expect(!ignores("notes.txt\\ ", "notes.txt"))
|
|
// Only spaces, and only trailing: a tab is part of the pattern (git trims spaces alone).
|
|
#expect(ignores("notes.txt\t", "notes.txt\t"))
|
|
}
|
|
|
|
/// A `.gitignore` hand-edited on Windows must not become a file of patterns nobody can match —
|
|
/// git terminates each pattern before the `\r`, and so does this.
|
|
@Test("CRLF line endings parse, and a BOM is skipped")
|
|
func lineEndingsAndBOM() {
|
|
#expect(ignores("*.tmp\r\nbuild/\r\n", "scratch.tmp"))
|
|
#expect(ignores("\u{FEFF}*.tmp\n", "scratch.tmp"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Anchoring
|
|
|
|
@Suite("Gitignore ▸ anchoring")
|
|
struct GitignoreAnchoringTests {
|
|
|
|
/// "If there is no separator … the pattern may also match at any level below" — which is the
|
|
/// property the seed leans on entirely: one `.DS_Store` line covers every folder in the board.
|
|
@Test("A separator-less pattern matches at every depth")
|
|
func unanchoredMatchesEverywhere() {
|
|
let file = ".DS_Store\n"
|
|
#expect(ignores(file, ".DS_Store"))
|
|
#expect(ignores(file, "lane/.DS_Store"))
|
|
#expect(ignores(file, "lane/card/.DS_Store"))
|
|
#expect(!ignores(file, "lane/card/DS_Store"))
|
|
#expect(ignores(file, "lane/.DS_Store/inside.txt"), "and a directory by that name takes everything with it")
|
|
}
|
|
|
|
@Test("A leading slash anchors to the board root")
|
|
func leadingSlashAnchors() {
|
|
let file = "/notes.txt\n"
|
|
#expect(ignores(file, "notes.txt"))
|
|
#expect(!ignores(file, "lane/notes.txt"))
|
|
#expect(!ignores(file, "lane/card/notes.txt"))
|
|
}
|
|
|
|
/// "If there is a separator at the beginning or middle (or both) … the pattern is relative to
|
|
/// the directory level of the particular `.gitignore` file itself" — which for a board is its
|
|
/// root, and the reason the loose-file gate matches the **board-relative** path.
|
|
@Test("An interior slash anchors too")
|
|
func interiorSlashAnchors() {
|
|
let file = "lane/notes.txt\n"
|
|
#expect(ignores(file, "lane/notes.txt"))
|
|
#expect(!ignores(file, "other/lane/notes.txt"))
|
|
#expect(!ignores(file, "notes.txt"))
|
|
}
|
|
|
|
/// A *trailing* separator is the directory marker and does not anchor: `build/` still means "any
|
|
/// folder called build, anywhere".
|
|
@Test("A trailing slash does not anchor")
|
|
func trailingSlashDoesNotAnchor() {
|
|
let file = "build/\n"
|
|
#expect(ignores(file, "build", isDirectory: true))
|
|
#expect(ignores(file, "lane/card/build", isDirectory: true))
|
|
#expect(ignores(file, "lane/card/build/output.o"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Directory-only patterns
|
|
|
|
@Suite("Gitignore ▸ directory-only patterns")
|
|
struct GitignoreDirectoryTests {
|
|
|
|
@Test("A trailing slash matches only directories")
|
|
func directoryOnly() {
|
|
let file = "cache/\n"
|
|
#expect(ignores(file, "cache", isDirectory: true))
|
|
#expect(!ignores(file, "cache", isDirectory: false), "a *file* called cache is not what the pattern is about")
|
|
// …and everything inside the directory rides along.
|
|
#expect(ignores(file, "cache/thing.bin"))
|
|
}
|
|
|
|
@Test("Without the slash, files and directories both match")
|
|
func withoutTheSlash() {
|
|
#expect(ignores("cache\n", "cache", isDirectory: false))
|
|
#expect(ignores("cache\n", "cache", isDirectory: true))
|
|
}
|
|
}
|
|
|
|
// MARK: - Negation and last-match-wins
|
|
|
|
@Suite("Gitignore ▸ negation and precedence")
|
|
struct GitignoreNegationTests {
|
|
|
|
@Test("The last matching pattern decides")
|
|
func lastMatchWins() {
|
|
#expect(!ignores("*.txt\n!notes.txt\n", "notes.txt"))
|
|
#expect(ignores("!notes.txt\n*.txt\n", "notes.txt"), "order is the whole of the rule")
|
|
#expect(ignores("*.txt\n!notes.txt\n*.txt\n", "notes.txt"))
|
|
#expect(ignores("*.txt\n!notes.txt\n", "other.txt"))
|
|
}
|
|
|
|
/// "It is not possible to re-include a file if a parent directory of that file is excluded" —
|
|
/// git never descends into an ignored directory, so the rule that would have rescued the file is
|
|
/// never read at all.
|
|
@Test("A negation cannot reach inside an excluded directory")
|
|
func negationCannotEscapeAnExcludedParent() {
|
|
let file = "build/\n!build/keep.txt\n"
|
|
#expect(ignores(file, "build/keep.txt"))
|
|
#expect(ignores(file, "build/deep/keep.txt"))
|
|
}
|
|
|
|
/// A directory the file re-includes is not excluded, so its contents are reachable again.
|
|
@Test("A re-included directory lets its contents through")
|
|
func reIncludedDirectory() {
|
|
let file = "lane\n!lane\n"
|
|
#expect(!ignores(file, "lane/card/notes.txt"))
|
|
}
|
|
|
|
@Test("A leading bang is escapable")
|
|
func escapedBang() {
|
|
#expect(ignores("\\!important.txt\n", "!important.txt"))
|
|
#expect(!ignores("\\!important.txt\n", "important.txt"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Wildcards
|
|
|
|
@Suite("Gitignore ▸ wildcards")
|
|
struct GitignoreWildcardTests {
|
|
|
|
@Test("A star matches any run of characters but never a separator")
|
|
func starDoesNotCrossSeparators() {
|
|
#expect(ignores("*.tmp\n", "scratch.tmp"))
|
|
#expect(ignores("*.tmp\n", "lane/card/scratch.tmp"))
|
|
#expect(ignores("lane/*.tmp\n", "lane/scratch.tmp"))
|
|
#expect(!ignores("lane/*.tmp\n", "lane/card/scratch.tmp"), "one star, one segment")
|
|
#expect(ignores("*\n", "anything"))
|
|
}
|
|
|
|
@Test("A star matches nothing at all, at either end")
|
|
func starMatchesEmpty() {
|
|
#expect(ignores("*.tmp\n", ".tmp"))
|
|
#expect(ignores("notes*\n", "notes"))
|
|
#expect(ignores("*notes*\n", "notes"))
|
|
}
|
|
|
|
@Test("A question mark is exactly one character, and never a separator")
|
|
func questionMark() {
|
|
#expect(ignores("shot?.png\n", "shot1.png"))
|
|
#expect(!ignores("shot?.png\n", "shot.png"))
|
|
#expect(!ignores("shot?.png\n", "shot10.png"))
|
|
#expect(!ignores("a?b\n", "a/b"))
|
|
}
|
|
|
|
@Test("Character classes: sets, ranges, negation, and a literal bracket")
|
|
func characterClasses() {
|
|
#expect(ignores("shot[0-9].png\n", "shot7.png"))
|
|
#expect(!ignores("shot[0-9].png\n", "shotX.png"))
|
|
#expect(ignores("shot[abc].png\n", "shotb.png"))
|
|
#expect(!ignores("shot[!abc].png\n", "shotb.png"))
|
|
#expect(ignores("shot[!abc].png\n", "shotz.png"))
|
|
#expect(ignores("shot[^abc].png\n", "shotz.png"), "^ negates too")
|
|
// A `]` first in the group is a literal member, not the terminator.
|
|
#expect(ignores("weird[]].txt\n", "weird].txt"))
|
|
// An unterminated group is a literal bracket — the reading that cannot lose a character.
|
|
#expect(ignores("draft[1.txt\n", "draft[1.txt"))
|
|
}
|
|
|
|
@Test("Escapes make a wildcard literal")
|
|
func escapedWildcards() {
|
|
#expect(ignores("star\\*.txt\n", "star*.txt"))
|
|
#expect(!ignores("star\\*.txt\n", "starry.txt"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Globstar
|
|
|
|
@Suite("Gitignore ▸ ** segments")
|
|
struct GitignoreGlobstarTests {
|
|
|
|
@Test("A leading **/ matches at any depth")
|
|
func leadingGlobstar() {
|
|
let file = "**/notes.txt\n"
|
|
#expect(ignores(file, "notes.txt"))
|
|
#expect(ignores(file, "lane/notes.txt"))
|
|
#expect(ignores(file, "lane/card/notes.txt"))
|
|
}
|
|
|
|
/// "A trailing `/**` matches everything inside" — everything *inside*, so the directory itself is
|
|
/// not what this pattern is about.
|
|
@Test("A trailing /** matches everything inside, not the folder itself")
|
|
func trailingGlobstar() {
|
|
let file = "lane/**\n"
|
|
#expect(ignores(file, "lane/card"))
|
|
#expect(ignores(file, "lane/card/notes.txt"))
|
|
#expect(!ignores(file, "lane", isDirectory: true))
|
|
}
|
|
|
|
/// "`a/**/b` matches `a/b`, `a/x/b`, `a/x/y/b`" — zero or more segments, verbatim.
|
|
@Test("A middle /**/ spans zero or more directories")
|
|
func middleGlobstar() {
|
|
let file = "a/**/b\n"
|
|
#expect(ignores(file, "a/b"))
|
|
#expect(ignores(file, "a/x/b"))
|
|
#expect(ignores(file, "a/x/y/b"))
|
|
#expect(!ignores(file, "b"))
|
|
#expect(!ignores(file, "x/a/b"))
|
|
}
|
|
|
|
/// "Other consecutive asterisks are considered regular asterisks" — inside a segment, `**` is
|
|
/// just `*`, so it still cannot cross a separator.
|
|
@Test("Asterisks inside a segment are ordinary stars")
|
|
func consecutiveStarsInsideASegment() {
|
|
#expect(ignores("a**b\n", "axxb"))
|
|
#expect(!ignores("a**b\n", "a/x/b"))
|
|
}
|
|
}
|
|
|
|
// MARK: - The seed
|
|
|
|
@Suite("Gitignore ▸ the seed the app writes")
|
|
struct GitignoreSeedSemanticsTests {
|
|
|
|
private let seed = GitignoreRules(parsing: BoardWriter.gitignoreSeed)
|
|
|
|
@Test("The seed is exactly the two lines the ruling names")
|
|
func theSeedText() {
|
|
// 06-history-undo.md ▸ Repository hygiene: "`.DS_Store` plus the writer's temp pattern
|
|
// (`.*.lanework-*`)". Files the app creates end with LF.
|
|
#expect(BoardWriter.gitignoreSeed == ".DS_Store\n.*.lanework-*\n")
|
|
}
|
|
|
|
@Test("It covers the Finder's litter at every level")
|
|
func finderLitter() {
|
|
#expect(seed.isIgnored(relativePath: ".DS_Store"))
|
|
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/.DS_Store"))
|
|
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/.DS_Store"))
|
|
}
|
|
|
|
/// The second line and `BoardWriter.atomicReplace`'s temp name are one fact spelled twice, so
|
|
/// this asserts against a name the Writer's own rule produces rather than a hand-written one.
|
|
@Test("It covers a crashed write's residue")
|
|
func writerTempResidue() {
|
|
let residue = ".index.md.lanework-\(UUID().uuidString)"
|
|
#expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/\(residue)"))
|
|
#expect(seed.isIgnored(relativePath: ".\(IntegrityRules.gitignoreFileName).lanework-\(UUID().uuidString)"))
|
|
}
|
|
|
|
@Test("And nothing else — a card's real files are not noise")
|
|
func nothingElse() {
|
|
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/notes.txt"))
|
|
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/index.md"))
|
|
#expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/DS_Store.txt"))
|
|
#expect(!seed.isIgnored(relativePath: "CLAUDE.md"))
|
|
}
|
|
}
|
|
|
|
// MARK: - The documented divergences
|
|
|
|
@Suite("Gitignore ▸ the deliberate divergences")
|
|
struct GitignoreDivergenceTests {
|
|
|
|
/// Matching is case-sensitive, always: `core.ignorecase` is a repository setting on a file this
|
|
/// app reads on boards that have no repository, and folding a *pattern* would silently widen
|
|
/// what the user wrote.
|
|
@Test("Patterns are case-sensitive")
|
|
func caseSensitive() {
|
|
#expect(ignores("*.tmp\n", "scratch.tmp"))
|
|
#expect(!ignores("*.tmp\n", "scratch.TMP"))
|
|
#expect(!ignores(".DS_Store\n", ".ds_store"))
|
|
}
|
|
|
|
/// POSIX bracket expressions are not a grammar this matcher has: `[[:digit:]]` reads as the
|
|
/// ordinary group `[` `[:digt]` — the characters between the brackets — followed by a literal
|
|
/// `]`, so it matches `shot:].png` rather than `shot7.png`. Nothing realistic in a board's noise
|
|
/// file writes one, and a second character-class grammar to hold them would be exactly the
|
|
/// over-engineering the type exists to avoid.
|
|
@Test("POSIX bracket expressions are read as ordinary classes")
|
|
func posixClasses() {
|
|
#expect(ignores("shot[[:digit:]].png\n", "shot:].png"))
|
|
#expect(!ignores("shot[[:digit:]].png\n", "shot7.png"))
|
|
}
|
|
}
|