The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - 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
420 lines
20 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|