import Foundation // MARK: - BoardGitMode /// **Which git mode a board opened in** (07-sync-collab.md ▸ the mode state machine; /// 06-history-undo.md ▸ Rules ▸ Detection). /// /// A board has exactly one mode at a time and the mode is not fixed at creation — "a board may be /// created plain, gain git later, and later still gain a remote" (07). What decides it is one /// question asked of the filesystem, `nearest-.git-wins`, and `detect(boardRoot:)` below is the /// whole of that question. /// /// ### 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 /// user's repo" (06 ▸ Rules) — which makes it as distinct from `git` as `none` is, and the reason it /// is a case rather than a flag on `git`. What it no longer costs is ⌘Z: the native stack binds here /// 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 /// property of a repository the app has already decided it manages — remote detection, tracking and /// the ahead/behind badge are pro-m2's, behind this same seam. What this enum answers is the /// 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 — **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 /// are indistinguishable by design — the app's own add-git (opt-in init), or **adoption**, "a /// board whose root already contains `.git` opens in git mode, silently … the repo's presence /// *is* the opt-in" (06 ▸ Rules). case git /// 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 { /// **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" /// /// "A `git init` under an open mode-none board takes effect at the next open — the running /// session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session /// mode flips from watching; stated here so it isn't rediscovered as a bug)" (06). Nothing /// calls this on a reload path, and `FolderWatcher`'s `.git` filtering — which exists to ignore /// git churn — is what makes that structural rather than a rule somebody has to keep: there is /// no event a re-detection could hang off even if one wanted it. The one deliberate mid-session /// transition is the app's own add-git (`HistoryStore.addGit`), a *commanded* flip, which sets /// 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" — 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 — 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 { 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 } }