Build HistoryStore — opt-in git and mode detection
The pro-m1 foundation card. SwiftGitX 0.4.0 (bundled libgit2, the pathfinder's pin) joins the one target; new Kanban/Git/ holds BoardGitMode (pure nearest-.git-wins detection, .git-as-file counts, NSString ancestor walk), HistoryStore (@MainActor @Observable; compose() is the tier gate — free tier gets no object, no detection, no stat), GitRepository (scope-confined SwiftGitX handles: create = init + HEAD forced to main + whole-tree "Initial board state" commit; branch reads incl. unborn/detached; path-history ranks), GitIdentity (derived default as a pure function + repo-local config reader — not libgit2's merged ladder), and GitPathHistory (Mutex-guarded lazy ranker). beginSession composes the git state beside the tier and feeds BoardStore.makeIdentityHistoryRanker; git-mode loads pass the git-backed IdentityHistoryRanker to BoardLoader. The popover's git slot resolves a pure five-way matrix: free tier unchanged (absent / BoardGitNote), Pro mode-aware — Add Git on mode none, honest prose on repo-nested, read-only branch line on git. Provider binding unchanged: both tiers still bind native until the undo/redo card. 42 new tests across 8 suites, all repositories built through bundled libgit2; InertGitTests untouched and green. 2194 tests / 375 suites. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -341,6 +341,36 @@ public final class AppModel {
|
||||
/// whatever the entitlement happens to say when the sidebar renders.
|
||||
public let tier: Tier
|
||||
|
||||
/// **This board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components
|
||||
/// ▸ HistoryStore) — the detected mode, the repository behind it in git mode, and the
|
||||
/// add-git action the popover offers on a board that has none.
|
||||
///
|
||||
/// `nil` under the free tier, and that is the inert posture made structural rather than
|
||||
/// remembered: with no object there is nothing to consult, nothing to detect with, and no
|
||||
/// path by which a free-tier session could touch `.git` (12-editions.md ▸ The free tier and
|
||||
/// `.git`). `HistoryStore.compose` is the one place the tier decides it.
|
||||
///
|
||||
/// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is
|
||||
/// settled at composition and cannot change under an open session. What can change is the
|
||||
/// mode *inside* it, by add-git alone — the one commanded mid-session flip 06 allows.
|
||||
public let git: HistoryStore?
|
||||
|
||||
/// The mode this board is being edited in, `none` when there is no git state at all — which
|
||||
/// is every free-tier session ("The free tier ships exactly one mode: `none`",
|
||||
/// 12-editions.md ▸ Tier matrix).
|
||||
///
|
||||
/// **The consumer this is waiting for is the git `HistoryProviding` implementation** — the
|
||||
/// undo/redo card two cards further into pro-m1, which binds over `makeHistoryProvider` and
|
||||
/// reads exactly this to know whether it has a repository to be an undo stack for. Until it
|
||||
/// lands both tiers bind the native stack, and this is a recorded fact with one reader: the
|
||||
/// popover's git section.
|
||||
///
|
||||
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
|
||||
/// type's isolation, and everything that asks a session what mode it is in is main-actor
|
||||
/// work anyway (a menu, a popover, a provider being composed).
|
||||
@MainActor
|
||||
public var gitMode: BoardGitMode { git?.mode ?? .none }
|
||||
|
||||
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
|
||||
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
|
||||
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
|
||||
@@ -698,6 +728,24 @@ public final class AppModel {
|
||||
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
||||
// the board session and dies at close/quit ... standard macOS behavior".
|
||||
let history = makeHistoryProvider(store, tier)
|
||||
// **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
||||
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
|
||||
// without looking at the disk at all — the inert posture is unconditional there — and under
|
||||
// `.pro` it is one `stat` per open, freshly, so a board that gained or lost a `.git` since
|
||||
// its last open opens in the mode it now has.
|
||||
//
|
||||
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
|
||||
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
|
||||
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier)
|
||||
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal
|
||||
// layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
|
||||
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
|
||||
// load wants its own — see `BoardStore.makeIdentityHistoryRanker` — and because add-git
|
||||
// flips the mode mid-session, which this closure picks up for free by asking the git state
|
||||
// at the moment of each load rather than at composition.
|
||||
if let git {
|
||||
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
|
||||
}
|
||||
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
||||
// store is that boundary — every app-mediated mutation goes out through one of its write
|
||||
// methods — so it is the store that computes each inverse and registers it. What it cannot
|
||||
@@ -710,6 +758,7 @@ public final class AppModel {
|
||||
recordID: recordID,
|
||||
history: history,
|
||||
tier: tier,
|
||||
git: git,
|
||||
// The lock's enablement half (13-native-undo.md ▸ Rules): Undo and Redo disable with the
|
||||
// other mutating commands while the board refuses writes, and the stack survives to
|
||||
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
|
||||
|
||||
@@ -283,10 +283,20 @@ struct BoardWindowHost: View {
|
||||
// card windows share that machinery and have no board to describe. It goes in after the
|
||||
// load rather than at attach because it carries the store; the controller installs it once,
|
||||
// whichever of the two arrives second.
|
||||
//
|
||||
// The tier and the git state come from the **session**, which `start()` began a moment ago,
|
||||
// rather than from the entitlement or the disk: a board's popover must describe the board as
|
||||
// it opened (12-editions.md ▸ The entitlement, "an open board finishes with the provider it
|
||||
// composed"; 06-history-undo.md ▸ Rules, mode is an open-time fact). A `nil` session cannot
|
||||
// happen on this path — `beginSession` precedes `configureWindow` — and reads as the free
|
||||
// tier's posture, which is the harmless direction.
|
||||
let session = appModel.session(for: ref)
|
||||
windowController.installTitlebarAccessory(
|
||||
boardInfoTitlebarAccessory(
|
||||
store: store,
|
||||
recents: appModel.styleRecents,
|
||||
tier: session?.tier ?? .free,
|
||||
git: session?.git,
|
||||
presentation: boardInfo
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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, no undo" (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`.
|
||||
///
|
||||
/// ### 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - GitIdentity
|
||||
|
||||
/// **Who the app's commits are authored by** (06-history-undo.md ▸ Interaction with external
|
||||
/// writers ▸ "Where the user's git identity comes from").
|
||||
///
|
||||
/// Two sources, in the design's own order — and the order is git's own, which is the point:
|
||||
///
|
||||
/// 1. **Repo-local `.git/config` wins when present.** "Standard git semantics, readable in-sandbox
|
||||
/// because it lives under the board root, and the natural state of adopted/cloned boards." The
|
||||
/// popover's name/email fields (a later card) write exactly that file: "the setting *is* the
|
||||
/// file, portable to any git client, per-board by nature".
|
||||
/// 2. **Absent repo config, the derived default**: "the macOS account's full name plus
|
||||
/// `shortname@hostname` — git's own no-config fallback shape, zero ceremony."
|
||||
///
|
||||
/// What is deliberately *not* a source is `~/.gitconfig`: the app is sandboxed and cannot read it,
|
||||
/// which 06 states as an honest limit rather than a bug. Nothing here consults libgit2's own config
|
||||
/// ladder for the same reason — a global layer that is unreachable in the shipped app but readable
|
||||
/// on a developer's machine would make the app's authorship depend on how it was launched.
|
||||
///
|
||||
/// The commits this type does *not* speak for are the synthetic ones: foreign changes commit as
|
||||
/// `Lanework External <external@lanework.invalid>` and `modified-by`-stamped windows as
|
||||
/// `<slug>@agents.lanework.invalid` (06). Those are the auto-commit card's, and they are pinned
|
||||
/// strings rather than derivations — nothing about them belongs in a type about *the user's*
|
||||
/// identity.
|
||||
public struct GitIdentity: Sendable, Equatable {
|
||||
|
||||
public let name: String
|
||||
public let email: String
|
||||
|
||||
public init(name: String, email: String) {
|
||||
self.name = name
|
||||
self.email = email
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The derived default
|
||||
|
||||
public extension GitIdentity {
|
||||
|
||||
/// The derived default, as a **pure function of three strings** — so the shape 06 names can be
|
||||
/// proven without asserting anything about the machine the tests run on.
|
||||
///
|
||||
/// `fullName` is the account's display name (`NSFullUserName()`), `accountName` its short name
|
||||
/// (`NSUserName()`), `hostName` the machine's (`ProcessInfo.hostName`). Every one of them can
|
||||
/// come back empty or shaped in a way git would reject, so each is defended:
|
||||
///
|
||||
/// - An empty full name falls back to the account name — git does the same when GECOS is blank,
|
||||
/// and a commit authored by `"" <me@mac>` is a commit no client renders sensibly.
|
||||
/// - The email's local part and host are sanitized to what an address may contain: a signature
|
||||
/// with a space or an angle bracket in it is not merely ugly, libgit2 refuses it outright and
|
||||
/// the commit fails.
|
||||
/// - An empty host reads `localhost`, which is what a machine with no name is.
|
||||
static func derived(fullName: String, accountName: String, hostName: String) -> GitIdentity {
|
||||
let account = accountName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedName = fullName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = trimmedName.isEmpty ? (account.isEmpty ? "Lanework" : account) : trimmedName
|
||||
|
||||
let localPart = addressComponent(account, fallback: "user")
|
||||
// A trailing dot is legal in a fully-qualified name and useless in an address; `.local`
|
||||
// hosts keep theirs, which is exactly what git's own fallback produces on a Mac.
|
||||
let host = addressComponent(
|
||||
hostName.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".")
|
||||
? String(hostName.trimmingCharacters(in: .whitespacesAndNewlines).dropLast())
|
||||
: hostName,
|
||||
fallback: "localhost"
|
||||
)
|
||||
|
||||
return GitIdentity(name: name, email: "\(localPart)@\(host)")
|
||||
}
|
||||
|
||||
/// The derived default for *this* machine — the one impure call, kept to one line so everything
|
||||
/// above it stays provable.
|
||||
static func derivedDefault() -> GitIdentity {
|
||||
derived(
|
||||
fullName: NSFullUserName(),
|
||||
accountName: NSUserName(),
|
||||
hostName: ProcessInfo.processInfo.hostName
|
||||
)
|
||||
}
|
||||
|
||||
/// **The resolution 06 states**, per key rather than wholesale: a repo-local config naming only
|
||||
/// `user.name` contributes exactly that and the email still derives — git resolves each key on
|
||||
/// its own, and a half-configured repo is a real state (it is what a `git config user.email`
|
||||
/// typo leaves behind).
|
||||
static func resolve(repoLocal: (name: String?, email: String?), derived: GitIdentity) -> GitIdentity {
|
||||
func configured(_ value: String?, or fallback: String) -> String {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty else { return fallback }
|
||||
return trimmed
|
||||
}
|
||||
return GitIdentity(
|
||||
name: configured(repoLocal.name, or: derived.name),
|
||||
email: configured(repoLocal.email, or: derived.email)
|
||||
)
|
||||
}
|
||||
|
||||
/// Characters an address part may carry, with everything else collapsed to `-`. Deliberately
|
||||
/// conservative rather than RFC-complete: the input is a Mac account name and a Bonjour host
|
||||
/// name, and the only job is that libgit2 accepts the signature and a git client renders it.
|
||||
private static func addressComponent(_ raw: String, fallback: String) -> String {
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._"))
|
||||
let mapped = String(
|
||||
String.UnicodeScalarView(
|
||||
raw.unicodeScalars.map { allowed.contains($0) ? $0 : Unicode.Scalar("-") }
|
||||
)
|
||||
)
|
||||
let trimmed = mapped.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
|
||||
return trimmed.isEmpty ? fallback : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Repo-local config
|
||||
|
||||
/// **The board's own `.git/config`, read as text** (06-history-undo.md: "repo-local `.git/config`
|
||||
/// wins when present … readable in-sandbox because it lives under the board root").
|
||||
///
|
||||
/// Read by hand rather than through libgit2's config ladder, deliberately: `git_repository_config`
|
||||
/// merges the repository, global and system layers, so a value read through it is not the answer to
|
||||
/// "what does *this repository* say" — it is the answer to "what does this machine say", which is
|
||||
/// the question the sandbox makes unanswerable and which 06 rules out of the identity story
|
||||
/// entirely. Reading the file the design names gives the same answer in the shipped sandboxed app,
|
||||
/// in a test, and on a developer's machine with a `~/.gitconfig` full of opinions.
|
||||
///
|
||||
/// The parse is tolerant by design: it is looking for two keys in one section of a format that
|
||||
/// allows comments, indentation and quoting, and anything it fails to understand simply reads as
|
||||
/// absent — which falls through to the derived default, the same place a missing file lands.
|
||||
enum GitConfigFile {
|
||||
|
||||
/// `user.name` / `user.email` as the config file at `gitDirectory/config` states them; both
|
||||
/// `nil` when the file does not exist, cannot be read, or names neither key.
|
||||
static func identity(inGitDirectory gitDirectory: URL) -> (name: String?, email: String?) {
|
||||
let configURL = gitDirectory.appendingPathComponent("config")
|
||||
guard let text = try? String(contentsOf: configURL, encoding: .utf8) else { return (nil, nil) }
|
||||
return identity(inConfigText: text)
|
||||
}
|
||||
|
||||
/// The parse, over text — the pure half, and where the format's edges are decided.
|
||||
static func identity(inConfigText text: String) -> (name: String?, email: String?) {
|
||||
var section: String?
|
||||
var name: String?
|
||||
var email: String?
|
||||
|
||||
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue }
|
||||
|
||||
if line.hasPrefix("[") {
|
||||
// `[user]`, and `[user "work"]` — a subsection is somebody else's scope, so the
|
||||
// header's first token is what names the section.
|
||||
let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
|
||||
section = header
|
||||
.split(separator: " ", maxSplits: 1)
|
||||
.first
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
continue
|
||||
}
|
||||
|
||||
guard section == "user", let separator = line.firstIndex(of: "=") else { continue }
|
||||
let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let value = unquoted(line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
|
||||
switch key {
|
||||
case "name": name = value.nonEmpty
|
||||
case "email": email = value.nonEmpty
|
||||
default: continue
|
||||
}
|
||||
}
|
||||
|
||||
return (name, email)
|
||||
}
|
||||
|
||||
/// Strips one layer of surrounding quotes, and an unquoted trailing comment. A `#` inside
|
||||
/// quotes is content — git's own rule, and the one place a naive strip would corrupt a name.
|
||||
private static func unquoted(_ value: String) -> String {
|
||||
if value.hasPrefix("\"") {
|
||||
let body = value.dropFirst()
|
||||
guard let closing = body.firstIndex(of: "\"") else { return String(body) }
|
||||
return String(body[body.startIndex..<closing])
|
||||
}
|
||||
let uncommented = value.prefix { $0 != "#" && $0 != ";" }
|
||||
return uncommented.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String conveniences
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
// MARK: - GitPathHistory
|
||||
|
||||
/// **One load's answer to "how early did this path enter history"** — the object behind
|
||||
/// `HistoryStore.identityHistoryRanker`, and the git implementation of the seam
|
||||
/// `BoardLoader.IdentityHistoryRanker` describes.
|
||||
///
|
||||
/// ### Lazy, because the question is usually never asked
|
||||
///
|
||||
/// The loader consults the ranker **only when it has already found a duplicate identity**
|
||||
/// (`BoardLoader.dedupeIdentities` gates on a collision before it builds a single occurrence), which
|
||||
/// on a healthy board is never. So nothing here walks a repository at construction: the map is built
|
||||
/// on the first `rank(of:)` call and reused for the rest of that load, which means the ordinary case
|
||||
/// costs one allocation and no libgit2 at all.
|
||||
///
|
||||
/// ### Sendable, because the load runs off the main actor
|
||||
///
|
||||
/// `BoardStore.startReload` walks the tree in a detached task, so the ranker crosses into it and the
|
||||
/// closure `BoardLoader` calls is `@Sendable`. The cache is therefore a `Mutex` rather than a plain
|
||||
/// `var` — one lock, held across the walk itself, which is correct rather than merely safe: two
|
||||
/// concurrent first-callers would otherwise each walk the whole ancestry to compute the same map.
|
||||
final class GitPathHistory: Sendable {
|
||||
|
||||
private let boardRoot: URL
|
||||
|
||||
/// `nil` until the first ask — see the type's note. The distinction between "not computed" and
|
||||
/// "computed, and the repository had nothing to say" is what keeps an empty history from being
|
||||
/// recomputed on every occurrence in a colliding board.
|
||||
private let ranks = Mutex<[String: Int]?>(nil)
|
||||
|
||||
init(boardRoot: URL) {
|
||||
self.boardRoot = boardRoot
|
||||
}
|
||||
|
||||
/// The seam value the loader takes: lower is earlier, `nil` is untracked or no history.
|
||||
var ranker: BoardLoader.IdentityHistoryRanker {
|
||||
BoardLoader.IdentityHistoryRanker { [self] path in rank(of: path) }
|
||||
}
|
||||
|
||||
/// The rank of one board-root-relative path.
|
||||
func rank(of path: String) -> Int? {
|
||||
ranks.withLock { cache in
|
||||
if cache == nil {
|
||||
cache = GitRepository.pathFirstAppearanceRanks(at: boardRoot)
|
||||
}
|
||||
return cache?[path]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import Foundation
|
||||
import SwiftGitX
|
||||
import os
|
||||
|
||||
// MARK: - Failure
|
||||
|
||||
/// **Why a git operation didn't happen**, named and carrying libgit2's own message.
|
||||
///
|
||||
/// One type rather than a case per operation because everything that reaches a user goes through
|
||||
/// the same two sentences — what was being attempted, and what the library said — and because the
|
||||
/// operations that will join `initialize` here (commit, checkout, pull) all fail in exactly that
|
||||
/// shape (06-history-undo.md ▸ Interaction with external writers: "An operation that fails
|
||||
/// *cleanly* … surfaces as a one-shot banner failure naming the operation and the error").
|
||||
public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConvertible {
|
||||
|
||||
/// What was being attempted, in the user's words rather than libgit2's — "Adding git to this
|
||||
/// board", not `git_repository_init`.
|
||||
public let operation: String
|
||||
|
||||
/// libgit2's message for the failure, verbatim. Kept rather than mapped: the messages are
|
||||
/// specific ("could not write to '…': Permission denied") in a way no re-phrasing of ours would
|
||||
/// be, and the alternative to showing it is a shrug.
|
||||
public let message: String
|
||||
|
||||
public init(operation: String, message: String) {
|
||||
self.operation = operation
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var description: String { "\(operation) failed: \(message)" }
|
||||
}
|
||||
|
||||
// MARK: - GitRepository
|
||||
|
||||
/// **The board's repository, through the bundled libgit2** (06-history-undo.md ▸ Rules ▸ Opt-in
|
||||
/// init: "Bundled libgit2 — no git install required").
|
||||
///
|
||||
/// SwiftGitX vendors libgit2 as an in-process library, so every call here runs inside the sandbox
|
||||
/// with no `Process`, no `/usr/bin/git` and no sandbox extension — the shipped Release build behaves
|
||||
/// identically on a machine that has never had the command-line tools installed.
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// Every function is `nonisolated` and **opens its own `Repository`, confined to its own
|
||||
/// synchronous scope**. `Repository` is `Sendable` (SwiftGitX marks it so to make handles
|
||||
/// transferable), but the libgit2 handle underneath is not safe for concurrent use from several
|
||||
/// threads at once, so no handle here is ever shared across an `await`, a `Task`, or a stored
|
||||
/// property. `HistoryStore` — which is `@MainActor` — reaches these through `Task.detached`, so the
|
||||
/// main actor never blocks on libgit2 and libgit2 never sees two threads at once.
|
||||
///
|
||||
/// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's
|
||||
/// *policy* deliberately left behind: nothing here auto-initializes anything, nothing seeds a
|
||||
/// `.gitignore` (06 ▸ Repository hygiene, a later card), and nothing commits on its own schedule.
|
||||
enum GitRepository {
|
||||
|
||||
/// **The root commit's own subject** (06-history-undo.md ▸ Rules ▸ Abnormal repo states,
|
||||
/// settled): "whenever the app creates a repo's first commit … it commits the whole tree as
|
||||
/// *Initial board state*, never a folded diff-from-empty: there is no last-committed snapshot to
|
||||
/// diff against, and forty Adds would bury the event."
|
||||
static let initialCommitSubject = "Initial board state"
|
||||
|
||||
/// The branch a board's first commit lands on.
|
||||
///
|
||||
/// **Forced rather than inherited, deliberately.** libgit2's compiled-in initial-branch name
|
||||
/// comes from `init.defaultBranch` in whatever config layer it can find at
|
||||
/// `git_repository_init` time — which is non-deterministic across machines and simply
|
||||
/// unavailable in the sandbox (redirected, empty HOME). `Repository.create(at:)` has no
|
||||
/// initial-branch parameter, so this is applied by writing `.git/HEAD` directly: on a freshly
|
||||
/// created, unborn, non-bare repository that file is nothing but the plain-text symbolic ref, so
|
||||
/// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches
|
||||
/// the repo.
|
||||
///
|
||||
/// DESIGN is silent on the name; `main` is git's own modern default and the pathfinder's choice.
|
||||
static let initialBranchName = "main"
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
// MARK: Opt-in init
|
||||
|
||||
/// **Add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init): initializes a repository at
|
||||
/// `boardRoot` and immediately commits the whole tree as `Initial board state`.
|
||||
///
|
||||
/// The commit is not deferred to any debounce — "init doesn't wait for the debounce; the board
|
||||
/// is protected from the moment git exists" — so the two halves are one operation and a failure
|
||||
/// in either is one failure.
|
||||
///
|
||||
/// **It refuses a board that already has a `.git`.** The app "never mutates repo state it didn't
|
||||
/// create" (06), and `git_repository_init` over an existing repository is a re-initialization —
|
||||
/// harmless in the common case and precisely the kind of thing that rule exists to forbid. The
|
||||
/// caller (`HistoryStore.addGit`) has already established mode `none`; this is the check that
|
||||
/// makes it impossible rather than merely unlikely.
|
||||
///
|
||||
/// 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> {
|
||||
let operation = "Adding git to this board"
|
||||
|
||||
guard !BoardGitMode.hasGitEntry(at: boardRoot) else {
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "this board already has a git repository"
|
||||
))
|
||||
}
|
||||
|
||||
let gitDirectory: URL
|
||||
do {
|
||||
let created = try Repository.create(at: boardRoot)
|
||||
gitDirectory = created.path
|
||||
// Before anything else touches the repo — see `initialBranchName`.
|
||||
try? "ref: refs/heads/\(initialBranchName)\n".write(
|
||||
to: gitDirectory.appendingPathComponent("HEAD"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
} catch {
|
||||
return .failure(GitOperationFailure(operation: operation, message: reason(error)))
|
||||
}
|
||||
|
||||
// A **fresh handle** for the staging and the commit, so nothing reads HEAD through a
|
||||
// repository object that predates the symbolic ref just written: libgit2 caches refs per
|
||||
// repository, and the whole point of writing that file was to decide where the first commit
|
||||
// lands. The creating handle is dropped above.
|
||||
let repository: Repository
|
||||
do {
|
||||
repository = try Repository.open(at: boardRoot)
|
||||
} catch {
|
||||
return .failure(GitOperationFailure(operation: operation, message: reason(error)))
|
||||
}
|
||||
|
||||
applyIdentity(to: repository, gitDirectory: gitDirectory)
|
||||
|
||||
do {
|
||||
// An empty pathspec passed to `git_index_add_all` (via `add(paths:)`) matches every path
|
||||
// in the working tree — full `git add -A` semantics in one step, `.gitignore` respected
|
||||
// — which is what "commits the whole tree" means: the board's files, the agent guide,
|
||||
// strays and all (06 ▸ Commit messages: "the committer stages the whole board root").
|
||||
try repository.add(paths: [])
|
||||
_ = try repository.commit(message: initialCommitSubject)
|
||||
} catch {
|
||||
logger.error("initial commit failed at \(boardRoot.path, privacy: .public): \(reason(error), privacy: .public)")
|
||||
return .failure(GitOperationFailure(operation: operation, message: reason(error)))
|
||||
}
|
||||
|
||||
return .success(branchName(at: boardRoot) ?? initialBranchName)
|
||||
}
|
||||
|
||||
/// Gives the repository a commit identity **only when it has none** (06-history-undo.md
|
||||
/// ▸ Interaction with external writers ▸ "Where the user's git identity comes from").
|
||||
///
|
||||
/// `GitIdentity` resolves what the identity *is*: repo-local config when present, the derived
|
||||
/// default otherwise. What this method adds is the mechanism — writing the resolved identity
|
||||
/// into the repository's own config so libgit2's default signature resolves to it.
|
||||
///
|
||||
/// **That write is a mechanism, not a design decision, and it is the narrowest one available.**
|
||||
/// SwiftGitX 0.4.0's `commit(message:)` takes no signature (its `CommitOptions` leaves
|
||||
/// `author`/`committer` null, so libgit2 falls back to `git_signature_default`, which fails
|
||||
/// outright in a sandbox with no readable config). Every board this runs on is one the app
|
||||
/// created milliseconds earlier, whose config the app itself wrote, and the keys are only ever
|
||||
/// *added* — a config that already names an identity is left exactly as it was, which is the
|
||||
/// adopted-repo promise. The auto-commit card needs per-commit authorship anyway (foreign
|
||||
/// changes commit as `Lanework External`, `modified-by` windows as the agent), so it must reach
|
||||
/// a signature-capable commit path regardless; when it does, this materialization goes with it.
|
||||
private static func applyIdentity(to repository: Repository, gitDirectory: URL) {
|
||||
let configured = GitConfigFile.identity(inGitDirectory: gitDirectory)
|
||||
guard configured.name == nil || configured.email == nil else { return }
|
||||
|
||||
let identity = GitIdentity.resolve(repoLocal: configured, derived: .derivedDefault())
|
||||
if configured.name == nil {
|
||||
try? repository.config.set("user.name", to: identity.name)
|
||||
}
|
||||
if configured.email == nil {
|
||||
try? repository.config.set("user.email", to: identity.email)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Reads
|
||||
|
||||
/// The current branch's short name, or `nil` when there is no repository at `boardRoot` or
|
||||
/// libgit2 cannot open it — the popover's read-only branch line (03-board-ui.md ▸ Board
|
||||
/// popover), and nothing more: branch switching and creation are a later card.
|
||||
///
|
||||
/// **An unborn HEAD answers with a name, not with `nil`** (06 ▸ Rules ▸ Abnormal repo states:
|
||||
/// "an unborn HEAD is normal git mode"). Every SwiftGitX HEAD accessor goes through
|
||||
/// `git_repository_head`, which refuses to resolve an unborn HEAD to a name and throws instead,
|
||||
/// so the only way to recover the branch a first commit *would* land on is to read `.git/HEAD`'s
|
||||
/// symbolic-ref target — the same plain text this file writes at init.
|
||||
nonisolated static func branchName(at boardRoot: URL) -> String? {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
||||
let repository = try? Repository.open(at: boardRoot) else { return nil }
|
||||
|
||||
if repository.isHEADUnborn {
|
||||
return unbornBranchName(gitDirectory: repository.path)
|
||||
}
|
||||
guard let head = try? repository.HEAD else { return nil }
|
||||
if repository.isHEADDetached {
|
||||
// Detached HEAD reports its branch `name` as the literal "HEAD", which labels nothing.
|
||||
// The short hash is what plain git shows in the same state. (The *posture* a detached
|
||||
// HEAD calls for — pausing the whole git surface honestly, 06 ▸ Abnormal repo states —
|
||||
// is the auto-commit card's; this is only the label.)
|
||||
return (head.target as? Commit)?.id.abbreviated ?? "HEAD"
|
||||
}
|
||||
return head.name
|
||||
}
|
||||
|
||||
/// HEAD's commit, flattened to what a caller (and a test) can assert on: subject, author, and
|
||||
/// how many parents it has — a root commit having none is how "the root commit has its own
|
||||
/// subject" is checkable.
|
||||
nonisolated static func headCommit(at boardRoot: URL) -> CommitSummary? {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
||||
let repository = try? Repository.open(at: boardRoot),
|
||||
!repository.isHEADUnborn,
|
||||
let head = try? repository.HEAD,
|
||||
let commit = head.target as? Commit else { return nil }
|
||||
|
||||
return CommitSummary(
|
||||
oid: commit.id.hex,
|
||||
subject: commit.summary,
|
||||
authorName: commit.author.name,
|
||||
authorEmail: commit.author.email,
|
||||
parentCount: (try? commit.parents)?.count ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
/// Every file path in HEAD's tree, board-root-relative and sorted — what the repository actually
|
||||
/// tracks right now.
|
||||
nonisolated static func trackedPaths(at boardRoot: URL) -> [String] {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
||||
let repository = try? Repository.open(at: boardRoot),
|
||||
!repository.isHEADUnborn,
|
||||
let head = try? repository.HEAD,
|
||||
let commit = head.target as? Commit else { return [] }
|
||||
return filePaths(of: commit, in: repository).sorted()
|
||||
}
|
||||
|
||||
/// One commit, as much of it as anything outside this file needs.
|
||||
struct CommitSummary: Sendable, Equatable {
|
||||
let oid: String
|
||||
let subject: String
|
||||
let authorName: String
|
||||
let authorEmail: String
|
||||
let parentCount: Int
|
||||
}
|
||||
|
||||
// MARK: Path history
|
||||
|
||||
/// **When each path entered history** — the git half of the loader's earlier-occurrence-wins
|
||||
/// ladder (01-storage-format.md ▸ Fractal layout ▸ Rules: "on git boards, the path history
|
||||
/// already tracks outranks the newcomer"; `BoardLoader.IdentityHistoryRanker`).
|
||||
///
|
||||
/// The answer is `git log --diff-filter=A`-shaped, walked here rather than shelled out: HEAD's
|
||||
/// **first-parent** ancestry oldest-first, with each commit's rank being its position in that
|
||||
/// walk. Paths present in the oldest commit reached rank 0 (its whole tree, since a root commit
|
||||
/// has no parent to diff against and a capped walk's base is "everything that already existed");
|
||||
/// every later commit contributes the paths its diff *adds*. Lower is earlier, which is exactly
|
||||
/// the ranker's contract, and a path never seen is absent — the `nil` the rule reads as
|
||||
/// "outranked by anything tracked".
|
||||
///
|
||||
/// **Ranks are recorded for folders, not only files**, because the loader asks about *items*:
|
||||
/// a card is a folder, and what git tracks is the `index.md` inside it. Every directory prefix
|
||||
/// of an added file therefore takes that file's rank unless it already has an earlier one.
|
||||
///
|
||||
/// Two honest limits. The walk is **capped** (`limit`), so a board with a longer history than
|
||||
/// that reads everything at its base as equally early — a tie the ladder resolves on birth date,
|
||||
/// exactly as it does without git. And **renames are not followed**: libgit2 reports a rename as
|
||||
/// an add plus a delete unless rename detection is run over the diff, so a card moved between
|
||||
/// lanes ranks at its move rather than at its birth (`--follow`'s job). Both degrade toward the
|
||||
/// no-history answer rather than toward a wrong one.
|
||||
nonisolated static func pathFirstAppearanceRanks(at boardRoot: URL, limit: Int = 512) -> [String: Int] {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
||||
let repository = try? Repository.open(at: boardRoot),
|
||||
!repository.isHEADUnborn,
|
||||
let head = try? repository.HEAD,
|
||||
let tip = head.target as? Commit else { return [:] }
|
||||
|
||||
var chain: [Commit] = []
|
||||
var current: Commit? = tip
|
||||
while let commit = current, chain.count < limit {
|
||||
chain.append(commit)
|
||||
current = (try? commit.parents)?.first
|
||||
}
|
||||
|
||||
var ranks: [String: Int] = [:]
|
||||
for (rank, commit) in chain.reversed().enumerated() {
|
||||
if rank == 0 {
|
||||
for path in filePaths(of: commit, in: repository) {
|
||||
record(path: path, rank: rank, into: &ranks)
|
||||
}
|
||||
continue
|
||||
}
|
||||
guard let diff = try? repository.diff(commit: commit) else { continue }
|
||||
for delta in diff.changes where delta.type == .added || delta.type == .renamed || delta.type == .copied {
|
||||
record(path: delta.newFile.path, rank: rank, into: &ranks)
|
||||
}
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
/// Records `path` and every directory prefix above it at `rank`, keeping the earliest rank any
|
||||
/// of them has already earned.
|
||||
private static func record(path: String, rank: Int, into ranks: inout [String: Int]) {
|
||||
var components = path.split(separator: "/").map(String.init)
|
||||
while !components.isEmpty {
|
||||
let key = components.joined(separator: "/")
|
||||
if let existing = ranks[key] {
|
||||
ranks[key] = min(existing, rank)
|
||||
} else {
|
||||
ranks[key] = rank
|
||||
}
|
||||
components.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
/// Every blob path under `commit`'s tree, recursively.
|
||||
private static func filePaths(of commit: Commit, in repository: Repository) -> [String] {
|
||||
guard let tree = try? commit.tree else { return [] }
|
||||
var paths: [String] = []
|
||||
|
||||
func walk(_ tree: Tree, prefix: String) {
|
||||
for entry in tree.entries {
|
||||
let path = prefix.isEmpty ? entry.name : prefix + "/" + entry.name
|
||||
if entry.type == .tree {
|
||||
guard let subtree: Tree = try? repository.show(id: entry.id) else { continue }
|
||||
walk(subtree, prefix: path)
|
||||
} else {
|
||||
paths.append(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree, prefix: "")
|
||||
return paths
|
||||
}
|
||||
|
||||
/// The unborn HEAD's symbolic target, parsed out of `.git/HEAD`'s plain text
|
||||
/// (`ref: refs/heads/main` → `main`).
|
||||
private static func unbornBranchName(gitDirectory: URL) -> String? {
|
||||
guard let contents = try? String(
|
||||
contentsOf: gitDirectory.appendingPathComponent("HEAD"),
|
||||
encoding: .utf8
|
||||
) else { return nil }
|
||||
let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let prefix = "ref: refs/heads/"
|
||||
guard trimmed.hasPrefix(prefix) else { return nil }
|
||||
let name = String(trimmed.dropFirst(prefix.count))
|
||||
return name.isEmpty ? nil : name
|
||||
}
|
||||
|
||||
/// libgit2's own message for a SwiftGitX error — far more useful than the struct's synthesized
|
||||
/// description — falling back to the description for anything else.
|
||||
private static func reason(_ error: any Error) -> String {
|
||||
if let gitError = error as? SwiftGitXError { return gitError.message }
|
||||
return String(describing: error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - HistoryStore
|
||||
|
||||
/// **A board's git state** (02-architecture.md ▸ Components ▸ HistoryStore): which mode the board
|
||||
/// opened in, the repository behind it when there is one, and the two operations that can change
|
||||
/// either — the app's own add-git, and nothing else.
|
||||
///
|
||||
/// ### One per board session, composed under the tier
|
||||
///
|
||||
/// `compose(boardRoot:tier:)` is the whole gate: **the free tier gets no `HistoryStore` at all**, so
|
||||
/// a free-tier session runs no detection, opens no repository, and does not so much as `stat` a
|
||||
/// `.git` — "any `.git` is inert … the app never reads history, never commits, never touches `.git`
|
||||
/// in any way" (12-editions.md ▸ The free tier and `.git`), which `InertGitTests` pins against real
|
||||
/// bytes. Nothing in this type is conditional on a tier, because the tier decided whether the type
|
||||
/// exists.
|
||||
///
|
||||
/// ### What it does not do yet
|
||||
///
|
||||
/// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history
|
||||
/// ranker. **The provider binding is not part of it** — both tiers still bind
|
||||
/// `NativeHistoryProvider` (`AppModel.makeHistoryProvider`), and the consumer of `mode` is the
|
||||
/// undo/redo card two cards later, which builds the git `HistoryProviding` implementation over
|
||||
/// exactly this object. Auto-commit, commit messages, branch controls, the identity fields, remotes
|
||||
/// and `.gitignore` seeding are each their own card and deliberately absent here.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class HistoryStore {
|
||||
|
||||
/// The board this is the git state of. The board root *is* the repository's working-tree root
|
||||
/// in git mode — that is what mode `git` means.
|
||||
public let boardRoot: URL
|
||||
|
||||
/// **Detected once, at composition, and changed by exactly one thing afterwards.**
|
||||
///
|
||||
/// "Detection is nearest-`.git`-wins, checked at every board open … never mid-session"
|
||||
/// (06-history-undo.md ▸ Rules). A `git init` run in a terminal under an open board therefore
|
||||
/// takes effect at its *next* open — the watcher does not scan for `.git` appearing, and nothing
|
||||
/// re-runs `BoardGitMode.detect` for the life of this object.
|
||||
///
|
||||
/// The one deliberate mid-session transition is `addGit()` below: "the rule forbids *discovered*
|
||||
/// flips, never commanded ones."
|
||||
public private(set) var mode: BoardGitMode
|
||||
|
||||
/// The current branch's short name in git mode, `nil` until it has been read (or when there is
|
||||
/// nothing to read).
|
||||
///
|
||||
/// Filled by `refreshBranch()` rather than at composition, deliberately: composition happens on
|
||||
/// the board-open path, where 02-architecture.md's hang-avoidance doctrine says nothing may
|
||||
/// block, and opening a repository is libgit2 work — small, but work. Detection is a `stat`;
|
||||
/// this is a read, and it waits until the popover actually asks.
|
||||
public private(set) var branch: String?
|
||||
|
||||
/// Whether add-git is in flight — the button's disabled state, and the guard that keeps a double
|
||||
/// click from running `git_repository_init` twice.
|
||||
public private(set) var isAddingGit = false
|
||||
|
||||
/// The last add-git failure, or `nil` if the last attempt succeeded (or there hasn't been one).
|
||||
///
|
||||
/// Surfaced inline in the popover rather than as a banner: the popover is where the operation
|
||||
/// was asked for and is still open when it answers, and 02-architecture.md's one-shot banner
|
||||
/// vocabulary is for failures of writes the user made *elsewhere*. DESIGN does not settle
|
||||
/// add-git's failure surface either way.
|
||||
public private(set) var lastFailure: GitOperationFailure?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
init(boardRoot: URL, mode: BoardGitMode) {
|
||||
self.boardRoot = boardRoot
|
||||
self.mode = mode
|
||||
}
|
||||
|
||||
/// **The tier gate and the open-time detection, in one line** (12-editions.md ▸ The provider
|
||||
/// seam; 06-history-undo.md ▸ Rules ▸ Detection) — called by `AppModel.beginSession` beside the
|
||||
/// entitlement read that supplies `tier`.
|
||||
///
|
||||
/// `nil` under `.free` means exactly what it says: no git state exists for that session, so no
|
||||
/// caller can accidentally consult one. Under `.pro` the mode is whatever the filesystem says
|
||||
/// right now, and a board that has changed mode since its last open simply opens in the new one
|
||||
/// — "the app just reflects what it finds".
|
||||
///
|
||||
/// **Adoption needs no step of its own**: a board whose root already carries `.git` lands in
|
||||
/// `.git` here, silently, with no dialog and nothing to confirm — "the repo's presence *is* the
|
||||
/// opt-in" (06 ▸ Rules ▸ Adoption).
|
||||
public static func compose(boardRoot: URL, tier: Tier) -> HistoryStore? {
|
||||
guard tier == .pro else { return nil }
|
||||
let mode = BoardGitMode.detect(boardRoot: boardRoot)
|
||||
logger.debug("board opened in git mode \(mode.rawValue, privacy: .public)")
|
||||
return HistoryStore(boardRoot: boardRoot, mode: mode)
|
||||
}
|
||||
|
||||
// MARK: - Add git
|
||||
|
||||
/// **Opt-in init** (06-history-undo.md ▸ Rules): initializes a repository at the board root and
|
||||
/// immediately commits the whole tree as "Initial board state".
|
||||
///
|
||||
/// Reachable from one place — the board popover's git section under Pro — and from nowhere else:
|
||||
/// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo
|
||||
/// under every board it opened.
|
||||
///
|
||||
/// **It flips the open board's mode immediately**, which is the design's one sanctioned
|
||||
/// mid-session transition: "clicking it flips the open board into git mode immediately — the
|
||||
/// popover flows straight into the git controls". The flip is commanded, not discovered, which
|
||||
/// is what distinguishes it from the `git init` a user runs in a terminal under an open board.
|
||||
///
|
||||
/// Only mode `none` can be added to. Mode `git` has nothing to add, and a repo-nested board is
|
||||
/// one the app "leaves strictly alone" — no nested repo, ever.
|
||||
@discardableResult
|
||||
public func addGit() async -> Bool {
|
||||
guard mode == .none, !isAddingGit else { return false }
|
||||
|
||||
isAddingGit = true
|
||||
lastFailure = nil
|
||||
defer { isAddingGit = false }
|
||||
|
||||
let root = boardRoot
|
||||
// Off the main actor: `git_repository_init` plus a whole-tree stage and commit is real
|
||||
// filesystem work, and the popover it was clicked in stays live while it runs.
|
||||
let outcome = await Task.detached(priority: .userInitiated) {
|
||||
GitRepository.create(at: root)
|
||||
}.value
|
||||
|
||||
switch outcome {
|
||||
case .success(let branchName):
|
||||
mode = .git
|
||||
branch = branchName
|
||||
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
||||
return true
|
||||
case .failure(let failure):
|
||||
lastFailure = failure
|
||||
Self.logger.error("add-git failed: \(failure.description, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the current branch name into `branch` — the popover's read-only display line, refreshed
|
||||
/// when the popover opens. A no-op outside git mode.
|
||||
public func refreshBranch() async {
|
||||
guard mode == .git else { return }
|
||||
let root = boardRoot
|
||||
branch = await Task.detached(priority: .userInitiated) {
|
||||
GitRepository.branchName(at: root)
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - The loader's history seam
|
||||
|
||||
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md ▸ Fractal layout ▸ Rules;
|
||||
/// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for — the
|
||||
/// free tier and modes `none`/`repoNested` alike, all of which fall through to the ladder's
|
||||
/// remaining rungs (birth date, then traversal order).
|
||||
///
|
||||
/// **A fresh ranker per ask, deliberately.** Each one computes its map at most once, lazily, and
|
||||
/// only if something actually asks — which is only when a duplicate identity was found, since
|
||||
/// that is the only thing `BoardLoader.dedupeIdentities` consults it for. A ranker cached across
|
||||
/// loads would answer from a history that has since moved; one built per load never can.
|
||||
public var identityHistoryRanker: BoardLoader.IdentityHistoryRanker? {
|
||||
guard mode == .git else { return nil }
|
||||
return GitPathHistory(boardRoot: boardRoot).ranker
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,28 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
|
||||
|
||||
/// **Where git path history reaches the loader** (01-storage-format.md ▸ Fractal layout
|
||||
/// ▸ Rules, the duplicate-id winner rule; `BoardLoader.IdentityHistoryRanker`) — `nil` on every
|
||||
/// board the app manages no git for, which is every free-tier board and every Pro board without
|
||||
/// a repo at its root.
|
||||
///
|
||||
/// A **provider** rather than a ranker, for two reasons that point the same way. Each load wants
|
||||
/// its own ranker, so that a load never answers from a history that has moved since the last one
|
||||
/// (the ranker caches internally, once, per load). And add-git flips a board into git mode
|
||||
/// mid-session, which a closure asked at load time absorbs by construction while a value handed
|
||||
/// over at composition never could.
|
||||
///
|
||||
/// `@MainActor` because it is called here, on the main actor, at the head of each reload; what
|
||||
/// it returns is `Sendable` and does its git work off-main, inside the walk that consults it.
|
||||
///
|
||||
/// **The board's first load predates this** — `init` runs inside `BoardStoreRegistry.acquire`,
|
||||
/// before a session exists to compose the git state that supplies it — so an opening board's
|
||||
/// duplicate-id ladder falls through to birth date, and every reload after it consults history.
|
||||
/// Deliberate, and the narrow cost of composing the git state where the design puts it
|
||||
/// (`AppModel.beginSession`) rather than where the first walk happens to run.
|
||||
@ObservationIgnored
|
||||
var makeIdentityHistoryRanker: (@MainActor () -> BoardLoader.IdentityHistoryRanker?)?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
|
||||
|
||||
// MARK: - Init
|
||||
@@ -628,6 +650,10 @@ public final class BoardStore: HealHost {
|
||||
let generation = reloadGeneration
|
||||
let root = rootURL
|
||||
let barrier = loadBarrier
|
||||
// Asked once per load, on the main actor, and answered off it: what comes back is a lazy
|
||||
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
|
||||
// break a tie for. `nil` everywhere the app manages no git.
|
||||
let historyRanker = makeIdentityHistoryRanker?()
|
||||
reloadInFlight = true
|
||||
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
|
||||
|
||||
@@ -636,7 +662,7 @@ public final class BoardStore: HealHost {
|
||||
// and the loader's typed error is lost on the way into `Result`.
|
||||
let outcome: Result<LoadResult, BoardLoadError>
|
||||
do throws(BoardLoadError) {
|
||||
outcome = .success(try BoardLoader.load(boardRoot: root))
|
||||
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
|
||||
} catch {
|
||||
outcome = .failure(error)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,13 @@ struct BoardInfoWidget: View {
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
|
||||
/// The tier and the git state this board's **session** composed with — read once, at the moment
|
||||
/// the widget is installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse
|
||||
/// never interrupts an open session"). `git` is a reference type and `@Observable`, so add-git
|
||||
/// flipping the mode redraws the popover without anything here being re-created.
|
||||
let tier: Tier
|
||||
let git: HistoryStore?
|
||||
|
||||
@Bindable var presentation: BoardInfoPresentation
|
||||
|
||||
var body: some View {
|
||||
@@ -87,7 +94,7 @@ struct BoardInfoWidget: View {
|
||||
.help("Board Info")
|
||||
.accessibilityLabel("Board Info")
|
||||
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
|
||||
BoardInfoView(store: store, recents: recents)
|
||||
BoardInfoView(store: store, recents: recents, tier: tier, git: git)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,15 +107,23 @@ struct BoardInfoWidget: View {
|
||||
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
|
||||
/// SwiftUI's, and anything hung on it has to be taken back off.
|
||||
@MainActor
|
||||
/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — so that
|
||||
/// a caller with no session in hand (the accessory-installation tests, which are about AppKit
|
||||
/// plumbing rather than about git) describes a board honestly rather than by accident. The app's own
|
||||
/// call site passes the session's values explicitly.
|
||||
func boardInfoTitlebarAccessory(
|
||||
store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
tier: Tier = .free,
|
||||
git: HistoryStore? = nil,
|
||||
presentation: BoardInfoPresentation
|
||||
) -> NSTitlebarAccessoryViewController {
|
||||
let hosting = NSHostingView(
|
||||
rootView: BoardInfoWidget(
|
||||
store: store,
|
||||
recents: recents,
|
||||
tier: tier,
|
||||
git: git,
|
||||
presentation: presentation
|
||||
)
|
||||
)
|
||||
@@ -135,10 +150,16 @@ struct BoardInfoView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let tier: Tier
|
||||
let git: HistoryStore?
|
||||
|
||||
/// Whether this board carries a `.git` — checked once, off disk, when the view is built (which
|
||||
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
|
||||
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
|
||||
///
|
||||
/// **The free tier's input only.** Under Pro the section reads the session's detected mode
|
||||
/// instead — a fact settled at open, which is where 06-history-undo.md puts detection — and this
|
||||
/// stays what it always was: the one quiet question the free tier asks of a board's folder.
|
||||
private let hasGitDirectory: Bool
|
||||
|
||||
/// The style editor brings its own padding, so the sections around it carry the same number by
|
||||
@@ -149,10 +170,14 @@ struct BoardInfoView: View {
|
||||
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
}
|
||||
|
||||
init(store: BoardStore, recents: StyleRecents) {
|
||||
init(store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil) {
|
||||
self.store = store
|
||||
self.recents = recents
|
||||
self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL)
|
||||
self.tier = tier
|
||||
self.git = git
|
||||
// Asked only where it is the answer: under Pro the mode already knows, and a free-tier
|
||||
// board is the only one this question is for (12-editions.md ▸ The free tier and `.git`).
|
||||
self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -176,15 +201,12 @@ struct BoardInfoView: View {
|
||||
StyleEditorView(store: store, recents: recents, target: .board)
|
||||
}
|
||||
|
||||
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary board adds
|
||||
// nothing here at all — no header, no divider, no placeholder — and the popover ends at
|
||||
// Styling, complete in itself. Only a board that actually carries an inert `.git` earns
|
||||
// this closing note.
|
||||
if hasGitDirectory {
|
||||
Divider()
|
||||
BoardGitNote()
|
||||
.padding(inset)
|
||||
}
|
||||
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier
|
||||
// board adds nothing here at all — no header, no divider, no placeholder — and the
|
||||
// popover ends at Styling, complete in itself. What a Pro board adds instead is the
|
||||
// mode-aware git section (03-board-ui.md ▸ Board popover), which is a *section*, header
|
||||
// and all, because under Pro git is a feature of the board rather than a signpost.
|
||||
gitSection
|
||||
}
|
||||
// The style editor's popover width, taken from the editor rather than restated: the embed
|
||||
// below must lay out here exactly as it does at its other two anchors, and that number is
|
||||
@@ -192,6 +214,49 @@ 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
|
||||
/// `BoardGitSection`.
|
||||
@ViewBuilder
|
||||
private var gitSection: some View {
|
||||
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
|
||||
case .absent:
|
||||
EmptyView()
|
||||
|
||||
case .proPointer:
|
||||
Divider()
|
||||
BoardGitNote()
|
||||
.padding(inset)
|
||||
|
||||
case .addGit:
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Git")
|
||||
if let git {
|
||||
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
|
||||
}
|
||||
}
|
||||
.padding(inset)
|
||||
|
||||
case .repoNested:
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Git")
|
||||
BoardGitNestedNote()
|
||||
}
|
||||
.padding(inset)
|
||||
|
||||
case .branch:
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Git")
|
||||
if let git {
|
||||
BoardGitBranchLine(git: git)
|
||||
}
|
||||
}
|
||||
.padding(inset)
|
||||
}
|
||||
}
|
||||
|
||||
/// The section titles, matching the style editor's own headers so the popover reads as one
|
||||
/// surface rather than borrowed ones.
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
@@ -278,6 +343,125 @@ private struct BoardRenameField: View {
|
||||
|
||||
// MARK: - Git
|
||||
|
||||
/// **What the popover's git slot is, for one board** (03-board-ui.md ▸ Board popover;
|
||||
/// 06-history-undo.md ▸ Rules; 12-editions.md ▸ The free tier and `.git`) — a pure function of two
|
||||
/// facts, so the posture matrix is provable without a popover on screen.
|
||||
///
|
||||
/// The free tier's two cases are settled 2026-07-27 and unchanged by this card: absent on an
|
||||
/// ordinary board, a one-line Pro pointer on a board carrying an inert `.git`. The Pro cases are the
|
||||
/// 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).
|
||||
enum BoardGitSection: Equatable, CaseIterable {
|
||||
|
||||
/// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
|
||||
/// complete in itself".
|
||||
case absent
|
||||
|
||||
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
|
||||
/// to Pro (12 ▸ Tier naming).
|
||||
case proPointer
|
||||
|
||||
/// Pro, mode `none`: the add-git action (06 ▸ Rules ▸ Opt-in init).
|
||||
case addGit
|
||||
|
||||
/// Pro, repo-nested: the honest explanation, no action.
|
||||
case repoNested
|
||||
|
||||
/// Pro, git mode: the read-only branch/source line. Branch switching and creation, the commit
|
||||
/// identity fields and the remote controls are later cards — nothing here is a control.
|
||||
case branch
|
||||
|
||||
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
||||
switch tier {
|
||||
case .free:
|
||||
// Detection never runs under the free tier, so the mode is not consulted here — the one
|
||||
// question asked is whether the folder carries a `.git`, which is what the pointer is
|
||||
// about (12: "any `.git` is inert … a stray like any other, preserved verbatim").
|
||||
return hasGitDirectory ? .proPointer : .absent
|
||||
case .pro:
|
||||
switch mode {
|
||||
case .none: return .addGit
|
||||
case .git: return .branch
|
||||
case .repoNested: return .repoNested
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The add-git action** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the one place in the app that
|
||||
/// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than
|
||||
/// a promise: there is no other caller of `HistoryStore.addGit`.
|
||||
///
|
||||
/// The caption states what pressing it does, in the order it happens, because it is not undoable in
|
||||
/// the ordinary sense: a repository appears in the board's folder and its current state becomes the
|
||||
/// first commit.
|
||||
private struct BoardGitAddAction: View {
|
||||
|
||||
let git: HistoryStore
|
||||
/// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses
|
||||
/// writes refuses this one too — initializing a repository is a write, and a commit is several.
|
||||
let isEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Button("Add Git") {
|
||||
Task { await git.addGit() }
|
||||
}
|
||||
.disabled(!isEnabled || git.isAddingGit)
|
||||
|
||||
Text("Creates a git repository in this board's folder and commits its current state.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let failure = git.lastFailure {
|
||||
Text(failure.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it:
|
||||
/// short prose in place of an action, never a hidden or greyed-out add-git.
|
||||
private struct BoardGitNestedNote: View {
|
||||
|
||||
var body: some View {
|
||||
Text("This board lives inside a repository; Lanework leaves it to that repository.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The branch/source display** (03-board-ui.md ▸ Board popover) — read-only, and deliberately the
|
||||
/// whole of the git-mode section for now: switching, creation, identity and remotes are each their
|
||||
/// own card, and a control shown before it works is worse than one that isn't there yet.
|
||||
///
|
||||
/// The name is read when the popover appears rather than at session composition, so the open path
|
||||
/// never waits on libgit2 (`HistoryStore.branch`).
|
||||
private struct BoardGitBranchLine: View {
|
||||
|
||||
let git: HistoryStore
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(git.branch ?? "…")
|
||||
.font(.callout)
|
||||
.foregroundStyle(git.branch == nil ? .secondary : .primary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(git.branch.map { "Branch \($0)" } ?? "Reading branch")
|
||||
.task { await git.refreshBranch() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user