From 26239200ea35b56a292a137db850a2dedb5b02f0 Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 6 Aug 2026 17:53:23 -0400 Subject: [PATCH] =?UTF-8?q?Denial=20is=20not=20absence=20=E2=80=94=20detec?= =?UTF-8?q?tion=20learns=20the=20unverifiable=20answer=2006=20ruled=20for?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy --- Kanban/App/AppModel.swift | 12 +- Kanban/Git/BoardGitMode.swift | 216 +++++++++++++++++----- Kanban/Git/GitRepository.swift | 18 +- Kanban/UI/Board/BoardInfoPopover.swift | 34 +++- Kanban/UI/Board/BoardSettingsSheet.swift | 7 + KanbanTests/BoardGitModeTests.swift | 151 ++++++++++++++- KanbanTests/BoardInfoPopoverTests.swift | 18 +- KanbanTests/BoardSettingsSheetTests.swift | 12 ++ KanbanTests/GitUndoTests.swift | 24 +++ KanbanTests/HistoryStoreTests.swift | 30 +++ 10 files changed, 464 insertions(+), 58 deletions(-) diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 24d3fd6..a833124 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -326,10 +326,12 @@ public final class AppModel { /// native undo runs". The absent git state *is* the tier test; nothing here reads a flag. /// - **Mode `git`** (Pro only — no other tier composes a git state) — the git provider: undo as /// forward restore commits over HEAD's first-parent ancestry (06). - /// - **Mode `none` and mode `repoNested` alike** — the **native stack**, exactly as in the free - /// tier. "Boards without app-managed git — repo-nested included — bind 13-native-undo.md's - /// native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, re-ruled 2026-07-31 - /// twice; 12 ▸ The provider seam; 13's header). + /// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**, exactly as + /// in the free tier. "Boards without app-managed git — repo-nested included — bind + /// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, + /// re-ruled 2026-07-31 twice; 12 ▸ The provider seam; 13's header). `unverifiable` joins the + /// same branch structurally — a denial can never be told apart from a repository actually + /// being there, so it takes `repoNested`'s posture, undo included (06 ▸ Rules ▸ Detection). /// /// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone /// stance "concerns *git*, and this stack never touches git — memory-only, journal-free, @@ -357,7 +359,7 @@ public final class AppModel { guard let git else { return NativeHistoryProvider() } switch git.mode { case .git: return GitHistoryProvider(boardRoot: store.rootURL) - case .none, .repoNested: return NativeHistoryProvider() + case .none, .repoNested, .unverifiable: return NativeHistoryProvider() } } diff --git a/Kanban/Git/BoardGitMode.swift b/Kanban/Git/BoardGitMode.swift index e1fe67a..079018f 100644 --- a/Kanban/Git/BoardGitMode.swift +++ b/Kanban/Git/BoardGitMode.swift @@ -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 - } } diff --git a/Kanban/Git/GitRepository.swift b/Kanban/Git/GitRepository.swift index 6815f01..f951d74 100644 --- a/Kanban/Git/GitRepository.swift +++ b/Kanban/Git/GitRepository.swift @@ -93,17 +93,20 @@ enum GitRepository { /// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here /// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`). /// - /// **Create re-runs full detection and refuses anything but mode none** (06 ▸ Rules ▸ Detection, - /// ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and refuses unless it - /// reads clean none, so the forbidden nested init is impossible even on a raced or stale read." + /// **Create re-runs full detection and refuses anything but clean mode none** (06 ▸ Rules ▸ + /// Detection, ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and + /// refuses unless it reads clean none, so the forbidden nested init is impossible even on a + /// raced or stale read." /// /// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it /// detected at board open, which can be minutes old — a `git init` in a terminal at the board root /// *or anywhere above it* between the two would otherwise slip past a root-only check and /// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole /// walk runs again here, at the moment of the write, so the refusal is structural rather than - /// probable. (Detection has no *unverifiable* answer yet — 06's denial-is-not-absence distinction - /// is not built — so "clean none" is spelled `.none` for now.) + /// probable. **`.unverifiable` refuses too** — a denied ancestor check can never be told apart + /// from a repository actually being there, so only a genuinely clean `.none` reading proceeds; a + /// stale `.none` that has since become unverifiable is refused exactly like one that has since + /// become repo-nested. /// /// Returns the branch the root commit landed on, which is the popover's display line. nonisolated static func create(at boardRoot: URL) -> Result { @@ -122,6 +125,11 @@ enum GitRepository { operation: operation, message: "this board lives inside a repository; Lanework leaves it to that repository" )) + case .unverifiable: + return .failure(GitOperationFailure( + operation: operation, + message: "this board's surroundings could not be fully checked, so Lanework will not add a repository here" + )) } let gitDirectory: URL diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index d1c90a0..0c43ae3 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -340,7 +340,7 @@ struct BoardInfoView: View { .frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width) } - /// The popover's closing section, whichever of the five postures this board is in — see + /// The popover's closing section, whichever of the six postures this board is in — see /// `BoardGitSection`. @ViewBuilder private var gitSection: some View { @@ -372,6 +372,14 @@ struct BoardInfoView: View { } .padding(inset) + case .unverifiable: + Divider() + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Git") + BoardGitUnverifiableNote() + } + .padding(inset) + case .branch: Divider() VStack(alignment: .leading, spacing: 6) { @@ -505,6 +513,8 @@ private struct BoardRenameField: View { /// mode, one to one — and the mode-`none` and repo-nested pair is where the design is most /// insistent: a repo-nested board gets **prose, not a disabled button**. "The option is absent /// because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸ Rules). +/// `unverifiable` (the git-detection axis) joins as a fourth Pro case, structurally identical to +/// `repoNested` but worded as its own honest prose — a denial is not a nesting. /// /// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup /// left the popover for the board settings sheet, so mode `none` no longer renders an action here at @@ -531,6 +541,13 @@ enum BoardGitSection: Equatable, CaseIterable { /// since nothing setup-shaped can apply (`BoardSettingsAvailability`). case repoNested + /// Pro, unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository + /// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested` + /// (no action, no Board Settings… row, `BoardSettingsAvailability` false), but its own case so + /// the view renders its own honest prose rather than the nested sentence — "unverifiable" is not + /// "nested". + case unverifiable + /// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state /// explanation when the surface is held, and the Board Settings… row. The remote half — /// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same @@ -549,6 +566,7 @@ enum BoardGitSection: Equatable, CaseIterable { case .none: return .noRepository case .git: return .branch case .repoNested: return .repoNested + case .unverifiable: return .unverifiable } } } @@ -566,6 +584,20 @@ private struct BoardGitNestedNote: View { } } +/// **The unverifiable explanation** (06-history-undo.md ▸ Rules ▸ Detection, "Denial is not +/// absence", ruled 2026-07-31), worded as its own honest sentence rather than borrowing +/// `BoardGitNestedNote`'s — a denied ancestor check is not a found repository, and telling a user +/// their board is nested when the truth is "couldn't check" would be a lie dressed as caution. +private struct BoardGitUnverifiableNote: View { + + var body: some View { + Text("Lanework couldn't verify whether this board sits inside a repository, so it isn't offering to add one here.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } +} + /// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and /// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git /// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old diff --git a/Kanban/UI/Board/BoardSettingsSheet.swift b/Kanban/UI/Board/BoardSettingsSheet.swift index e5031eb..200cd9a 100644 --- a/Kanban/UI/Board/BoardSettingsSheet.swift +++ b/Kanban/UI/Board/BoardSettingsSheet.swift @@ -159,6 +159,13 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable { // level up — so the popover's prose stands and this surface simply does not exist for // such a board. return [] + case .unverifiable: + // **Structurally the same emptiness as `.repoNested`, for the same reason** (06 ▸ Rules ▸ + // Detection, "Denial is not absence"): a denied ancestor check can never be told apart + // from a repository actually being there, so add-git stays unreachable and this surface + // does not exist for such a board either. Only the popover's *prose* tells the two apart + // — this inventory does not, because the sheet has nothing to set up on either. + return [] } } } diff --git a/KanbanTests/BoardGitModeTests.swift b/KanbanTests/BoardGitModeTests.swift index 944d8da..cd59148 100644 --- a/KanbanTests/BoardGitModeTests.swift +++ b/KanbanTests/BoardGitModeTests.swift @@ -9,7 +9,9 @@ import Testing /// The rule has four claims and this file is one test per claim: root wins, an ancestor is nested, /// neither is `none`, and the answer is re-derived rather than remembered — "a board can therefore /// change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects -/// what it finds." +/// what it finds." `BoardGitEntryProbeTests` and `BoardGitModeDenialTests` below pin the +/// 2026-08-06 axis on top of it: "Denial is not absence" — a check the sandbox refuses must read as +/// `.unverifiable`, never as `.none`. // MARK: - Fixtures @@ -127,3 +129,150 @@ struct BoardGitModeTests { #expect(BoardGitMode.detect(boardRoot: fixture.root) == .none) } } + +// MARK: - Entry probe classification + +/// **The errno-aware probe underneath detection** (06-history-undo.md ▸ Rules ▸ Detection, "Denial +/// is not absence", ruled 2026-07-31) — pinned directly, one test per classification, before the +/// walk that builds on it is asked to prove anything. +/// +/// The `denied` cases chmod a real directory to `0o000` — tests run unprivileged, so `EACCES` is +/// genuinely reachable this way — and restore it with an explicit `defer` declared *after* the +/// fixture's own teardown defer, so it runs first (Swift's LIFO defer order): +/// `BoardDuplicatorTests.aFailedWalkRemovesThePartialSibling` is the precedent this mirrors, so a +/// failed assertion can never leave an unremovable temp directory behind. +@Suite("Board git mode ▸ entry probe") +struct BoardGitEntryProbeTests { + + @Test("A `.git` directory probes as exists") + func probesExists() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try makeGitDirectory(at: fixture.root) + + #expect(BoardGitMode.probeGitEntry(at: fixture.root) == .exists) + #expect(BoardGitMode.hasGitEntry(at: fixture.root), "the boolean convenience agrees") + } + + @Test("A plain folder with no `.git` probes as absent") + func probesAbsent() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + + #expect(BoardGitMode.probeGitEntry(at: fixture.root) == .absent) + #expect(!BoardGitMode.hasGitEntry(at: fixture.root)) + } + + @Test("A folder somewhere the sandbox denies traversal probes as denied, not absent") + func probesDenied() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let outer = fixture.root.appendingPathComponent("outer", isDirectory: true) + let inner = outer.appendingPathComponent("inner", isDirectory: true) + try FileManager.default.createDirectory(at: inner, withIntermediateDirectories: true) + + // Chmod the *parent*, not the probed folder itself: resolving `inner/.git` needs search + // permission on `outer`, which a plain unix permission bit can deny for the test's own + // unprivileged user exactly as the sandbox denies an ungranted ancestor. + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: outer.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: outer.path) } + + #expect(BoardGitMode.probeGitEntry(at: inner) == .denied) + #expect(!BoardGitMode.hasGitEntry(at: inner), "the boolean convenience collapses denied to false, like absent") + } +} + +// MARK: - Denial-aware detection + +/// **The walk semantics denial adds** (06 ▸ Rules ▸ Detection): a denied ancestor never ends the +/// walk early, because a farther ancestor's `.git` still makes repo-nested certain; only a walk that +/// finds nothing at all *and* saw a denial along the way reads `.unverifiable`. +@Suite("Board git mode ▸ denial-aware detection") +struct BoardGitModeDenialTests { + + @Test("A denied board-root probe is unverifiable outright — the walk never runs") + func deniedRootProbeIsUnverifiable() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let boardRoot = fixture.root.appendingPathComponent("board", isDirectory: true) + try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) + + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: boardRoot.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: boardRoot.path) } + + #expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable) + } + + @Test("A denied ancestor with nothing found anywhere else reads unverifiable") + func deniedAncestorWithNothingFoundIsUnverifiable() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let blocked = fixture.root.appendingPathComponent("blocked", isDirectory: true) + let boardRoot = blocked.appendingPathComponent("board", isDirectory: true) + try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) + + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) } + + let walk = BoardGitMode.ancestorWalk(above: boardRoot) + #expect(walk.root == nil) + #expect(walk.sawDenial) + #expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable) + } + + @Test("A denied nearer ancestor never hides a `.git` on a farther one — repo-nested is certain") + func deniedAncestorWithARepositoryFartherUpIsRepoNested() throws { + // **A note on what chmod can and cannot simulate**: the sandbox denies a *specific path* + // independently of the filesystem's own permission bits — an ancestor above the board's + // grant can be denied while the board root itself, inside the grant, stays fully readable. + // POSIX `chmod`, in contrast, cascades: removing search permission from a real ancestor + // directory denies resolving *everything* beneath it, board root included, which is a + // strictly stronger (and still individually honest) denial than the sandbox's. So this test + // proves the walk's own claim directly — `ancestorWalk(above:)` never touches `boardRoot` + // itself, only the candidates above it, and is unaffected by that cascade. + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try makeGitDirectory(at: fixture.root) + let blocked = fixture.root.appendingPathComponent("blocked", isDirectory: true) + let boardRoot = blocked.appendingPathComponent("board", isDirectory: true) + try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) + + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) } + + // "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." + let walk = BoardGitMode.ancestorWalk(above: boardRoot) + #expect(walk.root?.standardizedFileURL == fixture.root.standardizedFileURL) + #expect(walk.sawDenial, "the denial is still recorded, even though it didn't decide the outcome") + + // `detect(boardRoot:)` itself reads `.unverifiable` here — not `.repoNested` — but for the + // cascade reason above, not because the walk's certainty claim is false: `blocked` sits + // between the filesystem root and `boardRoot`, so chmoding it also denies **`boardRoot`'s + // own** `.git` probe, and `detect` answers that denial before the ancestor walk ever runs + // (06 ▸ Rules: "probe the board root's `.git` first … denied → `.unverifiable`"). A real + // sandboxed board, whose own root sits inside the grant, would not hit this path — its own + // probe would succeed and the walk above is what would then run and find `.repoNested`. + #expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable) + } + + @Test("All-clean paths are unaffected: none, git, and repo-nested still read as before") + func cleanPathsAreUnaffected() throws { + let plain = try WriterFixture() + defer { plain.tearDown() } + try plain.item("", Item.board) + #expect(BoardGitMode.detect(boardRoot: plain.root) == .none) + + let gitBoard = try WriterFixture() + defer { gitBoard.tearDown() } + try makeGitDirectory(at: gitBoard.root) + #expect(BoardGitMode.detect(boardRoot: gitBoard.root) == .git) + + let nested = try WriterFixture() + defer { nested.tearDown() } + try makeGitDirectory(at: nested.root) + let board = try makeSubfolder(nested, named: "project/docs/board") + #expect(BoardGitMode.detect(boardRoot: board) == .repoNested) + } +} diff --git a/KanbanTests/BoardInfoPopoverTests.swift b/KanbanTests/BoardInfoPopoverTests.swift index 6048970..5d9c0d1 100644 --- a/KanbanTests/BoardInfoPopoverTests.swift +++ b/KanbanTests/BoardInfoPopoverTests.swift @@ -66,6 +66,18 @@ struct BoardGitSectionTests { #expect(section != .noRepository) } + @Test("An unverifiable board gets its own posture — structurally like nested, never the same case") + func unverifiableIsItsOwnPostureNotRepoNested() { + // "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check + // is not a found repository, so the two must resolve to different cases even though both are + // action-less, prose-only sections. + let section = BoardGitSection.resolve(tier: .pro, mode: .unverifiable, hasGitDirectory: false) + + #expect(section == .unverifiable) + #expect(section != .repoNested, "a denial is not a nesting") + #expect(section != .noRepository) + } + @Test("Every posture is reachable, and none of them is two postures") func theMatrixIsTotal() { let resolved = Set( @@ -129,7 +141,7 @@ struct BoardInfoTitlebarSummaryTests { } } - @Test("An inert .git under Pro — mode none or repo-nested — never shows a branch") + @Test("An inert .git under Pro — mode none, repo-nested, or unverifiable — never shows a branch") func inertGitNeverShowsABranch() { #expect( BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .none, branch: "main").branch == nil @@ -138,6 +150,10 @@ struct BoardInfoTitlebarSummaryTests { BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .repoNested, branch: "main").branch == nil ) + #expect( + BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .unverifiable, branch: "main").branch + == nil + ) } @Test("A git-mode board whose branch has not been read yet shows none, honestly") diff --git a/KanbanTests/BoardSettingsSheetTests.swift b/KanbanTests/BoardSettingsSheetTests.swift index 7c4dd00..a467b13 100644 --- a/KanbanTests/BoardSettingsSheetTests.swift +++ b/KanbanTests/BoardSettingsSheetTests.swift @@ -37,6 +37,14 @@ struct BoardSettingsSectionTests { #expect(offered == Set(BoardSettingsSection.allCases)) } + @Test("Pro, unverifiable: the sheet has nothing to show — structurally like repo-nested") + func unverifiableHoldsNothing() { + // "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check + // can never be told apart from a repository actually being there, so add-git stays as + // unreachable here as it is on a genuinely nested board. + #expect(BoardSettingsSection.resolve(tier: .pro, mode: .unverifiable) == []) + } + @Test("The sections carry the headers VoiceOver navigates by") func headersAreNamed() { // 10-accessibility.md ▸ Board settings sheet: "titled and sectioned with headers VoiceOver @@ -65,6 +73,10 @@ struct BoardSettingsAvailabilityTests { // popover's explanation stands and no door opens. #expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .repoNested)) + // **Pro, unverifiable**: the same unreachability, for the denial-not-absence reason — a + // denied ancestor check is never distinguishable from a repository actually being there. + #expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .unverifiable)) + // **The free tier**: no setup exists there at all (12-editions.md ▸ The free tier and // `.git`), whatever mode a stray value claims — detection never runs off Pro, so the mode is // swept for completeness rather than because it can vary. diff --git a/KanbanTests/GitUndoTests.swift b/KanbanTests/GitUndoTests.swift index 892a96e..50a73cb 100644 --- a/KanbanTests/GitUndoTests.swift +++ b/KanbanTests/GitUndoTests.swift @@ -1123,6 +1123,30 @@ struct GitUndoBindingTests { #expect(session.git?.committer == nil, "no committer, and so nothing that could write there") } + @Test("The default provider closure binds the native stack on an unverifiable board too") + func proOnAnUnverifiableBoardBindsTheNativeStack() throws { + // "`unverifiable` joins the same branch structurally" (`AppModel.makeHistoryProvider`) — a + // denied ancestor check is no more a repository the app manages than a repo-nested one is. + // + // This exercises the seam directly rather than through `openBoard`: reaching `.unverifiable` + // on a real board needs a denied *ancestor*, and POSIX permission bits (unlike the sandbox's + // independent per-path grants) cascade — chmoding a real ancestor to deny its `.git` check + // also denies reading the board's own files underneath it, so the board could never actually + // open (`BoardGitModeDenialTests` in `BoardGitModeTests.swift` covers the detection axis + // itself against real denied directories; this covers what the composition root does with + // whatever mode a `HistoryStore` reports, real detection or not). + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let (model, tearDown) = try makeModel() + defer { tearDown() } + + let git = HistoryStore(boardRoot: fixture.root, mode: .unverifiable, ledger: EchoLedger()) + let provider = model.makeHistoryProvider(store, .pro, git) + + #expect(provider is NativeHistoryProvider) + } + @Test("The free tier's repo-nested board still binds the native stack — it never detects one") func freeTierOnARepoNestedBoardIsNativeToo() throws { let outer = try WriterFixture() diff --git a/KanbanTests/HistoryStoreTests.swift b/KanbanTests/HistoryStoreTests.swift index 066eacc..1eb991e 100644 --- a/KanbanTests/HistoryStoreTests.swift +++ b/KanbanTests/HistoryStoreTests.swift @@ -323,6 +323,36 @@ struct HistoryStoreAddGitTests { #expect(git.mode == .none, "a refused add-git changes nothing, mode included") } + @Test("Create refuses a board that a fresh detection reads unverifiable — a denied ancestor") + func createRefusesUnverifiable() throws { + // **The tightened guard** (06 ▸ Rules ▸ Detection): "only a genuinely clean `.none` reading + // proceeds" — a stale `.none` that has since become unverifiable is refused exactly like one + // that has since become repo-nested (`createRefusesAStaleModeNone` above). + let outer = try WriterFixture() + defer { outer.tearDown() } + let blocked = outer.root.appendingPathComponent("blocked", isDirectory: true) + let boardRoot = blocked.appendingPathComponent("board", isDirectory: true) + try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) + try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) + + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) } + + #expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable, "the fixture is set up correctly") + + let failure = GitRepository.create(at: boardRoot) + guard case .failure(let reason) = failure else { + Issue.record("initializing where detection cannot rule out a repository must be refused") + return + } + #expect(reason.operation == "Adding git to this board") + #expect(!reason.message.isEmpty) + #expect( + !FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path), + "no repository created on an unverifiable read" + ) + } + @Test("A failure answers at the form when it is up, and at the banner when it is not") @MainActor func aFailureAnswersAtTheFormOrTheBanner() async throws {