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. /// /// ### Three cases, and the third is not a degraded second /// /// `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. /// /// ### 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. 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. 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 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. case repoNested } // 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`. /// /// ### 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". /// /// 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. static func detect(boardRoot: URL) -> BoardGitMode { if hasGitEntry(at: boardRoot) { return .git } if enclosingRepositoryRoot(above: boardRoot) != nil { return .repoNested } 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 } }