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 /// the ruling's whole premise is one definition of noise shared with the committer: "on Pro boards /// the same file governs the committer, so ignored noise neither relocates nor commits". A matcher /// that read the file differently from libgit2 would make that one sentence two behaviours. /// /// 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")) } }