import Foundation /// **The board's noise definition, parsed** — a board-root `.gitignore` read as git itself reads /// one, and asked one question: is this board-relative path ignored? /// /// The file outgrew git on 2026-07-31 (01-storage-format.md § Fractal layout ▸ Rules, "`.gitignore` /// is the noise gate"; 06-history-undo.md ▸ Repository hygiene): it is now the one definition of /// noise the **loose-file relocation heal** obeys, on every board, git or not. A file matching it /// keeps the ordinary stray posture — "skipped, preserved verbatim, logged, never relocated, never /// announced" — and "the exclusion list is exactly that file, nothing hardcoded". On a Pro board the /// same file governs the committer through libgit2, so ignored noise neither relocates nor commits: /// one definition, two consumers. This type is the app's half of that, because the load path cannot /// reach for libgit2 — the free tier opens a board without so much as a `fileExists` under `.git` /// (12-editions.md ▸ The free tier and `.git`), and the heal runs on boards that have no repository /// at all. /// /// **Pure: no I/O, no filesystem, no `URL`.** It parses text and answers about paths. Reading the /// bytes is `BoardLoader.ignoreRules(atBoardRoot:)`' job, once per walk — the loader stays a pure /// function of the tree, and this stays a pure function of the file. /// /// ## The semantics, which are git's /// /// Implemented from `gitignore(5)`, in its own order: /// /// - A **blank line** matches nothing; it exists to space the file out. /// - A line beginning with `#` is a **comment**. `\#` begins a pattern with a literal `#`. /// - **Trailing spaces are ignored** unless escaped (`\ `). /// - A leading `!` **negates**, re-including a path an earlier pattern excluded. `\!` is a literal. /// - A trailing `/` makes the pattern **directory-only**. /// - A `/` at the beginning or in the middle **anchors** the pattern to the board root; a pattern /// with no interior separator matches **at any depth** (`.DS_Store` matches every one of them). /// - `*` matches any run of characters but never `/`; `?` matches exactly one non-`/` character; /// `[…]` is a character class, negatable with `!` or `^` and carrying ranges (`[0-9]`). /// - A path segment that is exactly `**` matches **zero or more** segments: leading `**/` matches at /// any depth, a trailing `/**` matches everything inside, and `a/**/b` matches `a/b` as well as /// `a/x/y/b`. Asterisks anywhere else are ordinary `*`s, exactly as git says. /// - **Last match wins.** The verdict is the last pattern in file order that matched, negations /// included — which is why the rules are kept in file order and evaluated in it. /// - **An ignored directory is final.** A path under an excluded directory is excluded whatever a /// later negation says about it — git does not descend into an ignored directory, so a rule about /// something inside it is never consulted. `isIgnored(relativePath:isDirectory:)` walks the /// ancestors for exactly this reason. /// /// ## The deliberate divergences, and they are three /// /// - **Only this file.** Nested `.gitignore` files are never consulted, `.git/info/exclude` and the /// user's global excludes are never read, and `core.excludesFile` does not exist here. That is the /// ruling's own wording — "nested `.gitignore` files are ordinary strays the heal never consults" /// — and it is what makes the noise definition something the user can see in one place. /// - **Case-sensitive matching**, always. Git's is `core.ignorecase`'s to decide, which is a /// repository setting on a file this app reads on boards that have no repository. The board's own /// name comparisons go the other way (`IntegrityRules.reservedCardChildNames` is lowercased /// "because the filesystem this runs on usually is"), but a *pattern* is the user's text and /// folding it would silently widen what they wrote. /// - **No POSIX bracket expressions** (`[[:digit:]]`): a `[` that does not close is a literal `[`, /// and `[:digit:]` reads as the ordinary class it looks like. Nothing realistic in a board's noise /// file uses them, and inventing a second character-class grammar to hold them would be the /// over-engineering this type exists to avoid. public struct GitignoreRules: Sendable, Equatable { /// The file's patterns, **in file order** — which is the whole of last-match-wins. private let patterns: [Pattern] /// Parses a whole `.gitignore` body. /// /// **An empty file parses to no patterns and ignores nothing** — the escape hatch 06 names by /// hand ("the escape hatch for wanting no exclusions is an *empty* file, which the app honors and /// never rewrites"), and the same answer a board carrying no `.gitignore` at all gets. The two /// are deliberately indistinguishable to every consumer: one of them is a statement and the other /// is a silence, but neither excludes anything. /// /// Lines are split on **any** newline (`Character.isNewline`), which is git's own reading of a /// CRLF file — `dir.c` terminates each pattern before the `\r` — arrived at from the other /// direction: Swift treats `\r\n` as a single grapheme, so splitting on newline-ness drops the /// carriage return by construction rather than by trimming it afterwards. A board hand-edited on /// Windows must not end up carrying patterns nobody can match. A leading UTF-8 BOM is skipped for /// the reason git skips one: it is a byte-order mark, not the first character of a pattern. public init(parsing text: String) { var body = Substring(text) if body.hasPrefix("\u{FEFF}") { body = body.dropFirst() } patterns = body.split(whereSeparator: \.isNewline).compactMap(Pattern.init(line:)) } /// Whether the file said anything at all. `true` for a board with no `.gitignore` and for one /// whose `.gitignore` is empty or all comments — the honored-empty state. public var isEmpty: Bool { patterns.isEmpty } /// Whether `relativePath` — **board-relative**, `/`-separated, no leading slash (`//notes.txt`) /// — is ignored by this file. /// /// `isDirectory` decides the directory-only patterns (`build/`) and nothing else. It describes /// what is on disk at that path, which is the caller's to know: the loose-file gate always passes /// `false`, because the carve-out is exactly *files*. /// /// **Ancestors are consulted first.** A path inside an excluded directory is excluded, and no /// negation reaches it — git never descends into an ignored directory, so it never sees the rule /// that would have re-included the file. Everything below `build/` is ignored even where the file /// goes on to say `!build/keep.txt`. public func isIgnored(relativePath: String, isDirectory: Bool = false) -> Bool { guard !patterns.isEmpty else { return false } let segments = relativePath.split(separator: "/").map(String.init) guard !segments.isEmpty else { return false } for depth in 1 ..< segments.count where verdict(for: Array(segments.prefix(depth)), isDirectory: true) == true { return true } return verdict(for: segments, isDirectory: isDirectory) == true } /// The last-match-wins verdict for one path: `true` ignored, `false` explicitly re-included, /// `nil` matched by nothing. /// /// `nil` and `false` behave identically at every call site today; they are kept apart because the /// file's grammar keeps them apart — "this was never mentioned" and "this was mentioned and then /// taken back" are different statements, and a future consumer (a listing that shows *why*) would /// need the difference the moment it existed. private func verdict(for segments: [String], isDirectory: Bool) -> Bool? { var answer: Bool? for pattern in patterns { guard !pattern.directoryOnly || isDirectory else { continue } guard pattern.matches(segments) else { continue } answer = !pattern.isNegated } return answer } } // MARK: - One pattern extension GitignoreRules { /// One line of the file, compiled: what it matches, whether it re-includes, and whether it only /// speaks about directories. /// /// **Anchoring is baked into `segments`** rather than carried as a flag: an unanchored pattern is /// exactly its anchored self with a leading `**`, which is what `gitignore(5)` says in prose /// ("the pattern may also match at any level below") and what removes a branch from the matcher. fileprivate struct Pattern: Sendable, Equatable { let segments: [Segment] let isNegated: Bool let directoryOnly: Bool /// `nil` for a line that is not a pattern at all — blank, or a comment. init?(line: Substring) { var text = Self.trimmingTrailingSpaces(line) guard !text.isEmpty, text.first != "#" else { return nil } if text.first == "!" { isNegated = true text = text.dropFirst() } else { isNegated = false } // The trailing separator is the directory-only marker, and it is *not* an interior // separator for the anchoring question below: `foo/` matches a directory named `foo` at // any depth, while `a/b/` is anchored. if text.hasSuffix("/") { directoryOnly = true text = text.dropLast() } else { directoryOnly = false } guard !text.isEmpty else { return nil } let anchored = text.contains("/") let parsed = text .split(separator: "/", omittingEmptySubsequences: true) .map(Segment.init(text:)) guard !parsed.isEmpty else { return nil } segments = anchored ? parsed : [.globstar] + parsed } /// Whether this pattern matches the whole of `path`. func matches(_ path: [String]) -> Bool { Self.match(segments[...], path[...]) } /// The segment walk, with `**`'s zero-or-more the only place it backtracks. private static func match(_ pattern: ArraySlice, _ path: ArraySlice) -> Bool { guard let head = pattern.first else { return path.isEmpty } let tail = pattern.dropFirst() if case .globstar = head { // **A trailing `**` matches everything *inside*** (`gitignore(5)`), so it needs a // segment to consume: `a/**` matches `a/b`, never the bare `a`. Where `a` itself is // ignored, some other pattern said so. guard !tail.isEmpty else { return !path.isEmpty } var remaining = path while true { if match(tail, remaining) { return true } guard !remaining.isEmpty else { return false } remaining = remaining.dropFirst() } } guard case let .literalOrWildcards(tokens) = head, let name = path.first, Segment.match(tokens[...], Array(name)[...]) else { return false } return match(tail, path.dropFirst()) } /// Drops the trailing spaces git drops — every one that is not `\`-escaped. /// /// Only spaces, and only trailing: git trims exactly this (`trim_trailing_spaces`), so a /// pattern ending in a tab keeps it, and a filename that really does end in a space stays /// reachable by writing `foo\ `. private static func trimmingTrailingSpaces(_ line: Substring) -> Substring { var end = line.endIndex while end > line.startIndex { let previous = line.index(before: end) guard line[previous] == " " else { break } // Escaped when preceded by an odd number of backslashes. var backslashes = 0 var scan = previous while scan > line.startIndex { scan = line.index(before: scan) guard line[scan] == "\\" else { break } backslashes += 1 } guard backslashes.isMultiple(of: 2) else { break } end = previous } return line[line.startIndex ..< end] } } /// One `/`-separated piece of a pattern. fileprivate enum Segment: Sendable, Equatable { /// Exactly `**` — zero or more path segments. "Other consecutive asterisks are considered /// regular asterisks" (`gitignore(5)`), which is why this case is reserved for the whole /// segment and never for a `**` sitting inside one. case globstar /// Everything else, tokenized once at parse time. case literalOrWildcards([Token]) init(text: Substring) { self = text == "**" ? .globstar : .literalOrWildcards(Token.tokenize(text)) } /// Matches one path segment against one pattern segment — `*`'s backtracking, iteratively, /// because a pattern is small and a recursion per `*` is not worth the stack. static func match(_ tokens: ArraySlice, _ name: ArraySlice) -> Bool { var t = tokens.startIndex var n = name.startIndex // Where to resume from if a `*` guessed short: the star itself and the character it was // last asked to swallow up to. var starToken: Int? var starName = name.startIndex while n < name.endIndex { if t < tokens.endIndex { switch tokens[t] { case .anyRun: starToken = t starName = n t += 1 continue case .anyCharacter: t += 1 n += 1 continue case let .literal(character) where character == name[n]: t += 1 n += 1 continue case let .characterClass(group) where group.matches(name[n]): t += 1 n += 1 continue case .literal, .characterClass: break } } guard let star = starToken else { return false } starName += 1 n = starName t = star + 1 } // Trailing `*`s can still match nothing at all. while t < tokens.endIndex, tokens[t] == .anyRun { t += 1 } return t == tokens.endIndex } } /// One element of a pattern segment. fileprivate enum Token: Sendable, Equatable { case literal(Character) /// `?` case anyCharacter /// `*` — any run, `/` excluded by construction (a token never sees a separator). case anyRun /// `[…]` case characterClass(CharacterClass) /// Compiles one segment's characters. A `\` escapes whatever follows it; a trailing `\` is a /// literal backslash (there is nothing left for it to escape); an unterminated `[` is a /// literal `[`, which is git's own reading and the only one that cannot lose a character. static func tokenize(_ text: Substring) -> [Token] { var tokens: [Token] = [] var index = text.startIndex while index < text.endIndex { let character = text[index] switch character { case "\\": let next = text.index(after: index) guard next < text.endIndex else { tokens.append(.literal("\\")) index = next continue } tokens.append(.literal(text[next])) index = text.index(after: next) case "?": tokens.append(.anyCharacter) index = text.index(after: index) case "*": // Consecutive asterisks inside a segment are one ordinary `*`. if tokens.last != .anyRun { tokens.append(.anyRun) } index = text.index(after: index) case "[": if let (group, end) = CharacterClass.parse(text, from: index) { tokens.append(.characterClass(group)) index = end } else { tokens.append(.literal("[")) index = text.index(after: index) } default: tokens.append(.literal(character)) index = text.index(after: index) } } return tokens } } /// A `[…]` group: members, ranges, and the leading `!`/`^` negation. fileprivate struct CharacterClass: Sendable, Equatable { enum Member: Sendable, Equatable { case single(Character) case range(ClosedRange) } let isNegated: Bool let members: [Member] func matches(_ character: Character) -> Bool { let hit = members.contains { member in switch member { case let .single(value): value == character case let .range(range): range.contains(character) } } return hit != isNegated } /// Parses from the `[` at `start`, answering the group and the index just past its `]`, or /// `nil` when the group never closes. /// /// A `]` **immediately after** the opening bracket (or its negation mark) is a literal member /// rather than the terminator, which is the POSIX rule git inherits — `[]]` matches a bracket. static func parse(_ text: Substring, from start: Substring.Index) -> (CharacterClass, Substring.Index)? { var index = text.index(after: start) var negated = false if index < text.endIndex, text[index] == "!" || text[index] == "^" { negated = true index = text.index(after: index) } var members: [Member] = [] var first = true while index < text.endIndex { let character = text[index] if character == "]", !first { return (CharacterClass(isNegated: negated, members: members), text.index(after: index)) } first = false var value = character if character == "\\" { let next = text.index(after: index) guard next < text.endIndex else { break } value = text[next] index = next } // A `-` between two members is a range; one at either end of the group is a literal. let afterValue = text.index(after: index) if afterValue < text.endIndex, text[afterValue] == "-" { let upperIndex = text.index(after: afterValue) if upperIndex < text.endIndex, text[upperIndex] != "]" { var upper = text[upperIndex] var end = upperIndex if upper == "\\" { let escaped = text.index(after: upperIndex) guard escaped < text.endIndex else { break } upper = text[escaped] end = escaped } // An inverted range (`z-a`) is nonsense; git's matcher never matches one, and // `ClosedRange` would trap on it. if value <= upper { members.append(.range(value ... upper)) } index = text.index(after: end) continue } } members.append(.single(value)) index = text.index(after: index) } return nil } } }