Files
lanework/Kanban/Storage/GitignoreRules.swift
T
rzen 445d035a83 The paper agrees with the code — guide v11, README, DESIGN re-rulings, and the adjudicated sweep
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
2026-08-08 12:31:27 -04:00

424 lines
21 KiB
Swift

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, retired with app-managed git,
/// `strategy/01-git-excision.md`): 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 once governed the committer through libgit2 too, so ignored noise neither relocated nor
/// committed — one definition, two consumers; app-managed git is gone now, so the heal is the only
/// consumer left.) This type is the app's half of that — once split from libgit2 because the load
/// path couldn't reach for it while the committer did; libgit2 left the project entirely with the
/// git excision (`strategy/01-git-excision.md`, step 6), so today there is no other implementation to
/// duplicate at all. The free tier always opened 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 (`<lane>/<card>/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<Segment>, _ path: ArraySlice<String>) -> 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<Token>, _ name: ArraySlice<Character>) -> 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<Character>)
}
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
}
}
}