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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **Nearest-`.git`-wins, freshly at every open** (06-history-undo.md ▸ Rules ▸ Detection) — the one
|
||||
/// question every git surface starts from, pinned against real directories rather than against a
|
||||
/// mocked filesystem, because what it is *about* is what is on disk.
|
||||
///
|
||||
/// The rule has four claims and this file is one test per claim: root wins, an ancestor is nested,
|
||||
/// neither is `none`, and the answer is re-derived rather than remembered — "a board can therefore
|
||||
/// change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects
|
||||
/// what it finds."
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A `.git` **directory** with a plausible ref inside — what `git init` leaves.
|
||||
private func makeGitDirectory(at parent: URL) throws {
|
||||
let gitDirectory = parent.appendingPathComponent(".git", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: gitDirectory, withIntermediateDirectories: true)
|
||||
try Data("ref: refs/heads/main\n".utf8).write(to: gitDirectory.appendingPathComponent("HEAD"))
|
||||
}
|
||||
|
||||
/// A `.git` **file** — what a linked worktree or a submodule leaves. Still a repository, and the
|
||||
/// reason detection asks `fileExists` rather than `isDirectory`.
|
||||
private func makeGitPointerFile(at parent: URL, to target: String) throws {
|
||||
try Data("gitdir: \(target)\n".utf8).write(to: parent.appendingPathComponent(".git"))
|
||||
}
|
||||
|
||||
/// A board folder inside the fixture, so an *ancestor* can carry the repository.
|
||||
private func makeSubfolder(_ fixture: WriterFixture, named name: String) throws -> URL {
|
||||
let url = fixture.root.appendingPathComponent(name, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
// MARK: - Detection
|
||||
|
||||
@Suite("Board git mode ▸ detection")
|
||||
struct BoardGitModeTests {
|
||||
|
||||
@Test("A `.git` at the board root is git mode")
|
||||
func rootRepositoryIsGitMode() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try makeGitDirectory(at: fixture.root)
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
|
||||
}
|
||||
|
||||
@Test("A `.git` *file* is a repository too — a worktree is not mode none")
|
||||
func aWorktreePointerIsGitMode() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try makeGitPointerFile(at: fixture.root, to: "/somewhere/else/.git/worktrees/board")
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
|
||||
}
|
||||
|
||||
@Test("No `.git` at the root and none above it is mode none")
|
||||
func aPlainFolderIsModeNone() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
|
||||
}
|
||||
|
||||
@Test("A `.git` at an ancestor and none at the root is repo-nested")
|
||||
func anEnclosingRepositoryIsRepoNested() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeGitDirectory(at: fixture.root)
|
||||
let board = try makeSubfolder(fixture, named: "project/docs/board")
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: board) == .repoNested)
|
||||
#expect(BoardGitMode.enclosingRepositoryRoot(above: board)?.standardizedFileURL
|
||||
== fixture.root.standardizedFileURL)
|
||||
}
|
||||
|
||||
@Test("Nearest wins: a board with its own `.git` inside a repository is git mode, not nested")
|
||||
func theNearestRepositoryWins() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try makeGitDirectory(at: fixture.root)
|
||||
let board = try makeSubfolder(fixture, named: "board")
|
||||
try makeGitDirectory(at: board)
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: board) == .git)
|
||||
}
|
||||
|
||||
@Test("The walk terminates above a board at the filesystem root, finding nothing")
|
||||
func theAncestorWalkTerminates() {
|
||||
// The one hazard this walk has ever had: NSURL-bridged URLs whose
|
||||
// `deletingLastPathComponent` grows "/.." forever instead of settling at "/". The temp
|
||||
// directory has no repository above it, so the honest answer is `nil` — reached, not hung.
|
||||
#expect(BoardGitMode.enclosingRepositoryRoot(above: URL(fileURLWithPath: "/")) == nil)
|
||||
}
|
||||
|
||||
// MARK: Freshness
|
||||
|
||||
@Test("Detection is re-derived at every open: a board that gains a `.git` opens in git mode next time")
|
||||
func modeChangesBetweenOpens() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none, "the first open")
|
||||
|
||||
// What a user does in a terminal under a closed board.
|
||||
try makeGitDirectory(at: fixture.root)
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git, "the next open reflects what it finds")
|
||||
}
|
||||
|
||||
@Test("And a board that loses its `.git` opens back in mode none")
|
||||
func modeChangesBackBetweenOpens() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try makeGitDirectory(at: fixture.root)
|
||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".git"))
|
||||
|
||||
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,49 @@ struct BoardInfoPopoverTests {
|
||||
#expect(!BoardGitNote.hasGitDirectory(at: fixture.root))
|
||||
}
|
||||
}
|
||||
|
||||
/// **The popover's git slot, posture by posture** (03-board-ui.md ▸ Board popover; 06-history-undo.md
|
||||
/// ▸ Rules; 12-editions.md ▸ The free tier and `.git`).
|
||||
///
|
||||
/// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the tier and the
|
||||
/// board's mode precisely so the matrix is assertable — the views it selects are SwiftUI and stay
|
||||
/// untested, exactly as the note's wording and placement do above.
|
||||
@Suite("Board popover ▸ the git section's posture")
|
||||
struct BoardGitSectionTests {
|
||||
|
||||
@Test("The free tier: absent on an ordinary board, a Pro pointer on a board carrying an inert .git")
|
||||
func theFreeTierIsContextual() {
|
||||
#expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: false) == .absent)
|
||||
#expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: true) == .proPointer)
|
||||
}
|
||||
|
||||
@Test("Pro: mode none offers add-git, git mode shows the branch")
|
||||
func proFollowsTheMode() {
|
||||
#expect(BoardGitSection.resolve(tier: .pro, mode: .none, hasGitDirectory: false) == .addGit)
|
||||
#expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch)
|
||||
}
|
||||
|
||||
@Test("A repo-nested board explains itself — the add-git action is absent, not disabled")
|
||||
func repoNestedExplainsRatherThanDisables() {
|
||||
let section = BoardGitSection.resolve(tier: .pro, mode: .repoNested, hasGitDirectory: false)
|
||||
|
||||
// The design is insistent here: "not a hidden 'add git' but a short explanation … the option
|
||||
// is absent because it *can't* apply, and the UI should teach that rather than look broken"
|
||||
// (06 ▸ Rules). A `.addGit` that rendered disabled would satisfy neither half.
|
||||
#expect(section == .repoNested)
|
||||
#expect(section != .addGit)
|
||||
}
|
||||
|
||||
@Test("Every posture is reachable, and none of them is two postures")
|
||||
func theMatrixIsTotal() {
|
||||
let resolved = Set(
|
||||
Tier.allCases.flatMap { tier in
|
||||
BoardGitMode.allCases.flatMap { mode in
|
||||
[true, false].map { BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: $0) }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
#expect(resolved == Set(BoardGitSection.allCases))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **Where the app's commits get their author from** (06-history-undo.md ▸ Interaction with external
|
||||
/// writers ▸ "Where the user's git identity comes from"): repo-local `.git/config` when it names
|
||||
/// one, the derived `Full Name <shortname@hostname>` default when it doesn't.
|
||||
///
|
||||
/// Both halves are pure functions here on purpose. The derivation takes its three strings as
|
||||
/// arguments rather than reading the machine, so the *shape* is provable on any machine — including
|
||||
/// one whose account has no full name, which is the case the fallbacks exist for. And the config
|
||||
/// read is a parse over text, so the format's edges (comments, quoting, subsections, a `[user]`
|
||||
/// section that never appears) are pinned without a repository.
|
||||
|
||||
@Suite("Git identity ▸ the derived default")
|
||||
struct GitIdentityDerivationTests {
|
||||
|
||||
@Test("The shape is the account's full name plus shortname@hostname")
|
||||
func theDerivedShape() {
|
||||
let identity = GitIdentity.derived(fullName: "Ada Lovelace", accountName: "ada", hostName: "analytical.local")
|
||||
|
||||
#expect(identity.name == "Ada Lovelace")
|
||||
#expect(identity.email == "[email protected]")
|
||||
}
|
||||
|
||||
@Test("An account with no full name falls back to its short name rather than committing as \"\"")
|
||||
func anEmptyFullNameFallsBack() {
|
||||
let identity = GitIdentity.derived(fullName: " ", accountName: "ada", hostName: "analytical.local")
|
||||
|
||||
#expect(identity.name == "ada")
|
||||
#expect(identity.email == "[email protected]")
|
||||
}
|
||||
|
||||
@Test("Characters an address may not carry are collapsed, not passed to libgit2")
|
||||
func addressComponentsAreSanitized() {
|
||||
// libgit2 refuses a signature carrying a space or an angle bracket outright — the commit
|
||||
// fails rather than looking odd — so this is a correctness fallback, not cosmetics.
|
||||
let identity = GitIdentity.derived(
|
||||
fullName: "Ada Lovelace",
|
||||
accountName: "ada lovelace",
|
||||
hostName: "Ada's <Mac>.local"
|
||||
)
|
||||
|
||||
#expect(!identity.email.contains(" "))
|
||||
#expect(!identity.email.contains("<"))
|
||||
#expect(!identity.email.contains(">"))
|
||||
#expect(identity.email == "[email protected]")
|
||||
}
|
||||
|
||||
@Test("A machine with no name reads localhost, and an account with none reads user")
|
||||
func emptyComponentsHaveHonestFallbacks() {
|
||||
let identity = GitIdentity.derived(fullName: "", accountName: "", hostName: "")
|
||||
|
||||
#expect(identity.name == "Lanework")
|
||||
#expect(identity.email == "user@localhost")
|
||||
}
|
||||
|
||||
@Test("A trailing dot on a fully-qualified host name is dropped")
|
||||
func aTrailingDotIsDropped() {
|
||||
let identity = GitIdentity.derived(fullName: "Ada", accountName: "ada", hostName: "host.example.com.")
|
||||
|
||||
#expect(identity.email == "[email protected]")
|
||||
}
|
||||
|
||||
@Test("This machine's derived default is well-formed, whatever this machine is called")
|
||||
func theMachineDefaultIsWellFormed() {
|
||||
let identity = GitIdentity.derivedDefault()
|
||||
|
||||
#expect(!identity.name.isEmpty)
|
||||
#expect(identity.email.contains("@"))
|
||||
#expect(!identity.email.contains(" "))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Git identity ▸ repo-local config wins")
|
||||
struct GitConfigFileTests {
|
||||
|
||||
@Test("A `[user]` section supplies both halves")
|
||||
func bothKeysAreRead() {
|
||||
let text = """
|
||||
[core]
|
||||
\trepositoryformatversion = 0
|
||||
[user]
|
||||
\tname = Ada Lovelace
|
||||
\temail = ada@example.com
|
||||
"""
|
||||
|
||||
let identity = GitConfigFile.identity(inConfigText: text)
|
||||
#expect(identity.name == "Ada Lovelace")
|
||||
#expect(identity.email == "[email protected]")
|
||||
}
|
||||
|
||||
@Test("Config wins over the derived default, key by key")
|
||||
func resolutionPrefersConfigPerKey() {
|
||||
let derived = GitIdentity(name: "Machine Owner", email: "[email protected]")
|
||||
|
||||
let both = GitIdentity.resolve(repoLocal: (name: "Ada", email: "[email protected]"), derived: derived)
|
||||
#expect(both == GitIdentity(name: "Ada", email: "[email protected]"))
|
||||
|
||||
// Half-configured is a real state — it is what a `git config user.email` typo leaves — and
|
||||
// git resolves each key on its own.
|
||||
let nameOnly = GitIdentity.resolve(repoLocal: (name: "Ada", email: nil), derived: derived)
|
||||
#expect(nameOnly == GitIdentity(name: "Ada", email: "[email protected]"))
|
||||
|
||||
let neither = GitIdentity.resolve(repoLocal: (name: nil, email: " "), derived: derived)
|
||||
#expect(neither == derived, "a blank value is not a value")
|
||||
}
|
||||
|
||||
@Test("Comments, quoting and subsections are read the way git reads them")
|
||||
func theParseHandlesTheFormatsEdges() {
|
||||
let text = """
|
||||
# a comment
|
||||
; another
|
||||
[user "work"]
|
||||
\tname = Wrong Section
|
||||
[user]
|
||||
\tname = "Ada # Lovelace"
|
||||
\temail = ada@example.com # trailing comment
|
||||
"""
|
||||
|
||||
let identity = GitConfigFile.identity(inConfigText: text)
|
||||
// `[user "work"]` is a subsection but still the `user` section — git reads its keys as
|
||||
// `user.name` under a subsection name, and this parse deliberately takes the last value it
|
||||
// meets rather than inventing subsection scoping for a file that has none in practice.
|
||||
#expect(identity.name == "Ada # Lovelace", "a `#` inside quotes is content")
|
||||
#expect(identity.email == "[email protected]", "an unquoted trailing comment is not")
|
||||
}
|
||||
|
||||
@Test("A config with no `[user]` section, or no config at all, names nobody")
|
||||
func absentConfigNamesNobody() throws {
|
||||
let empty = GitConfigFile.identity(inConfigText: "[core]\n\tbare = false\n")
|
||||
#expect(empty.name == nil)
|
||||
#expect(empty.email == nil)
|
||||
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let missing = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
|
||||
#expect(missing.name == nil)
|
||||
#expect(missing.email == nil)
|
||||
}
|
||||
|
||||
@Test("The file on disk is what is read — the board root's own `.git/config`")
|
||||
func theFileIsRead() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file(".git/config", Data("[user]\n\tname = Ada\n\temail = [email protected]\n".utf8))
|
||||
|
||||
let identity = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
|
||||
#expect(identity.name == "Ada")
|
||||
#expect(identity.email == "[email protected]")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import Foundation
|
||||
import SwiftGitX
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components
|
||||
/// ▸ HistoryStore) — composed under the tier, detected at open, and changed afterwards by exactly
|
||||
/// one thing.
|
||||
///
|
||||
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
|
||||
/// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with
|
||||
/// bundled libgit2 and no external git dependency", and a fixture faked out of hand-written files
|
||||
/// could not tell whether that happened. Nothing in this file shells out to `git` — there is no
|
||||
/// `/usr/bin/git` in the promise this feature makes, so there is none in its tests either.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A board with one lane and one card — small, and enough for a tree with three `index.md`s in it.
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// A `.git` that is not a repository — a directory with a plausible `HEAD` in it. Enough for
|
||||
/// *detection*, which asks the filesystem one question, and deliberately not enough for libgit2,
|
||||
/// which is how a test can tell the two apart.
|
||||
private func plantGitDirectory(in fixture: WriterFixture, under parent: String = "") throws {
|
||||
let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
|
||||
try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
|
||||
}
|
||||
|
||||
/// A second (third, fourth) commit, made the way an external writer makes one — SwiftGitX directly,
|
||||
/// not through the app, which has no commit surface until the auto-commit card.
|
||||
private func commitEverything(at boardRoot: URL, message: String) throws {
|
||||
let repository = try Repository.open(at: boardRoot)
|
||||
try repository.add(paths: [])
|
||||
_ = try repository.commit(message: message)
|
||||
}
|
||||
|
||||
/// Bytes and mtimes of everything under a subtree — `InertGitTests`' instrument, in the shape this
|
||||
/// file needs it: what proves that *reading* a board's mode touched nothing.
|
||||
private struct SubtreeEntry: Equatable {
|
||||
let path: String
|
||||
let data: Data?
|
||||
let modified: Date
|
||||
}
|
||||
|
||||
private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] {
|
||||
let base = root.appendingPathComponent(".git", isDirectory: true)
|
||||
let manager = FileManager.default
|
||||
guard let walker = manager.enumerator(atPath: base.path) else { return [] }
|
||||
|
||||
var entries: [SubtreeEntry] = []
|
||||
for case let relative as String in walker {
|
||||
let url = base.appendingPathComponent(relative)
|
||||
let attributes = try manager.attributesOfItem(atPath: url.path)
|
||||
guard let modified = attributes[.modificationDate] as? Date else { continue }
|
||||
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
|
||||
entries.append(SubtreeEntry(
|
||||
path: relative,
|
||||
data: isDirectory ? nil : try Data(contentsOf: url),
|
||||
modified: modified
|
||||
))
|
||||
}
|
||||
return entries.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
// MARK: - Composition
|
||||
|
||||
@MainActor
|
||||
@Suite("HistoryStore ▸ composition and the tier gate")
|
||||
struct HistoryStoreCompositionTests {
|
||||
|
||||
@Test("The free tier composes no git state at all, on any board")
|
||||
func theFreeTierComposesNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try plantGitDirectory(in: fixture)
|
||||
|
||||
// Not "mode none on a git board" — *nothing*. With no object there is no path by which a
|
||||
// free-tier session could read history, commit, or touch `.git` (12-editions.md ▸ The free
|
||||
// tier and `.git`, whose byte-level half is `InertGitTests`).
|
||||
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
|
||||
}
|
||||
|
||||
@Test("Pro on a plain board is mode none — and opening one never creates a repository")
|
||||
func proOnAPlainBoardIsModeNone() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.mode == .none)
|
||||
|
||||
// "No silent auto-init, ever" (06 ▸ Rules) — the deliberate pivot from the pathfinder, which
|
||||
// initialized a repository under every board it opened. Composing twice is the whole test:
|
||||
// two opens, no repository.
|
||||
_ = HistoryStore.compose(boardRoot: fixture.root, tier: .pro)
|
||||
#expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in")
|
||||
}
|
||||
|
||||
@Test("A board whose root already has a repository opens in git mode, silently")
|
||||
func adoptionNeedsNoStep() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
// The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own
|
||||
// add-git in a previous session), and the repository is simply there.
|
||||
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await first.addGit())
|
||||
let afterInit = try #require(GitRepository.headCommit(at: fixture.root))
|
||||
let before = try snapshotGitDirectory(fixture.root)
|
||||
|
||||
// The next open. Adoption is not init: no dialog, no confirmation, no second step — the mode
|
||||
// is simply what the filesystem says, and it says git.
|
||||
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(second.mode == .git)
|
||||
|
||||
// And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes.
|
||||
// Composition is a `stat`, not an operation.
|
||||
#expect(GitRepository.headCommit(at: fixture.root)?.oid == afterInit.oid)
|
||||
#expect(try snapshotGitDirectory(fixture.root) == before)
|
||||
}
|
||||
|
||||
@Test("A board nested inside a repository opens repo-nested")
|
||||
func nestedBoardsAreDetectedAsNested() throws {
|
||||
let outer = try WriterFixture()
|
||||
defer { outer.tearDown() }
|
||||
try plantGitDirectory(in: outer)
|
||||
|
||||
let boardRoot = outer.root.appendingPathComponent("docs/board", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
||||
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
|
||||
#expect(git.mode == .repoNested)
|
||||
}
|
||||
|
||||
@Test("Mode is an open-time fact: a `.git` appearing mid-session does not flip the open board")
|
||||
func noMidSessionDiscoveredFlip() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.mode == .none)
|
||||
|
||||
// What a `git init` in a terminal under an open board does — which is nothing, until the
|
||||
// next open (06 ▸ Rules: "the running session keeps its mode, and the watcher does not scan
|
||||
// for `.git` appearing").
|
||||
try plantGitDirectory(in: fixture)
|
||||
#expect(git.mode == .none, "the open session keeps the mode it composed with")
|
||||
|
||||
let nextOpen = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(nextOpen.mode == .git, "and the next open reflects what it finds")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add git
|
||||
|
||||
@MainActor
|
||||
@Suite("HistoryStore ▸ add-git")
|
||||
struct HistoryStoreAddGitTests {
|
||||
|
||||
@Test("Add-git initializes a repository at the board root and commits the whole tree")
|
||||
func addGitInitializesAndCommits() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await git.addGit())
|
||||
|
||||
#expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install")
|
||||
#expect(git.mode == .git, "the one commanded mid-session flip")
|
||||
|
||||
let head = try #require(GitRepository.headCommit(at: fixture.root))
|
||||
// "The root commit has its own subject" (06 ▸ Rules ▸ Abnormal repo states) — never a folded
|
||||
// diff-from-empty, because there is nothing to diff against and forty Adds would bury it.
|
||||
#expect(head.subject == "Initial board state")
|
||||
#expect(head.parentCount == 0, "it is the root commit")
|
||||
|
||||
// The whole tree, not a hand-picked set: the board, the lane, the card.
|
||||
let tracked = GitRepository.trackedPaths(at: fixture.root)
|
||||
#expect(tracked.contains("index.md"))
|
||||
#expect(tracked.contains("\(Ident.lane1)/index.md"))
|
||||
#expect(tracked.contains("\(Ident.lane1)/\(Ident.card1)/index.md"))
|
||||
}
|
||||
|
||||
@Test("Add-git seeds no `.gitignore` — repository hygiene is a later card")
|
||||
func addGitSeedsNoGitignore() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await git.addGit())
|
||||
|
||||
#expect(!fixture.exists(".gitignore"))
|
||||
#expect(!GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
|
||||
}
|
||||
|
||||
@Test("The root commit is authored by the derived default when the repo names nobody")
|
||||
func theRootCommitCarriesTheDerivedIdentity() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await git.addGit())
|
||||
|
||||
let derived = GitIdentity.derivedDefault()
|
||||
let head = try #require(GitRepository.headCommit(at: fixture.root))
|
||||
#expect(head.authorName == derived.name)
|
||||
#expect(head.authorEmail == derived.email)
|
||||
}
|
||||
|
||||
@Test("The branch is deterministic, and the popover's display line reads it")
|
||||
func theBranchIsMainAndReadable() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await git.addGit())
|
||||
|
||||
// Deterministic rather than inherited: libgit2's initial branch comes from an
|
||||
// `init.defaultBranch` this app cannot read in the sandbox (`GitRepository.initialBranchName`).
|
||||
#expect(git.branch == "main")
|
||||
#expect(GitRepository.branchName(at: fixture.root) == "main")
|
||||
|
||||
await git.refreshBranch()
|
||||
#expect(git.branch == "main")
|
||||
}
|
||||
|
||||
@Test("An unborn HEAD still has a branch name — a repository with no commits is normal git mode")
|
||||
func anUnbornHeadIsNormal() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
// `git init` and nothing else: the shape an adopted repository can genuinely be in
|
||||
// (06 ▸ Rules ▸ Abnormal repo states: "an unborn HEAD is normal git mode").
|
||||
_ = try Repository.create(at: fixture.root)
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.mode == .git)
|
||||
#expect(GitRepository.branchName(at: fixture.root) != nil)
|
||||
#expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine")
|
||||
}
|
||||
|
||||
@Test("Add-git refuses a board that already has a repository, and changes nothing")
|
||||
func addGitRefusesAnAdoptedBoard() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await first.addGit())
|
||||
let head = try #require(GitRepository.headCommit(at: fixture.root))
|
||||
|
||||
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await second.addGit() == false, "there is nothing to add")
|
||||
#expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized")
|
||||
}
|
||||
|
||||
@Test("Add-git refuses a repo-nested board — no nested repository, ever")
|
||||
func addGitRefusesANestedBoard() async throws {
|
||||
let outer = try WriterFixture()
|
||||
defer { outer.tearDown() }
|
||||
try plantGitDirectory(in: outer)
|
||||
|
||||
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
||||
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
|
||||
#expect(await git.addGit() == false)
|
||||
#expect(git.mode == .repoNested)
|
||||
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
|
||||
}
|
||||
|
||||
@Test("A refused add-git says why, in libgit2's words where it has any")
|
||||
func aRefusalIsExplained() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try plantGitDirectory(in: fixture)
|
||||
|
||||
// Mode `git` is refused by `HistoryStore` before libgit2 is reached, so the failure the
|
||||
// popover would show comes from the layer that *would* have run it.
|
||||
let failure = GitRepository.create(at: fixture.root)
|
||||
guard case .failure(let reason) = failure else {
|
||||
Issue.record("initializing over an existing repository must be refused")
|
||||
return
|
||||
}
|
||||
#expect(reason.operation == "Adding git to this board")
|
||||
#expect(!reason.message.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The loader's history ranker
|
||||
|
||||
@MainActor
|
||||
@Suite("HistoryStore ▸ the git-backed identity ranker")
|
||||
struct GitPathHistoryTests {
|
||||
|
||||
@Test("A path that entered history earlier ranks lower; an untracked one has no rank")
|
||||
func ranksFollowHistory() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await git.addGit())
|
||||
|
||||
// A second card, arriving in a later commit — the whole point of the rung.
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
try commitEverything(at: fixture.root, message: "Add card 'Second'")
|
||||
|
||||
// A third, on disk but never committed.
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
||||
|
||||
let ranker = try #require(git.identityHistoryRanker)
|
||||
let first = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card1)"))
|
||||
let second = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card2)"))
|
||||
|
||||
#expect(first < second, "lower is earlier")
|
||||
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card3)") == nil, "untracked is no rank at all")
|
||||
#expect(ranker.rank(Ident.lane1) == first, "a folder ranks with the first file that landed in it")
|
||||
}
|
||||
|
||||
@Test("No ranker where the app manages no git")
|
||||
func noRankerWithoutARepository() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(plain.identityHistoryRanker == nil, "mode none injects nothing")
|
||||
|
||||
// And the free tier has no `HistoryStore` to ask in the first place — pinned in the
|
||||
// composition suite above.
|
||||
}
|
||||
|
||||
@Test("Git history decides a real duplicate-id collision through the loader")
|
||||
func historyDecidesTheDuplicateWinner() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await git.addGit())
|
||||
|
||||
// The same identity, arriving later in a second lane — the copy the duplicate rule is about.
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card1)", Item.rich(order: "1024", title: "First (copy)"))
|
||||
try commitEverything(at: fixture.root, message: "Copy the card")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: git.identityHistoryRanker)
|
||||
let duplicates: [DuplicateIdentity] = result.defects.compactMap {
|
||||
if case .duplicateIdentity(let duplicate) = $0 { return duplicate }
|
||||
return nil
|
||||
}
|
||||
|
||||
let duplicate = try #require(duplicates.first)
|
||||
#expect(duplicate.winner == "\(Ident.lane1)/\(Ident.card1)", "the path that entered history first")
|
||||
#expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session composition
|
||||
|
||||
@MainActor
|
||||
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
||||
let folder = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("HistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
let model = AppModel(
|
||||
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
||||
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||
)
|
||||
return (model, { try? FileManager.default.removeItem(at: folder) })
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
|
||||
let ref = BoardWindowRef(url: url)
|
||||
let recordID = model.boardRegistry.recordOpen(of: url)
|
||||
let store = try model.storeRegistry.acquire(url)
|
||||
model.boardRegistry.setOpenNow(id: recordID)
|
||||
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
||||
return ref
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Board sessions ▸ the git state they compose")
|
||||
struct BoardSessionGitTests {
|
||||
|
||||
@Test("A free-tier session carries no git state, even on a board that has a repository")
|
||||
func freeSessionsCarryNoGitState() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try plantGitDirectory(in: fixture)
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
model.currentTier = { .free }
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
|
||||
#expect(session.git == nil)
|
||||
#expect(session.gitMode == .none, "the free tier ships exactly one mode")
|
||||
#expect(session.store.makeIdentityHistoryRanker == nil, "and injects nothing into the loader")
|
||||
}
|
||||
|
||||
@Test("A Pro session on a git board composes git mode and wires the loader's ranker")
|
||||
func proSessionsCarryTheDetectedMode() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
// A real repository, so the ranker has something to read.
|
||||
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await seed.addGit())
|
||||
|
||||
model.currentTier = { .pro }
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
|
||||
#expect(session.gitMode == .git)
|
||||
let provider = try #require(session.store.makeIdentityHistoryRanker)
|
||||
let ranker = try #require(provider())
|
||||
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
|
||||
|
||||
// The provider binding is deliberately *not* part of this card: both tiers still bind the
|
||||
// native stack until the undo/redo card builds the git provider over this mode.
|
||||
#expect(session.history is NativeHistoryProvider)
|
||||
}
|
||||
|
||||
@Test("A Pro session on a plain board is mode none and injects nothing")
|
||||
func proSessionsOnPlainBoardsInjectNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
|
||||
model.currentTier = { .pro }
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
|
||||
#expect(session.gitMode == .none)
|
||||
let provider = try #require(session.store.makeIdentityHistoryRanker, "the wiring is there")
|
||||
#expect(provider() == nil, "and it answers nothing on a board with no repository")
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it.
|
||||
|
||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." The read-only lock disables the surface without closing it.
|
||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board shows its current branch, read-only. The read-only lock disables the surface without closing it.
|
||||
|
||||
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
||||
|
||||
@@ -59,7 +59,9 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
||||
|
||||
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription will unlock is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **None of that is built yet** — pro-m1 and pro-m2 are the milestones that build it, and until they ship both tiers run the same native undo stack over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
|
||||
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **The first of that is built** — see "Git integration" below — and the rest is pro-m1 and pro-m2's remaining work; until it ships both tiers run the same native undo stack, the free one over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
|
||||
|
||||
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. Commits are authored from the repository's own `.git/config` when it names an identity, and otherwise from your macOS account name and machine (the popover's identity fields land with a later card). A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins. Still ahead in pro-m1/m2: auto-commit with semantic messages, git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+19
-2
@@ -22,6 +22,19 @@ packages:
|
||||
IndieAbout:
|
||||
url: https://git.rzen.dev/rzen/indie-about.git
|
||||
minorVersion: 0.2.2
|
||||
# **The bundled git client** (06-history-undo.md ▸ Rules: "Bundled libgit2 — no git install
|
||||
# required"): SwiftGitX vendors libgit2 as an in-process library, so every git operation the app
|
||||
# performs runs inside the sandbox with no `Process`, no `/usr/bin/git`, and no sandbox extension.
|
||||
# A board's history therefore works identically in a Release build on a machine that has never had
|
||||
# Xcode or the command-line tools installed.
|
||||
#
|
||||
# `exactVersion` rather than a range, and pinned to the pathfinder's own 0.4.0: the package is
|
||||
# pre-1.0 with an API that moves between minors (the commit and diff surfaces this depends on both
|
||||
# changed shape in 0.3→0.4), so a floating pin would be a resolver deciding when the git provider
|
||||
# stops compiling.
|
||||
SwiftGitX:
|
||||
url: https://github.com/ibrahimcetin/SwiftGitX
|
||||
exactVersion: 0.4.0
|
||||
|
||||
settings:
|
||||
base:
|
||||
@@ -40,8 +53,11 @@ settings:
|
||||
# resources. There is one product, one Info.plist, one entitlements file, one Swift module, and one
|
||||
# `.kanban` UTI **exported** (never imported) by the app that owns it.
|
||||
#
|
||||
# libgit2 arrives here with pro-m1 — a dependency of the one target, dormant behind the subscription
|
||||
# gate rather than the contents of a second download.
|
||||
# libgit2 arrived here with pro-m1, as SwiftGitX above — a dependency of the one target, dormant
|
||||
# behind the subscription gate rather than the contents of a second download. It links into the free
|
||||
# app and never runs there: detection itself is tier-gated (`HistoryStore.compose`), so a free-tier
|
||||
# session opens without so much as a `fileExists` under `.git` (12-editions.md ▸ The free tier and
|
||||
# `.git`, and `InertGitTests`).
|
||||
targets:
|
||||
# MARK: - Lanework
|
||||
|
||||
@@ -83,6 +99,7 @@ targets:
|
||||
- package: swift-markdown
|
||||
product: Markdown
|
||||
- package: IndieAbout
|
||||
- package: SwiftGitX
|
||||
postBuildScripts:
|
||||
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
|
||||
name: Update Build Info
|
||||
|
||||
Reference in New Issue
Block a user