Files
lanework/Kanban/Storage/GitignoreRules.swift
rzen 274ccd9ff5 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 by 16ef377:

- 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
2026-08-01 07:43:45 -04:00

420 lines
20 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): 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 (`<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
}
}
}