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:
@@ -114,6 +114,19 @@ public enum AppPreferences {
|
|||||||
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
||||||
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
||||||
|
|
||||||
|
/// **The board's zoom level** — "app-wide and persisted across restarts" (11-command-nexus.md
|
||||||
|
/// ▸ View ▸ Actual Size; 03-board-ui.md ▸ Layout — zoom). A rung on `BoardZoom.levels`, stored as
|
||||||
|
/// the multiplier itself. Read and written by `BoardZoomStore`, which owns the ladder's rules; the
|
||||||
|
/// key is declared here with its neighbours for `WindowID`'s reason.
|
||||||
|
///
|
||||||
|
/// **Every read goes through `BoardZoom.normalize`**, and this one cannot use the
|
||||||
|
/// `object(forKey:) as? Double ?? default` idiom its neighbours use to tell "off" from "never set"
|
||||||
|
/// — the trap here is worse than an ambiguous default. `double(forKey:)` answers 0 for an unset
|
||||||
|
/// key, and 0 is not merely a wrong level: it drives every `BoardMetrics.em` multiple to its 1pt
|
||||||
|
/// floor and draws a board of hairlines. Normalising is what makes an unset, hand-edited or
|
||||||
|
/// stale-from-a-future-build value indistinguishable from a legal one downstream.
|
||||||
|
public static let boardZoomLevelKey = "boardZoomLevel"
|
||||||
|
|
||||||
/// The cached subscription facts behind the tier decision (12-editions.md ▸ The entitlement) —
|
/// The cached subscription facts behind the tier decision (12-editions.md ▸ The entitlement) —
|
||||||
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
|
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
|
||||||
///
|
///
|
||||||
@@ -252,6 +265,12 @@ public final class AppModel {
|
|||||||
/// board-scoped and this list deliberately is not.
|
/// board-scoped and this list deliberately is not.
|
||||||
public let styleRecents: StyleRecents
|
public let styleRecents: StyleRecents
|
||||||
|
|
||||||
|
/// The board's app-wide zoom level (03-board-ui.md ▸ Layout — zoom). Owned here for
|
||||||
|
/// `styleRecents`' reason exactly: app-scoped, persisted beside it, and reached by every board
|
||||||
|
/// window through the environment — while the menu rows and the toolbar buttons, which live
|
||||||
|
/// outside every scene's environment, reach it through this object.
|
||||||
|
public let zoom: BoardZoomStore
|
||||||
|
|
||||||
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||||
///
|
///
|
||||||
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
||||||
@@ -326,10 +345,12 @@ public final class AppModel {
|
|||||||
/// native undo runs". The absent git state *is* the tier test; nothing here reads a flag.
|
/// 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
|
/// - **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).
|
/// forward restore commits over HEAD's first-parent ancestry (06).
|
||||||
/// - **Mode `none` and mode `repoNested` alike** — the **native stack**, exactly as in the free
|
/// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**, exactly as
|
||||||
/// tier. "Boards without app-managed git — repo-nested included — bind 13-native-undo.md's
|
/// in the free tier. "Boards without app-managed git — repo-nested included — bind
|
||||||
/// native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, re-ruled 2026-07-31
|
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog,
|
||||||
/// twice; 12 ▸ The provider seam; 13's header).
|
/// 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
|
/// **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,
|
/// stance "concerns *git*, and this stack never touches git — memory-only, journal-free,
|
||||||
@@ -357,7 +378,7 @@ public final class AppModel {
|
|||||||
guard let git else { return NativeHistoryProvider() }
|
guard let git else { return NativeHistoryProvider() }
|
||||||
switch git.mode {
|
switch git.mode {
|
||||||
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
|
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
|
||||||
case .none, .repoNested: return NativeHistoryProvider()
|
case .none, .repoNested, .unverifiable: return NativeHistoryProvider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +687,7 @@ public final class AppModel {
|
|||||||
) {
|
) {
|
||||||
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
||||||
styleRecents = StyleRecents(defaults: preferences)
|
styleRecents = StyleRecents(defaults: preferences)
|
||||||
|
zoom = BoardZoomStore(defaults: preferences)
|
||||||
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
|
||||||
// Reads the cached facts and nothing else — no StoreKit API is touched until
|
// Reads the cached facts and nothing else — no StoreKit API is touched until
|
||||||
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
|
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
|
||||||
|
|||||||
+171
-45
@@ -10,7 +10,7 @@ import Foundation
|
|||||||
/// question asked of the filesystem, `nearest-.git-wins`, and `detect(boardRoot:)` below is the
|
/// question asked of the filesystem, `nearest-.git-wins`, and `detect(boardRoot:)` below is the
|
||||||
/// whole of that question.
|
/// 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
|
/// `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
|
/// 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
|
/// 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.
|
/// 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
|
/// ### 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
|
/// 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.
|
/// question every later surface starts from: does the app manage git for this board at all.
|
||||||
public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
|
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
|
/// No `.git` at the board root and none above it — **every** ancestor check answered not-found,
|
||||||
/// the free tier ships (12-editions.md ▸ Tier matrix), and the one add-git moves a board out of.
|
/// "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
|
case none
|
||||||
|
|
||||||
/// A `.git` at the board root: the app manages this board's history. Reached two ways and they
|
/// 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).
|
/// *is* the opt-in" (06 ▸ Rules).
|
||||||
case git
|
case git
|
||||||
|
|
||||||
/// No `.git` at the board root but one above it: the board lives inside somebody else's
|
/// No `.git` at the board root, but one was found at an ancestor — **certain**, found on the
|
||||||
/// repository, which the app "leaves strictly alone" (06 ▸ Rules). The popover says so in
|
/// walk rather than inferred: the board lives inside somebody else's repository, which the app
|
||||||
/// prose — the add-git action is absent because it cannot apply, never hidden or greyed.
|
/// "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
|
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
|
// MARK: - Detection
|
||||||
|
|
||||||
public extension BoardGitMode {
|
public extension BoardGitMode {
|
||||||
|
|
||||||
/// **Nearest-`.git`-wins, freshly at every board open** (06-history-undo.md ▸ Rules): `.git` at
|
/// **What a `.git` path check reported** — `stat(2)`'s errno, classified into the three answers
|
||||||
/// the board root → `.git`; no `.git` at the root but one at any ancestor → `.repoNested`;
|
/// 06's ruling cares about. `exists`/`absent` are the two an unsandboxed filesystem check would
|
||||||
/// neither → `.none`.
|
/// 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"
|
/// ### 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.
|
/// 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
|
/// 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`,
|
/// Pure and total — but no longer silent about what it cannot see: a denied check surfaces as
|
||||||
/// the same answer an unreadable board would fail to open with anyway.
|
/// `.unverifiable` rather than being folded into `.none`, exactly the distinction "Denial is not
|
||||||
|
/// absence" exists to draw.
|
||||||
static func detect(boardRoot: URL) -> BoardGitMode {
|
static func detect(boardRoot: URL) -> BoardGitMode {
|
||||||
if hasGitEntry(at: boardRoot) { return .git }
|
switch probeGitEntry(at: boardRoot) {
|
||||||
if enclosingRepositoryRoot(above: boardRoot) != nil { return .repoNested }
|
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
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// 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`).
|
/// 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,
|
/// **Create re-runs full detection and refuses anything but clean mode none** (06 ▸ Rules ▸
|
||||||
/// ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and refuses unless it
|
/// Detection, ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and
|
||||||
/// reads clean none, so the forbidden nested init is impossible even on a raced or stale read."
|
/// 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
|
/// 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
|
/// 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
|
/// *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
|
/// 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
|
/// 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
|
/// probable. **`.unverifiable` refuses too** — a denied ancestor check can never be told apart
|
||||||
/// is not built — so "clean none" is spelled `.none` for now.)
|
/// 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.
|
/// Returns the branch the root commit landed on, which is the popover's display line.
|
||||||
nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
|
nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
|
||||||
@@ -122,6 +125,11 @@ enum GitRepository {
|
|||||||
operation: operation,
|
operation: operation,
|
||||||
message: "this board lives inside a repository; Lanework leaves it to that repository"
|
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
|
let gitDirectory: URL
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ struct BoardInfoView: View {
|
|||||||
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
|
.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`.
|
/// `BoardGitSection`.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var gitSection: some View {
|
private var gitSection: some View {
|
||||||
@@ -372,6 +372,14 @@ struct BoardInfoView: View {
|
|||||||
}
|
}
|
||||||
.padding(inset)
|
.padding(inset)
|
||||||
|
|
||||||
|
case .unverifiable:
|
||||||
|
Divider()
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Git")
|
||||||
|
BoardGitUnverifiableNote()
|
||||||
|
}
|
||||||
|
.padding(inset)
|
||||||
|
|
||||||
case .branch:
|
case .branch:
|
||||||
Divider()
|
Divider()
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
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
|
/// 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
|
/// 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).
|
/// 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
|
/// **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
|
/// 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`).
|
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
|
||||||
case repoNested
|
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
|
/// 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 —
|
/// 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
|
/// 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 .none: return .noRepository
|
||||||
case .git: return .branch
|
case .git: return .branch
|
||||||
case .repoNested: return .repoNested
|
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
|
/// 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
|
/// `.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
|
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
|
||||||
|
|||||||
@@ -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
|
// level up — so the popover's prose stands and this surface simply does not exist for
|
||||||
// such a board.
|
// such a board.
|
||||||
return []
|
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 []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
/// 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
|
/// 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
|
/// 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
|
// MARK: - Fixtures
|
||||||
|
|
||||||
@@ -127,3 +129,150 @@ struct BoardGitModeTests {
|
|||||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
|
#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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ struct BoardGitSectionTests {
|
|||||||
#expect(section != .noRepository)
|
#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")
|
@Test("Every posture is reachable, and none of them is two postures")
|
||||||
func theMatrixIsTotal() {
|
func theMatrixIsTotal() {
|
||||||
let resolved = Set(
|
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() {
|
func inertGitNeverShowsABranch() {
|
||||||
#expect(
|
#expect(
|
||||||
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .none, branch: "main").branch == nil
|
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
|
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .repoNested, branch: "main").branch
|
||||||
== nil
|
== 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")
|
@Test("A git-mode board whose branch has not been read yet shows none, honestly")
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ struct BoardSettingsSectionTests {
|
|||||||
#expect(offered == Set(BoardSettingsSection.allCases))
|
#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")
|
@Test("The sections carry the headers VoiceOver navigates by")
|
||||||
func headersAreNamed() {
|
func headersAreNamed() {
|
||||||
// 10-accessibility.md ▸ Board settings sheet: "titled and sectioned with headers VoiceOver
|
// 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.
|
// popover's explanation stands and no door opens.
|
||||||
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .repoNested))
|
#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
|
// **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
|
// `.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.
|
// swept for completeness rather than because it can vary.
|
||||||
|
|||||||
@@ -1123,6 +1123,30 @@ struct GitUndoBindingTests {
|
|||||||
#expect(session.git?.committer == nil, "no committer, and so nothing that could write there")
|
#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")
|
@Test("The free tier's repo-nested board still binds the native stack — it never detects one")
|
||||||
func freeTierOnARepoNestedBoardIsNativeToo() throws {
|
func freeTierOnARepoNestedBoardIsNativeToo() throws {
|
||||||
let outer = try WriterFixture()
|
let outer = try WriterFixture()
|
||||||
|
|||||||
@@ -323,6 +323,36 @@ struct HistoryStoreAddGitTests {
|
|||||||
#expect(git.mode == .none, "a refused add-git changes nothing, mode included")
|
#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")
|
@Test("A failure answers at the form when it is up, and at the banner when it is not")
|
||||||
@MainActor
|
@MainActor
|
||||||
func aFailureAnswersAtTheFormOrTheBanner() async throws {
|
func aFailureAnswersAtTheFormOrTheBanner() async throws {
|
||||||
|
|||||||
Reference in New Issue
Block a user