Repo-nested boards bind native undo in every tier (25d2513): the no-undo case is gone, makeHistoryProvider answers git or native, and the native path provably never touches the enclosing repository's .git. Session undo steps anchor by card identity, never by path (9119aa1): HistoryAnchor carries the card UUID (plus comment/draft vocabulary) and apply-time validation resolves the current folder via the same both-container walk writeCardBody uses — a board-side lane or trash move no longer stales the coarse close step, while a genuine field collision still skips it whole. 2448 tests in 423 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
107 lines
6.1 KiB
Swift
107 lines
6.1 KiB
Swift
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
|
|
}
|
|
}
|