Denial is not absence — detection learns the unverifiable answer 06 ruled for it

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-06 17:52:20 -04:00
parent 503ec4872c
commit 4ac012a55e
10 changed files with 484 additions and 58 deletions
+171 -45
View File
@@ -10,7 +10,7 @@ import Foundation
/// question asked of the filesystem, `nearest-.git-wins`, and `detect(boardRoot:)` below is the
/// whole of that question.
///
/// ### Three cases, and the third is not a degraded second
/// ### Four cases, and the fourth is not a fifth mode
///
/// `repoNested` is not "git mode with the repository somewhere else". A board inside a user's
/// existing repository gets **no app-managed git at all** "no nested repo, no commits into the
@@ -19,6 +19,17 @@ import Foundation
/// too (re-ruled 2026-07-31 13-native-undo.md's header; `AppModel.makeHistoryProvider`), because
/// that stack is memory-only and touches no repository, anybody's.
///
/// `unverifiable` answers a question `repoNested` cannot: what a sandboxed ancestor check *refuses*
/// to say (06 Rules Detection, "Denial is not absence", ruled 2026-07-31). A check the sandbox
/// answers `EACCES`/`EPERM` to is not "no repo there" it is "cannot tell" and folding that into
/// `.none` would let add-git offer app-managed init on a board that might already sit inside a
/// repository the app simply could not see. `unverifiable` therefore takes `repoNested`'s posture
/// everywhere structural (no add-git, no app-managed git, `BoardSettingsSheet.resolve` empty), since
/// the two share the one property every surface but the popover's prose cares about: neither may be
/// added to. Its prose is its own "unverifiable" is not "nested", and telling a user their board
/// sits inside a repository when the honest answer is "couldn't check" would be a lie dressed as
/// caution.
///
/// ### The remote half is deliberately absent
///
/// 07's state machine has a fourth state, git + remote. It is not here because a remote is a
@@ -27,8 +38,11 @@ import Foundation
/// question every later surface starts from: does the app manage git for this board at all.
public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
/// No `.git` at the board root and none above it. Plain folders on local disk the only mode
/// the free tier ships (12-editions.md Tier matrix), and the one add-git moves a board out of.
/// No `.git` at the board root and none above it **every** ancestor check answered not-found,
/// "clean none" in 06's own words. The only mode the free tier ships (12-editions.md Tier
/// matrix), the one add-git moves a board out of, and now that this axis exists the one mode
/// add-git's own re-detection requires before it will act: a raced or stale read that turns out
/// to be `.unverifiable` or `.repoNested` refuses the init exactly as those modes always did.
case none
/// A `.git` at the board root: the app manages this board's history. Reached two ways and they
@@ -37,19 +51,149 @@ public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
/// *is* the opt-in" (06 Rules).
case git
/// No `.git` at the board root but one above it: the board lives inside somebody else's
/// repository, which the app "leaves strictly alone" (06 Rules). The popover says so in
/// prose the add-git action is absent because it cannot apply, never hidden or greyed.
/// No `.git` at the board root, but one was found at an ancestor **certain**, found on the
/// walk rather than inferred: the board lives inside somebody else's repository, which the app
/// "leaves strictly alone" (06 Rules). The popover says so in prose the add-git action is
/// absent because it cannot apply, never hidden or greyed.
case repoNested
/// No `.git` was found at the board root or any ancestor, but at least one check along the way
/// was **denied** (`EACCES`/`EPERM`) rather than answered the sandbox refusing to say whether
/// an ancestor above its grant carries a repository (06 Rules Detection, "Denial is not
/// absence", ruled 2026-07-31). Structurally this takes `repoNested`'s posture: no add-git, no
/// app-managed git anywhere, `BoardSettingsSheet.resolve` empty a denial can never be told
/// apart from a repository actually being there, so the conservative posture is the only honest
/// one. Its prose is its own: the popover explains that Lanework could not verify whether the
/// board sits inside a repository, never the `repoNested` sentence verbatim denial is not
/// nesting.
case unverifiable
}
// MARK: - Detection
public extension BoardGitMode {
/// **Nearest-`.git`-wins, freshly at every board open** (06-history-undo.md Rules): `.git` at
/// the board root `.git`; no `.git` at the root but one at any ancestor `.repoNested`;
/// neither `.none`.
/// **What a `.git` path check reported** `stat(2)`'s errno, classified into the three answers
/// 06's ruling cares about. `exists`/`absent` are the two an unsandboxed filesystem check would
/// ever produce; `denied` is what "Denial is not absence" exists to pull apart from `absent`: a
/// check the sandbox refuses to answer must never read as "no repo there".
enum GitEntryProbe: Sendable, Equatable {
case exists
case absent
case denied
}
/// Probes whether `url` directly contains a `.git`, **whatever kind of node that is** (a
/// directory in an ordinary repository, a plain file `gitdir: ` in a linked worktree or a
/// submodule; both are repositories to git, so both are `.exists` here). `stat`, not `lstat`, so
/// a `.git` that is itself a symlink resolves the way `FileManager.fileExists` always has
/// a broken symlink reads `.absent`, never a false `.exists`.
///
/// **Classification is deliberately narrow**: `ENOENT`/`ENOTDIR` is an honest absence,
/// `EACCES`/`EPERM` is a sandbox denial, and **every other errno reads as `.absent`, not
/// `.denied`** `ELOOP` (a symlink cycle), `ENAMETOOLONG` and the rest are honest reports about
/// the path itself, not the sandbox refusing to look, and folding them into `.denied` would widen
/// `.unverifiable` past what the ruling is actually about. Only `EACCES`/`EPERM` name a refusal
/// to check.
static func probeGitEntry(at url: URL) -> GitEntryProbe {
let gitURL = url.appendingPathComponent(".git")
var info = stat()
let (status, failureErrno): (Int32, Int32) = gitURL.withUnsafeFileSystemRepresentation { representation in
guard let representation else { return (-1, ENOENT) }
let result = stat(representation, &info)
return (result, result == 0 ? 0 : errno)
}
if status == 0 { return .exists }
switch failureErrno {
case ENOENT, ENOTDIR:
return .absent
case EACCES, EPERM:
return .denied
default:
return .absent
}
}
/// Whether `url` directly contains a `.git` the boolean-shaped convenience for call sites
/// outside detection that only ever act on a board already known to be in git mode (the
/// `GitBranchOperation`/`GitCommitOperation`/`GitHeadSnapshot`/`GitHistoryWalk`/
/// `GitHousekeeping` family's guards): `.exists` is `true`, `.absent` and `.denied` alike are
/// `false`, since neither leaves an entry there to use.
///
/// **Detection itself never calls this.** `detect(boardRoot:)` reads `probeGitEntry` directly so
/// a denial can surface as `.unverifiable` instead of silently collapsing to `false` here.
static func hasGitEntry(at url: URL) -> Bool {
probeGitEntry(at: url) == .exists
}
/// The result of walking `boardRoot`'s ancestors for an enclosing repository: the nearest one
/// found, if any, and whether a probe anywhere along the way was denied.
struct AncestorWalk: Sendable, Equatable {
/// The nearest ancestor carrying a `.git`, or `nil` when none was found **certain either
/// way**, regardless of whether a *nearer* ancestor's probe was denied (06 Rules
/// Detection: "a farther ancestor showing `.git` makes repo-nested certain regardless of the
/// denied nearer one nearest-wins only affects which root you'd name, not whether one
/// exists").
public let root: URL?
/// Whether any ancestor probe on the walk answered denied, whether or not the walk
/// ultimately found a `.git`. A denial never ends the walk early it is recorded and the
/// walk continues past it, because only the *complete* walk can tell `.repoNested`
/// (something was found) from `.unverifiable` (nothing was found, but something couldn't be
/// checked) from clean `.none` (everything answered not-found).
public let sawDenial: Bool
}
/// Walks the ancestors above `boardRoot` for the nearest `.git`, denial-aware `detect`'s own
/// ancestor half, exposed because `enclosingRepositoryRoot` and `detect` are both one walk.
///
/// **The walk runs on plain path strings, never on `URL`s** carried over from the pathfinder,
/// where the URL version was a shipped hang. URLs arriving from AppKit surfaces (save panel,
/// bookmark resolution, window restoration) are NSURL-bridged, and for those
/// `deletingLastPathComponent` above `/` grows `/..` forever instead of reaching a fixed point
/// the way native Swift URLs do: the loop never terminated in the app (one core pegged, no repo
/// ever detected) while URL-based unit tests passed. `NSString`'s path math is a pure string
/// operation that terminates at `/` regardless of where the URL came from.
static func ancestorWalk(above boardRoot: URL) -> AncestorWalk {
var sawDenial = false
var path = (boardRoot.standardizedFileURL.path as NSString).deletingLastPathComponent
while !path.isEmpty {
let candidate = URL(fileURLWithPath: path, isDirectory: true)
switch probeGitEntry(at: candidate) {
case .exists:
return AncestorWalk(root: candidate, sawDenial: sawDenial)
case .denied:
// Denial does not end the walk: a farther ancestor's `.git` still makes repo-nested
// certain (the doc comment above). Recorded, and the walk continues past it.
sawDenial = true
case .absent:
break
}
if path == "/" { break }
path = (path as NSString).deletingLastPathComponent
}
return AncestorWalk(root: nil, sawDenial: sawDenial)
}
/// The nearest ancestor of `boardRoot` that carries a `.git`, or `nil` when the walk found
/// none the repo-nested half of detection, exposed because the popover's honest explanation is
/// about a repository that exists somewhere specific, and a later card may well want to name it.
///
/// **Existence only.** A denial recorded along the way is not observable through this call
/// `ancestorWalk(above:)` above is the sibling that reports it, and is what `detect` itself
/// calls; this stays the narrower question it always answered, unchanged in shape by this axis.
static func enclosingRepositoryRoot(above boardRoot: URL) -> URL? {
ancestorWalk(above: boardRoot).root
}
/// **Nearest-`.git`-wins, freshly at every board open, denial-aware** (06-history-undo.md
/// Rules Detection):
///
/// - `.git` at the board root `.git`.
/// - The board-root probe itself denied `.unverifiable` can't rule out git mode at the root.
/// - No `.git` at the root: walk the ancestors. Any `.git` found `.repoNested`, **certain
/// regardless of a denied nearer ancestor** (a farther ancestor's `.git` still settles it).
/// - No `.git` found on the walk, but a denial recorded along the way `.unverifiable`.
/// - Every ancestor answered not-found `.none`, genuinely clean.
///
/// ### Open-time only, and this function is the whole of "open-time"
///
@@ -63,44 +207,26 @@ public extension BoardGitMode {
/// the mode directly rather than re-running this.
///
/// A board can therefore be a different mode at its next open than at this one, and that is the
/// designed behaviour, not a cache to invalidate: "the app just reflects what it finds".
/// designed behaviour, not a cache to invalidate: "the app just reflects what it finds" which
/// now includes `.unverifiable` clearing to `.none` or `.git` once the sandbox grants visibility
/// it did not have before, or the reverse.
///
/// Pure and total a directory it cannot read simply has no `.git` in it, which is `.none`,
/// the same answer an unreadable board would fail to open with anyway.
/// Pure and total but no longer silent about what it cannot see: a denied check surfaces as
/// `.unverifiable` rather than being folded into `.none`, exactly the distinction "Denial is not
/// absence" exists to draw.
static func detect(boardRoot: URL) -> BoardGitMode {
if hasGitEntry(at: boardRoot) { return .git }
if enclosingRepositoryRoot(above: boardRoot) != nil { return .repoNested }
switch probeGitEntry(at: boardRoot) {
case .exists:
return .git
case .denied:
return .unverifiable
case .absent:
break
}
let walk = ancestorWalk(above: boardRoot)
if walk.root != nil { return .repoNested }
if walk.sawDenial { return .unverifiable }
return .none
}
/// Whether `url` directly contains a `.git`, **whatever kind of node that is**: a directory in
/// an ordinary repository, a plain file (`gitdir: `) in a linked worktree or a submodule. Both
/// are repositories to git, so both are repositories here a check that insisted on a
/// directory would read a worktree as mode `none` and offer to initialize a second repo on top
/// of one.
static func hasGitEntry(at url: URL) -> Bool {
FileManager.default.fileExists(atPath: url.appendingPathComponent(".git").path)
}
/// The nearest ancestor of `boardRoot` that carries a `.git`, or `nil` when there is none the
/// repo-nested half of detection, exposed because the popover's honest explanation is about a
/// repository that exists somewhere specific, and a later card may well want to name it.
///
/// **The walk runs on plain path strings, never on `URL`s** carried over from the pathfinder,
/// where the URL version was a shipped hang. URLs arriving from AppKit surfaces (save panel,
/// bookmark resolution, window restoration) are NSURL-bridged, and for those
/// `deletingLastPathComponent` above `/` grows `/..` forever instead of reaching a fixed point
/// the way native Swift URLs do: the loop never terminated in the app (one core pegged, no repo
/// ever detected) while URL-based unit tests passed. `NSString`'s path math is a pure string
/// operation that terminates at `/` regardless of where the URL came from.
static func enclosingRepositoryRoot(above boardRoot: URL) -> URL? {
var path = (boardRoot.standardizedFileURL.path as NSString).deletingLastPathComponent
while !path.isEmpty {
let candidate = URL(fileURLWithPath: path, isDirectory: true)
if hasGitEntry(at: candidate) { return candidate }
if path == "/" { break }
path = (path as NSString).deletingLastPathComponent
}
return nil
}
}