The stack comes out — Kanban/Git/ deleted wholesale, ten thousand lines into history
Step 5 of strategy/01-git-excision.md: the seventeen dead engine files and the five remaining git test suites go (InertGitTests stays — the naming footgun is a Storage keeper). Two rescues ride ahead of the delete: HarvestedReceipt relocates to EchoLedger (the harvest surface outlives its git consumer; foundation for the deferred journal), and commentTimestamps joins the narrator it always served. The provider-swap purge test re-expresses over a git-free fake; the duplicate-id ladder keeps every pure historyRank pin and loses only the two ranker-driven ones. Resurrection point: tag pre-git-excision. 2,707 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -936,6 +936,37 @@ enum ChangeNarrator {
|
||||
return components.prefix(depth).joined(separator: "/")
|
||||
}
|
||||
|
||||
/// **When each of this window's comments was created**, keyed by `commentFolder(of:)`'s own
|
||||
/// spelling — the chronology a commit's comment bullets sort by (06 ▸ Rules ▸ Auto-commit, blessed
|
||||
/// 2026-07-31: "by the comments' own `created`, folder name on ties").
|
||||
///
|
||||
/// Relocated 2026-08-08 from `GitAutoCommitter.swift` with the git excision: the flush that called
|
||||
/// this is gone, but the read itself is git-free — one `index.md` per touched comment folder, off
|
||||
/// the working tree — so it stays beside the vocabulary that consumes it
|
||||
/// (`ChangeNarrationRequest.commentTimestamps`) rather than leaving with its old caller.
|
||||
///
|
||||
/// **Missing is normal, not a defect.** A comment whose folder left the tree in this very window
|
||||
/// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
|
||||
/// at all — each is simply absent here and sorts after its dated siblings in folder-name order,
|
||||
/// which is `CommentThread.sorted`'s own fallback for the same field. Nothing here is reported —
|
||||
/// a commit message is the wrong place to discover a defect.
|
||||
static func commentTimestamps(for changed: [ChangedPath], boardRoot: URL) -> [String: Date] {
|
||||
var timestamps: [String: Date] = [:]
|
||||
var seen: Set<String> = []
|
||||
for path in changed {
|
||||
guard let folder = commentFolder(of: path.path), seen.insert(folder).inserted else { continue }
|
||||
let index = boardRoot
|
||||
.appendingPathComponent(folder)
|
||||
.appendingPathComponent(IntegrityRules.indexFileName)
|
||||
guard let data = try? Data(contentsOf: index),
|
||||
let document = try? BoardLoader.parseDocument(data, path: folder),
|
||||
let created = document.created.value
|
||||
else { continue }
|
||||
timestamps[folder] = created
|
||||
}
|
||||
return timestamps
|
||||
}
|
||||
|
||||
/// **The comment verb family** (01-storage-format.md § Enhanced schema, the `kind: comment` block:
|
||||
/// "foreign comment changes are described by **path shape** — the 'Update agent guide (vN)'
|
||||
/// mechanism: a changed path under `…/comments/<uuid>/` composes 'Comment on ⟨card title⟩' / 'Edit
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - BoardGitMode
|
||||
|
||||
/// **Which git mode a board opened in** (07-sync-collab.md ▸ the mode state machine;
|
||||
/// 06-history-undo.md ▸ Rules ▸ Detection).
|
||||
///
|
||||
/// A board has exactly one mode at a time and the mode is not fixed at creation — "a board may be
|
||||
/// created plain, gain git later, and later still gain a remote" (07). What decides it is one
|
||||
/// question asked of the filesystem, `nearest-.git-wins`, and `detect(boardRoot:)` below is the
|
||||
/// whole of that question.
|
||||
///
|
||||
/// ### Four cases, and the fourth is not a fifth mode
|
||||
///
|
||||
/// `repoNested` is not "git mode with the repository somewhere else". A board inside a user's
|
||||
/// existing repository gets **no app-managed git at all** — "no nested repo, no commits into the
|
||||
/// user's repo" (06 ▸ Rules) — which makes it as distinct from `git` as `none` is, and the reason it
|
||||
/// is a case rather than a flag on `git`. What it no longer costs is ⌘Z: the native stack binds here
|
||||
/// too (re-ruled 2026-07-31 — 13-native-undo.md's header; `AppModel.makeHistoryProvider`), because
|
||||
/// that stack is memory-only and touches no repository, anybody's.
|
||||
///
|
||||
/// `unverifiable` answers a question `repoNested` cannot: what a sandboxed ancestor check *refuses*
|
||||
/// to say (06 ▸ Rules ▸ Detection, "Denial is not absence", ruled 2026-07-31). A check the sandbox
|
||||
/// answers `EACCES`/`EPERM` to is not "no repo there" — it is "cannot tell" — and folding that into
|
||||
/// `.none` would let add-git offer app-managed init on a board that might already sit inside a
|
||||
/// repository the app simply could not see. `unverifiable` therefore takes `repoNested`'s posture
|
||||
/// everywhere structural (no add-git, no app-managed git, `BoardGitSetupSection.resolve` empty), since
|
||||
/// the two share the one property every surface but the popover's prose cares about: neither may be
|
||||
/// added to. Its prose is its own — "unverifiable" is not "nested", and telling a user their board
|
||||
/// sits inside a repository when the honest answer is "couldn't check" would be a lie dressed as
|
||||
/// caution.
|
||||
///
|
||||
/// ### The remote half is deliberately absent
|
||||
///
|
||||
/// 07's state machine has a fourth state, git + remote. It is not here because a remote is a
|
||||
/// property of a repository the app has already decided it manages — remote detection, tracking and
|
||||
/// the ahead/behind badge are pro-m2's, behind this same seam. What this enum answers is the
|
||||
/// question every later surface starts from: does the app manage git for this board at all.
|
||||
public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
|
||||
|
||||
/// No `.git` at the board root and none above it — **every** ancestor check answered not-found,
|
||||
/// "clean none" in 06's own words. The mode of every board nobody has opted into git for — which
|
||||
/// is what a board without app-managed git *is* now that the tier axis is gone (12-editions.md
|
||||
/// ▸ PIVOT 2026-08-07; it used to be the only mode the free tier shipped, over the retired
|
||||
/// inert-`.git` posture). The one add-git moves a board out of, and — now that this axis exists — the one mode
|
||||
/// add-git's own re-detection requires before it will act: a raced or stale read that turns out
|
||||
/// to be `.unverifiable` or `.repoNested` refuses the init exactly as those modes always did.
|
||||
case none
|
||||
|
||||
/// A `.git` at the board root: the app manages this board's history. Reached two ways and they
|
||||
/// are indistinguishable by design — the app's own add-git (opt-in init), or **adoption**, "a
|
||||
/// board whose root already contains `.git` opens in git mode, silently … the repo's presence
|
||||
/// *is* the opt-in" (06 ▸ Rules).
|
||||
case git
|
||||
|
||||
/// No `.git` at the board root, but one was found at an ancestor — **certain**, found on the
|
||||
/// walk rather than inferred: the board lives inside somebody else's repository, which the app
|
||||
/// "leaves strictly alone" (06 ▸ Rules). The popover says so in prose — the add-git action is
|
||||
/// absent because it cannot apply, never hidden or greyed.
|
||||
case repoNested
|
||||
|
||||
/// No `.git` was found at the board root or any ancestor, but at least one check along the way
|
||||
/// was **denied** (`EACCES`/`EPERM`) rather than answered — the sandbox refusing to say whether
|
||||
/// an ancestor above its grant carries a repository (06 ▸ Rules ▸ Detection, "Denial is not
|
||||
/// absence", ruled 2026-07-31). Structurally this takes `repoNested`'s posture: no add-git, no
|
||||
/// app-managed git anywhere, `BoardGitSetupSection.resolve` empty — a denial can never be told
|
||||
/// apart from a repository actually being there, so the conservative posture is the only honest
|
||||
/// one. Its prose is its own: the popover explains that Lanework could not verify whether the
|
||||
/// board sits inside a repository, never the `repoNested` sentence verbatim — denial is not
|
||||
/// nesting.
|
||||
case unverifiable
|
||||
}
|
||||
|
||||
// MARK: - Detection
|
||||
|
||||
public extension BoardGitMode {
|
||||
|
||||
/// **What a `.git` path check reported** — `stat(2)`'s errno, classified into the three answers
|
||||
/// 06's ruling cares about. `exists`/`absent` are the two an unsandboxed filesystem check would
|
||||
/// ever produce; `denied` is what "Denial is not absence" exists to pull apart from `absent`: a
|
||||
/// check the sandbox refuses to answer must never read as "no repo there".
|
||||
enum GitEntryProbe: Sendable, Equatable {
|
||||
case exists
|
||||
case absent
|
||||
case denied
|
||||
}
|
||||
|
||||
/// Probes whether `url` directly contains a `.git`, **whatever kind of node that is** (a
|
||||
/// directory in an ordinary repository, a plain file — `gitdir: …` — in a linked worktree or a
|
||||
/// submodule; both are repositories to git, so both are `.exists` here). `stat`, not `lstat`, so
|
||||
/// a `.git` that is itself a symlink resolves the way `FileManager.fileExists` always has —
|
||||
/// a broken symlink reads `.absent`, never a false `.exists`.
|
||||
///
|
||||
/// **Classification is deliberately narrow**: `ENOENT`/`ENOTDIR` is an honest absence,
|
||||
/// `EACCES`/`EPERM` is a sandbox denial, and **every other errno reads as `.absent`, not
|
||||
/// `.denied`** — `ELOOP` (a symlink cycle), `ENAMETOOLONG` and the rest are honest reports about
|
||||
/// the path itself, not the sandbox refusing to look, and folding them into `.denied` would widen
|
||||
/// `.unverifiable` past what the ruling is actually about. Only `EACCES`/`EPERM` name a refusal
|
||||
/// to check.
|
||||
static func probeGitEntry(at url: URL) -> GitEntryProbe {
|
||||
let gitURL = url.appendingPathComponent(".git")
|
||||
var info = stat()
|
||||
let (status, failureErrno): (Int32, Int32) = gitURL.withUnsafeFileSystemRepresentation { representation in
|
||||
guard let representation else { return (-1, ENOENT) }
|
||||
let result = stat(representation, &info)
|
||||
return (result, result == 0 ? 0 : errno)
|
||||
}
|
||||
if status == 0 { return .exists }
|
||||
switch failureErrno {
|
||||
case ENOENT, ENOTDIR:
|
||||
return .absent
|
||||
case EACCES, EPERM:
|
||||
return .denied
|
||||
default:
|
||||
return .absent
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `url` directly contains a `.git` — the boolean-shaped convenience for call sites
|
||||
/// outside detection that only ever act on a board already known to be in git mode (the
|
||||
/// `GitBranchOperation`/`GitCommitOperation`/`GitHeadSnapshot`/`GitHistoryWalk`/
|
||||
/// `GitHousekeeping` family's guards): `.exists` is `true`, `.absent` and `.denied` alike are
|
||||
/// `false`, since neither leaves an entry there to use.
|
||||
///
|
||||
/// **Detection itself never calls this.** `detect(boardRoot:)` reads `probeGitEntry` directly so
|
||||
/// a denial can surface as `.unverifiable` instead of silently collapsing to `false` here.
|
||||
static func hasGitEntry(at url: URL) -> Bool {
|
||||
probeGitEntry(at: url) == .exists
|
||||
}
|
||||
|
||||
/// The result of walking `boardRoot`'s ancestors for an enclosing repository: the nearest one
|
||||
/// found, if any, and whether a probe anywhere along the way was denied.
|
||||
struct AncestorWalk: Sendable, Equatable {
|
||||
/// The nearest ancestor carrying a `.git`, or `nil` when none was found — **certain either
|
||||
/// way**, regardless of whether a *nearer* ancestor's probe was denied (06 ▸ Rules ▸
|
||||
/// Detection: "a farther ancestor showing `.git` makes repo-nested certain regardless of the
|
||||
/// denied nearer one — nearest-wins only affects which root you'd name, not whether one
|
||||
/// exists").
|
||||
public let root: URL?
|
||||
/// Whether any ancestor probe on the walk answered denied, whether or not the walk
|
||||
/// ultimately found a `.git`. A denial never ends the walk early — it is recorded and the
|
||||
/// walk continues past it, because only the *complete* walk can tell `.repoNested`
|
||||
/// (something was found) from `.unverifiable` (nothing was found, but something couldn't be
|
||||
/// checked) from clean `.none` (everything answered not-found).
|
||||
public let sawDenial: Bool
|
||||
}
|
||||
|
||||
/// Walks the ancestors above `boardRoot` for the nearest `.git`, denial-aware — `detect`'s own
|
||||
/// ancestor half, exposed because `enclosingRepositoryRoot` and `detect` are both one walk.
|
||||
///
|
||||
/// **The walk runs on plain path strings, never on `URL`s** — carried over from the pathfinder,
|
||||
/// where the URL version was a shipped hang. URLs arriving from AppKit surfaces (save panel,
|
||||
/// bookmark resolution, window restoration) are NSURL-bridged, and for those
|
||||
/// `deletingLastPathComponent` above `/` grows `/..` forever instead of reaching a fixed point
|
||||
/// the way native Swift URLs do: the loop never terminated in the app (one core pegged, no repo
|
||||
/// ever detected) while URL-based unit tests passed. `NSString`'s path math is a pure string
|
||||
/// operation that terminates at `/` regardless of where the URL came from.
|
||||
static func ancestorWalk(above boardRoot: URL) -> AncestorWalk {
|
||||
var sawDenial = false
|
||||
var path = (boardRoot.standardizedFileURL.path as NSString).deletingLastPathComponent
|
||||
while !path.isEmpty {
|
||||
let candidate = URL(fileURLWithPath: path, isDirectory: true)
|
||||
switch probeGitEntry(at: candidate) {
|
||||
case .exists:
|
||||
return AncestorWalk(root: candidate, sawDenial: sawDenial)
|
||||
case .denied:
|
||||
// Denial does not end the walk: a farther ancestor's `.git` still makes repo-nested
|
||||
// certain (the doc comment above). Recorded, and the walk continues past it.
|
||||
sawDenial = true
|
||||
case .absent:
|
||||
break
|
||||
}
|
||||
if path == "/" { break }
|
||||
path = (path as NSString).deletingLastPathComponent
|
||||
}
|
||||
return AncestorWalk(root: nil, sawDenial: sawDenial)
|
||||
}
|
||||
|
||||
/// The nearest ancestor of `boardRoot` that carries a `.git`, or `nil` when the walk found
|
||||
/// none — the repo-nested half of detection, exposed because the popover's honest explanation is
|
||||
/// about a repository that exists somewhere specific, and a later card may well want to name it.
|
||||
///
|
||||
/// **Existence only.** A denial recorded along the way is not observable through this call —
|
||||
/// `ancestorWalk(above:)` above is the sibling that reports it, and is what `detect` itself
|
||||
/// calls; this stays the narrower question it always answered, unchanged in shape by this axis.
|
||||
static func enclosingRepositoryRoot(above boardRoot: URL) -> URL? {
|
||||
ancestorWalk(above: boardRoot).root
|
||||
}
|
||||
|
||||
/// **Nearest-`.git`-wins, freshly at every board open, denial-aware** (06-history-undo.md ▸
|
||||
/// Rules ▸ Detection):
|
||||
///
|
||||
/// - `.git` at the board root → `.git`.
|
||||
/// - The board-root probe itself denied → `.unverifiable` — can't rule out git mode at the root.
|
||||
/// - No `.git` at the root: walk the ancestors. Any `.git` found → `.repoNested`, **certain
|
||||
/// regardless of a denied nearer ancestor** (a farther ancestor's `.git` still settles it).
|
||||
/// - No `.git` found on the walk, but a denial recorded along the way → `.unverifiable`.
|
||||
/// - Every ancestor answered not-found → `.none`, genuinely clean.
|
||||
///
|
||||
/// ### Open-time only, and this function is the whole of "open-time"
|
||||
///
|
||||
/// "A `git init` under an open mode-none board takes effect at the next open — the running
|
||||
/// session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session
|
||||
/// mode flips from watching; stated here so it isn't rediscovered as a bug)" (06). Nothing
|
||||
/// calls this on a reload path, and `FolderWatcher`'s `.git` filtering — which exists to ignore
|
||||
/// git churn — is what makes that structural rather than a rule somebody has to keep: there is
|
||||
/// no event a re-detection could hang off even if one wanted it. The one deliberate mid-session
|
||||
/// transition is the app's own add-git (`HistoryStore.addGit`), a *commanded* flip, which sets
|
||||
/// the mode directly rather than re-running this.
|
||||
///
|
||||
/// A board can therefore be a different mode at its next open than at this one, and that is the
|
||||
/// designed behaviour, not a cache to invalidate: "the app just reflects what it finds" — which
|
||||
/// now includes `.unverifiable` clearing to `.none` or `.git` once the sandbox grants visibility
|
||||
/// it did not have before, or the reverse.
|
||||
///
|
||||
/// Pure and total — but no longer silent about what it cannot see: a denied check surfaces as
|
||||
/// `.unverifiable` rather than being folded into `.none`, exactly the distinction "Denial is not
|
||||
/// absence" exists to draw.
|
||||
static func detect(boardRoot: URL) -> BoardGitMode {
|
||||
switch probeGitEntry(at: boardRoot) {
|
||||
case .exists:
|
||||
return .git
|
||||
case .denied:
|
||||
return .unverifiable
|
||||
case .absent:
|
||||
break
|
||||
}
|
||||
|
||||
let walk = ancestorWalk(above: boardRoot)
|
||||
if walk.root != nil { return .repoNested }
|
||||
if walk.sawDenial { return .unverifiable }
|
||||
return .none
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - A harvested receipt
|
||||
|
||||
/// **One EchoLedger receipt, copied out for the committer** (02-architecture.md ▸ Components
|
||||
/// ▸ EchoLedger; 06-history-undo.md ▸ Interaction with external writers).
|
||||
///
|
||||
/// ### Why a copy and not a read
|
||||
///
|
||||
/// The ledger's receipts are **consumed** by the landing reload that classifies them — "one write,
|
||||
/// one echo", which is what buys the announcer its silence. The committer asks its question two
|
||||
/// seconds later, by which time several reloads have landed and every receipt for the user's own
|
||||
/// card edit is gone. Reading the live ledger at flush time would therefore attribute the user's own
|
||||
/// work to `Lanework External`, which is the one misattribution this whole mechanism exists to
|
||||
/// prevent.
|
||||
///
|
||||
/// So the committer harvests at the **close of each write bracket** — the moment a receipt describes
|
||||
/// a completed write and nothing has had a chance to consume it — and keeps its own copy for the
|
||||
/// life of the debounce window. Supersession still works: a later bracket's harvest overwrites the
|
||||
/// same key with the newer hash, exactly as the ledger's own `recordWrite` does.
|
||||
///
|
||||
/// The satisfaction check stays the ledger's rule, re-applied against disk at commit time, so the
|
||||
/// two races 02 settles land the same way here: byte-identical foreign bytes over a fresh app write
|
||||
/// classify app-mediated, and a foreign edit that misses the hash classifies foreign.
|
||||
public struct HarvestedReceipt: Sendable, Equatable {
|
||||
|
||||
public let receipt: EchoLedger.Receipt
|
||||
|
||||
/// **Whether the write that dropped it was a heal** — the flag 06 (ruled 2026-07-29) keys the
|
||||
/// third commit class on: "a debounce window holding a scheduled heal's changes alongside anyone
|
||||
/// else's splits the heal's paths into their own commit".
|
||||
public let isHeal: Bool
|
||||
|
||||
public init(receipt: EchoLedger.Receipt, isHeal: Bool) {
|
||||
self.receipt = receipt
|
||||
self.isHeal = isHeal
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The split
|
||||
|
||||
/// One debounce window's changed paths, divided into the commits they will become.
|
||||
///
|
||||
/// **Three classes, committed in this order** — foreign, then heal, then the user's:
|
||||
///
|
||||
/// - *Foreign first* is 06's own ordering, stated as a consequence of flush-before-overwrite:
|
||||
/// "flush-before-overwrite already orders them: foreign first, then the user's overwrite". The log
|
||||
/// then reads causally — what arrived, then what the user did about it.
|
||||
/// - *Heal in the middle* is a judgment call, recorded: DESIGN fixes the heal's **separation** and
|
||||
/// not its position. A scheduled heal repairs what a load found, so it follows the foreign change
|
||||
/// that usually caused it and precedes the user's gesture, which is the order the three actually
|
||||
/// happened in.
|
||||
public struct CommitSplit: Sendable, Equatable {
|
||||
|
||||
/// Changes nobody vouched for — an agent, a text editor, a terminal, or a blind window at launch.
|
||||
public var foreign: [ChangedPath] = []
|
||||
|
||||
/// The scheduled healers' paths, heal-marked in the ledger by the Writer operations that made
|
||||
/// them (`EchoLedger.markHeal`).
|
||||
public var heal: [ChangedPath] = []
|
||||
|
||||
/// The user acting through the app.
|
||||
public var user: [ChangedPath] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
/// One class of one window's changes, ready to become a commit.
|
||||
public struct Group: Sendable, Equatable {
|
||||
public let paths: [ChangedPath]
|
||||
/// Which class it is — carried rather than re-derived, so the planner never has to ask a
|
||||
/// list whether it contains its own members.
|
||||
public let kind: Kind
|
||||
|
||||
public enum Kind: Sendable, Equatable { case foreign, heal, user }
|
||||
}
|
||||
|
||||
/// The classes in commit order, empty ones dropped — what the planner turns into `PlannedCommit`s.
|
||||
public var ordered: [Group] {
|
||||
[
|
||||
Group(paths: foreign, kind: .foreign),
|
||||
Group(paths: heal, kind: .heal),
|
||||
Group(paths: user, kind: .user)
|
||||
].filter { !$0.paths.isEmpty }
|
||||
}
|
||||
|
||||
public var isEmpty: Bool { foreign.isEmpty && heal.isEmpty && user.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - CommitAttribution
|
||||
|
||||
/// **Who a commit is by** (06-history-undo.md ▸ Interaction with external writers: "Commit
|
||||
/// attribution is structural, not just a message convention").
|
||||
///
|
||||
/// A pure enum of statics over values: the changed paths, the harvested receipts, and the bytes on
|
||||
/// disk. Nothing here opens a repository, so every rule below is provable from a fixture rather than
|
||||
/// from a commit graph.
|
||||
public enum CommitAttribution {
|
||||
|
||||
// MARK: The pinned identities
|
||||
|
||||
/// **API, not decoration** (06): "The strings are API (users script against them; the `.invalid`
|
||||
/// TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness
|
||||
/// of a schema change."
|
||||
public static let externalAuthorName = "Lanework External"
|
||||
public static let externalAuthorEmail = "[email protected]"
|
||||
|
||||
/// The domain a self-reported `modified-by` stamp authors under — "distinct from both the user
|
||||
/// and the generic external author".
|
||||
public static let agentEmailDomain = "agents.lanework.invalid"
|
||||
|
||||
/// **Who a heal commit is by** (06 ▸ Commit messages ▸ Healing mutations commit separately, ruled
|
||||
/// 2026-07-31 — "the third pinned synthetic, joining Lanework External and the agent-slug family;
|
||||
/// strings are API"):
|
||||
///
|
||||
/// > a heal is a third origin — not the user's gesture, not a foreign writer — and the separation
|
||||
/// > exists for audit, so the trail filters by author like every origin; the committer stays the
|
||||
/// > user (the recorded-by convention above).
|
||||
///
|
||||
/// It replaced authoring heals as the user, which made the separate commit filterable only by
|
||||
/// message shape — and the shape vocabulary deliberately never says "healed".
|
||||
public static let integrityAuthorName = "Lanework Integrity"
|
||||
public static let integrityAuthorEmail = "[email protected]"
|
||||
|
||||
/// The frontmatter key a foreign writer refines its own attribution with
|
||||
/// (01-storage-format.md; 08-agent-integration.md teaches it).
|
||||
static let modifiedByKey = "modified-by"
|
||||
|
||||
public static var externalIdentity: GitIdentity {
|
||||
GitIdentity(name: externalAuthorName, email: externalAuthorEmail)
|
||||
}
|
||||
|
||||
/// The heal class's author (`integrityAuthorName`). The *committer* beside it is still the user's
|
||||
/// identity, every time — "every commit the app makes, foreign-authored included, records the
|
||||
/// user's app as its committer" (06).
|
||||
public static var integrityIdentity: GitIdentity {
|
||||
GitIdentity(name: integrityAuthorName, email: integrityAuthorEmail)
|
||||
}
|
||||
|
||||
/// **A `modified-by` stamp, as an author** (06): "that commit is authored as **X** with the
|
||||
/// synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part
|
||||
/// slugified)".
|
||||
///
|
||||
/// The local part is lowercased on top of the slug — a judgment call, recorded: DESIGN says
|
||||
/// "slugified" without fixing case, addresses are conventionally lower, and the guide's own
|
||||
/// example stamp is `modified-by: claude`. The display name is untouched, so `Claude Code` still
|
||||
/// renders as `Claude Code <claude-code@agents.lanework.invalid>`.
|
||||
public static func agentIdentity(named displayName: String) -> GitIdentity {
|
||||
let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let local = GitIdentity.addressComponent(name, fallback: "agent").lowercased()
|
||||
return GitIdentity(name: name.isEmpty ? externalAuthorName : name, email: "\(local)@\(agentEmailDomain)")
|
||||
}
|
||||
|
||||
// MARK: - Classification
|
||||
|
||||
/// **Every changed file, sorted into its commit** — the per-file rule 06 states, applied to the
|
||||
/// paths `git status` reported.
|
||||
///
|
||||
/// A path is the app's when the ledger holds a receipt for it (or for a folder above it) that
|
||||
/// **disk still satisfies**; it is a heal when that receipt is heal-marked; it is foreign
|
||||
/// otherwise. "No receipt anywhere → foreign" is the launch-catch-up doctrine and the whole of
|
||||
/// *the app never vouches for changes it didn't witness*.
|
||||
///
|
||||
/// ### Why the walk goes up the folders
|
||||
///
|
||||
/// Because the ledger keys some facts at *folders* while git only ever reports *files*. A card
|
||||
/// the app moved between lanes has one `.move` receipt on its folder and no receipt at all on
|
||||
/// the `index.md` that travelled inside it; a card the app deleted has one `.absence` receipt on
|
||||
/// its folder and git reports every file underneath as gone. Asking only the file's own key
|
||||
/// would classify both as foreign — the user's own delete, attributed to an agent.
|
||||
///
|
||||
/// The **nearest** receipt wins, so a rewritten `index.md` inside a moved folder answers with
|
||||
/// its own content receipt rather than with the move above it.
|
||||
public static func split(
|
||||
_ paths: [ChangedPath],
|
||||
under boardRoot: URL,
|
||||
receipts: [String: HarvestedReceipt]
|
||||
) -> CommitSplit {
|
||||
var split = CommitSplit()
|
||||
for path in paths {
|
||||
let absolute = EchoLedger.key(boardRoot.appendingPathComponent(path.path))
|
||||
switch vouched(forAbsolutePath: absolute, boardRoot: boardRoot, receipts: receipts) {
|
||||
case .none: split.foreign.append(path)
|
||||
case .some(true): split.heal.append(path)
|
||||
case .some(false): split.user.append(path)
|
||||
}
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
/// `nil` when nothing vouches for this path; otherwise whether the vouching receipt was a heal.
|
||||
private static func vouched(
|
||||
forAbsolutePath absolute: String,
|
||||
boardRoot: URL,
|
||||
receipts: [String: HarvestedReceipt]
|
||||
) -> Bool? {
|
||||
let root = EchoLedger.key(boardRoot)
|
||||
var candidate = absolute
|
||||
while candidate.hasPrefix(root), candidate.count >= root.count {
|
||||
if let held = receipts[candidate] {
|
||||
switch held.receipt {
|
||||
case let .content(hash):
|
||||
// Content is a claim about *these* bytes, so only the file's own key may answer
|
||||
// with it. A content receipt sitting on an ancestor would be a claim about a
|
||||
// folder's bytes, which is not a thing.
|
||||
if candidate == absolute {
|
||||
return hash == hashOfFile(atPath: absolute) ? held.isHeal : nil
|
||||
}
|
||||
case .absence:
|
||||
return exists(candidate) ? nil : held.isHeal
|
||||
case let .move(from, to):
|
||||
if candidate == to { return exists(candidate) ? held.isHeal : nil }
|
||||
if candidate == from { return exists(candidate) ? nil : held.isHeal }
|
||||
}
|
||||
}
|
||||
guard candidate != root else { break }
|
||||
let parent = (candidate as NSString).deletingLastPathComponent
|
||||
guard parent != candidate else { break }
|
||||
candidate = parent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - The foreign author
|
||||
|
||||
/// **`modified-by` refines foreign attribution** (06): the whole rule, as one function.
|
||||
///
|
||||
/// > when every file changed in a foreign debounce window carries the same `modified-by: X`,
|
||||
/// > that commit is authored as **X** … Any disagreement between stamps, any unstamped changed
|
||||
/// > file, or any true deletion in the window falls back to `Lanework External`.
|
||||
///
|
||||
/// **A folder move is not a deletion.** A rename's departure end is a file that is gone from
|
||||
/// disk and has no stamp to read, but it is not "a deletion [that] leaves no file to stamp" —
|
||||
/// its arrival end is right there in the same window, carrying whatever the writer stamped on
|
||||
/// it. So a paired departure is skipped rather than demoting the window. A bare `mv` that
|
||||
/// re-stamps nothing still demotes, through the unstamped-file clause, exactly as 06 says it
|
||||
/// does — which is why the agent guide teaches re-stamping on move.
|
||||
///
|
||||
/// A window of nothing but rename departures leaves no stamp to agree on and falls back too.
|
||||
public static func foreignIdentity(for paths: [ChangedPath], under boardRoot: URL) -> GitIdentity {
|
||||
var stamps: Set<String> = []
|
||||
for path in paths {
|
||||
if path.isDeletion {
|
||||
guard path.isRename else { return externalIdentity }
|
||||
continue
|
||||
}
|
||||
guard let stamp = modifiedBy(atRelativePath: path.path, under: boardRoot) else {
|
||||
return externalIdentity
|
||||
}
|
||||
stamps.insert(stamp)
|
||||
}
|
||||
guard stamps.count == 1, let name = stamps.first else { return externalIdentity }
|
||||
return agentIdentity(named: name)
|
||||
}
|
||||
|
||||
/// The `modified-by` a changed file carries, or `nil` for a file that carries none — **which
|
||||
/// every non-`index.md` path does, by construction**: a stray, an attachment, and `CLAUDE.md`
|
||||
/// have no frontmatter to stamp, so they are unstamped changed files and demote the window.
|
||||
static func modifiedBy(atRelativePath relativePath: String, under boardRoot: URL) -> String? {
|
||||
guard relativePath == BoardLoader.indexFileName
|
||||
|| relativePath.hasSuffix("/" + BoardLoader.indexFileName) else { return nil }
|
||||
let url = boardRoot.appendingPathComponent(relativePath)
|
||||
guard let text = try? String(contentsOf: url, encoding: .utf8),
|
||||
let document = try? FrontmatterDocument.parse(text),
|
||||
let raw = document.rawValue(for: modifiedByKey) else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
// MARK: - Disk
|
||||
|
||||
private static func exists(_ path: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
/// The hash of what is at `path` now, or `nil` when nothing is — the same digest the ledger's
|
||||
/// receipts were minted with, so the comparison is the ledger's own.
|
||||
private static func hashOfFile(atPath path: String) -> String? {
|
||||
guard let data = FileManager.default.contents(atPath: path) else { return nil }
|
||||
return EchoLedger.hash(of: data)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,342 +0,0 @@
|
||||
import Foundation
|
||||
import libgit2
|
||||
import os
|
||||
|
||||
// MARK: - Outcome
|
||||
|
||||
/// **How a branch operation ended** — the four answers 06-history-undo.md gives every app-initiated
|
||||
/// git operation, in the shape `GitCommitOutcome` already gives the committer's.
|
||||
///
|
||||
/// The kinship is deliberate: contention is never an error, a paused repository is a hold rather than
|
||||
/// a failure, and everything else is a clean failure carrying libgit2's own message ("An operation
|
||||
/// that fails *cleanly* — disk error, refused checkout … surfaces as a one-shot banner failure naming
|
||||
/// the operation and the error, the tree left as it was").
|
||||
public enum GitBranchOutcome: Sendable, Equatable {
|
||||
|
||||
/// HEAD now names this branch and the working tree is its state.
|
||||
case switched(String)
|
||||
|
||||
/// `index.lock` was held. The payload is the lock file's path — what an implausibly long wait
|
||||
/// names (06 ▸ Interaction with external writers: "a wait that persists implausibly long names
|
||||
/// the lock path").
|
||||
case locked(path: String)
|
||||
|
||||
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Branch controls
|
||||
/// disable in that state, so this is the race — a terminal started a merge between the popover
|
||||
/// rendering and the click landing.
|
||||
case held(GitRepositoryPause)
|
||||
|
||||
/// A clean failure: a refused checkout, an unwritable object store, a name that is not a branch.
|
||||
/// **The tree is untouched** — libgit2's safe checkout either applies wholly or refuses.
|
||||
case failed(GitOperationFailure)
|
||||
}
|
||||
|
||||
// MARK: - GitBranchOperation
|
||||
|
||||
/// **Branch switching and create-and-switch, over the bundled libgit2** (06-history-undo.md ▸ Branch
|
||||
/// switching) — the repository half of the operation, with nothing in it that knows about editors,
|
||||
/// banners, or the undo stack.
|
||||
///
|
||||
/// ### The checkout is `SAFE`, and that is the whole safety story
|
||||
///
|
||||
/// `git_checkout_tree` with `GIT_CHECKOUT_SAFE` "allows safe updates that cannot overwrite
|
||||
/// uncommitted data": a working tree carrying changes that conflict with the target refuses the
|
||||
/// checkout wholesale (`GIT_ECONFLICT`) and leaves every byte where it was. Nothing here ever passes
|
||||
/// `GIT_CHECKOUT_FORCE` — not on the switch, not on the create-and-switch, and not on the
|
||||
/// own-leftovers abort, which is the one path that could plausibly want it. That is what makes "a
|
||||
/// refused checkout is a clean one-shot failure, tree untouched" a property of the call rather than a
|
||||
/// promise, and it is checkable by grepping this file for `FORCE`.
|
||||
///
|
||||
/// The caller's contract is the other half: the switch runs on a settled tree — open Edit sessions
|
||||
/// settled explicitly, the pending auto-commit flushed — so in practice `SAFE` has nothing to refuse
|
||||
/// ("checkout runs on a truly settled tree: it cannot fail dirty").
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitCommitOperation`'s rule, unchanged and for its reason: every function is `nonisolated`, opens
|
||||
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
|
||||
/// `await`, a `Task`, or a stored property.
|
||||
enum GitBranchOperation {
|
||||
|
||||
/// What a failure calls itself on the banner — in the user's words, not libgit2's.
|
||||
static let operationName = "Switching branches"
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// libgit2's global state — `GitCommitOperation.startUp`'s twin, and for its reason.
|
||||
private static let startUp: Bool = {
|
||||
git_libgit2_init() >= 0
|
||||
}()
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// **Every local branch**, sorted the way a menu should list them.
|
||||
///
|
||||
/// Local only: remote-tracking branches are 07-sync-collab.md's, and a picker that offered
|
||||
/// `origin/main` would be offering a detached HEAD — precisely the state 06 pauses the whole git
|
||||
/// surface for.
|
||||
///
|
||||
/// An unborn HEAD answers with an empty list, which is honest: `git init` has created no branch
|
||||
/// yet, only a symbolic ref naming the one the first commit will make.
|
||||
nonisolated static func localBranches(at boardRoot: URL) -> [String] {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return [] }
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
var iterator: OpaquePointer?
|
||||
guard git_branch_iterator_new(&iterator, repository, GIT_BRANCH_LOCAL) == 0, let iterator else {
|
||||
return []
|
||||
}
|
||||
defer { git_branch_iterator_free(iterator) }
|
||||
|
||||
var names: [String] = []
|
||||
var reference: OpaquePointer?
|
||||
var kind = GIT_BRANCH_LOCAL
|
||||
while git_branch_next(&reference, &kind, iterator) == 0 {
|
||||
defer {
|
||||
reference.map(git_reference_free)
|
||||
reference = nil
|
||||
}
|
||||
var name: UnsafePointer<CChar>?
|
||||
guard git_branch_name(&name, reference) == 0, let name else { continue }
|
||||
names.append(String(cString: name))
|
||||
}
|
||||
return names.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Whether libgit2 would accept `name` as a branch name — `git check-ref-format --branch`'s
|
||||
/// answer, asked before anything is created so the failure names the input rather than a ref.
|
||||
nonisolated static func isValidBranchName(_ name: String) -> Bool {
|
||||
_ = startUp
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
var valid: Int32 = 0
|
||||
guard git_branch_name_is_valid(&valid, trimmed) == 0 else { return false }
|
||||
return valid == 1
|
||||
}
|
||||
|
||||
/// Whether a local branch by this name already exists — the create path's own refusal, phrased
|
||||
/// against the name the user typed instead of against libgit2's `GIT_EEXISTS`.
|
||||
nonisolated static func branchExists(_ name: String, at boardRoot: URL) -> Bool {
|
||||
localBranches(at: boardRoot).contains(name)
|
||||
}
|
||||
|
||||
/// `.git/index.lock`'s path, for the waiting state that names it.
|
||||
nonisolated static func indexLockPath(at boardRoot: URL) -> String {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else {
|
||||
return boardRoot.appendingPathComponent(".git/index.lock").path
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
return gitDirectory(of: repository).appendingPathComponent("index.lock").path
|
||||
}
|
||||
|
||||
// MARK: - The switch
|
||||
|
||||
/// **The checkout itself** (06 ▸ Branch switching): materialize the branch's tree with the safe
|
||||
/// strategy, then move HEAD's symbolic ref onto it.
|
||||
///
|
||||
/// The order is libgit2's own recommended one and it matters: the checkout's baseline is the
|
||||
/// *current* HEAD, so the tree is updated against what is actually checked out, and HEAD moves
|
||||
/// only once the bytes are there. An interruption between the two leaves a tree that matches the
|
||||
/// target under a HEAD that does not — which is exactly the leftover `GitOperationStamp` exists to
|
||||
/// recognize as the app's own.
|
||||
///
|
||||
/// - Parameter allowingPause: whether to proceed against a repository in a pause state. `false`
|
||||
/// everywhere except the own-leftovers abort, which is 06's one exemption from "the app never
|
||||
/// mutates repo state it didn't create" — see `abort(_:at:)`.
|
||||
nonisolated static func checkout(
|
||||
_ branch: String,
|
||||
at boardRoot: URL,
|
||||
allowingPause: Bool = false
|
||||
) -> GitBranchOutcome {
|
||||
_ = startUp
|
||||
|
||||
// The state check runs immediately before the write, never from a caller's earlier read: 06's
|
||||
// rule is that it runs "again before every flush", and a terminal can start a merge between a
|
||||
// popover rendering and a click landing.
|
||||
let reading = GitCommitOperation.reading(at: boardRoot)
|
||||
if let pause = reading.pause, !allowingPause { return .held(pause) }
|
||||
if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }
|
||||
|
||||
guard let repository = open(boardRoot) else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "this board's repository could not be opened"
|
||||
))
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
let fullName = "refs/heads/" + branch
|
||||
var reference: OpaquePointer?
|
||||
guard git_reference_lookup(&reference, repository, fullName) == 0, let reference else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "there is no local branch named '\(branch)'"
|
||||
))
|
||||
}
|
||||
defer { git_reference_free(reference) }
|
||||
|
||||
var target: OpaquePointer?
|
||||
guard git_reference_peel(&target, reference, GIT_OBJECT_COMMIT) == 0, let target else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
defer { git_object_free(target) }
|
||||
|
||||
var options = git_checkout_options()
|
||||
guard git_checkout_options_init(&options, UInt32(GIT_CHECKOUT_OPTIONS_VERSION)) == 0 else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
// **`SAFE`, never `FORCE`** — see the type's note. The value is libgit2's zero, spelled out
|
||||
// rather than left implicit so the strategy is visible at the point it is chosen.
|
||||
options.checkout_strategy = GIT_CHECKOUT_SAFE.rawValue
|
||||
|
||||
let checked = git_checkout_tree(repository, target, &options)
|
||||
guard checked == 0 else { return classify(checked, at: boardRoot) }
|
||||
|
||||
guard git_repository_set_head(repository, fullName) == 0 else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
logger.notice("checked out branch \(branch, privacy: .public)")
|
||||
return .switched(branch)
|
||||
}
|
||||
|
||||
/// **Create-and-switch** (06 ▸ Branch switching, and 03-board-ui.md ▸ Board popover: "branch
|
||||
/// switching and creation"): a new branch at the current HEAD, then the ordinary switch onto it.
|
||||
///
|
||||
/// **The checkout is not skipped**, though the new branch's tree is HEAD's by construction and the
|
||||
/// working tree therefore cannot change. The reason is a race the app shares its repository with
|
||||
/// by design (06 ▸ "Two writers, one repository"): an agent's self-commit landing between the
|
||||
/// branch's creation and the switch moves HEAD, and a `set_head` with no checkout would then leave
|
||||
/// the working tree describing a commit the new branch does not point at. Running the same
|
||||
/// checkout every switch runs costs one no-op index write in the ordinary case and is correct in
|
||||
/// the racing one.
|
||||
///
|
||||
/// **An unborn HEAD creates nothing and only moves the symbolic ref** — which is exactly what
|
||||
/// `git checkout -b` does on a repository with no commits: there is no commit to branch from, and
|
||||
/// the name HEAD points at is the branch the first commit will make (06 ▸ Rules ▸ Abnormal repo
|
||||
/// states: "an unborn HEAD … is normal git mode").
|
||||
nonisolated static func createAndSwitch(_ branch: String, at boardRoot: URL) -> GitBranchOutcome {
|
||||
_ = startUp
|
||||
let name = branch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard isValidBranchName(name) else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "'\(branch)' is not a valid branch name"
|
||||
))
|
||||
}
|
||||
guard !branchExists(name, at: boardRoot) else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "a branch named '\(name)' already exists"
|
||||
))
|
||||
}
|
||||
|
||||
let reading = GitCommitOperation.reading(at: boardRoot)
|
||||
if let pause = reading.pause { return .held(pause) }
|
||||
if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }
|
||||
|
||||
guard let repository = open(boardRoot) else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "this board's repository could not be opened"
|
||||
))
|
||||
}
|
||||
|
||||
if reading.isUnborn {
|
||||
defer { git_repository_free(repository) }
|
||||
guard git_repository_set_head(repository, "refs/heads/" + name) == 0 else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
return .switched(name)
|
||||
}
|
||||
|
||||
var created: OpaquePointer?
|
||||
let outcome: GitBranchOutcome? = {
|
||||
defer { git_repository_free(repository) }
|
||||
guard let head = headCommit(of: repository) else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
defer { git_commit_free(head) }
|
||||
guard git_branch_create(&created, repository, name, head, 0) == 0 else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
created.map(git_reference_free)
|
||||
return nil
|
||||
}()
|
||||
if let outcome { return outcome }
|
||||
|
||||
return checkout(name, at: boardRoot)
|
||||
}
|
||||
|
||||
// MARK: - The app's own leftovers
|
||||
|
||||
/// **Aborts an interrupted app-run switch** (06 ▸ Rules ▸ Abnormal repo states: "The one exemption
|
||||
/// is the app's own leftovers … finding a pause state with a matching stamp, the app **aborts its
|
||||
/// own unfinished operation** to restore the pre-operation state").
|
||||
///
|
||||
/// The abort *is* a checkout back to the branch the stamp recorded — the interrupted operation ran
|
||||
/// forwards, so undoing it is running the same operation backwards. It carries `allowingPause`
|
||||
/// because the leftover it is clearing is precisely a state that would otherwise refuse; that
|
||||
/// exemption is the stamp's whole purpose, and it is why nothing else in the app passes the flag.
|
||||
///
|
||||
/// **Still `SAFE`, still never `FORCE`.** An abort that overwrote uncommitted work to tidy up
|
||||
/// would be the app losing the user's bytes on its own initiative — and "abort discards nothing"
|
||||
/// is the design's own promise about it. A refused abort therefore stays refused and says so.
|
||||
///
|
||||
/// It deliberately does **not** call `git_repository_state_cleanup`: a branch switch never creates
|
||||
/// `MERGE_HEAD` or a rebase directory, so a leftover of *that* shape is not this operation's even
|
||||
/// when a stamp is standing, and removing it would be the never-mutate rule broken in the one
|
||||
/// place the exemption does not reach. (Recorded as a judgment call; the rebase that can leave one
|
||||
/// is 07-sync-collab.md's pull, whose own abort will own it.)
|
||||
nonisolated static func abort(_ stamp: GitOperationStamp, at boardRoot: URL) -> GitBranchOutcome {
|
||||
guard !stamp.fromBranch.isEmpty else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "the interrupted operation recorded no branch to return to"
|
||||
))
|
||||
}
|
||||
return checkout(stamp.fromBranch, at: boardRoot, allowingPause: true)
|
||||
}
|
||||
|
||||
// MARK: - Private plumbing
|
||||
|
||||
private static func open(_ boardRoot: URL) -> OpaquePointer? {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
|
||||
var repository: OpaquePointer?
|
||||
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
|
||||
return repository
|
||||
}
|
||||
|
||||
private static func gitDirectory(of repository: OpaquePointer) -> URL {
|
||||
URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
|
||||
var reference: OpaquePointer?
|
||||
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
|
||||
defer { git_reference_free(reference) }
|
||||
var object: OpaquePointer?
|
||||
guard git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0 else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
|
||||
pointer.map { String(cString: $0) }
|
||||
}
|
||||
|
||||
private static func lastErrorMessage() -> String {
|
||||
guard let error = git_error_last(), let message = error.pointee.message else {
|
||||
return "libgit2 reported no reason"
|
||||
}
|
||||
return String(cString: message)
|
||||
}
|
||||
|
||||
/// Turns a libgit2 status into the outcome 06 gives it — contention apart from failure, exactly as
|
||||
/// `GitCommitOperation.classify` does for a commit.
|
||||
private static func classify(_ status: Int32, at boardRoot: URL) -> GitBranchOutcome {
|
||||
if status == GIT_ELOCKED.rawValue { return .locked(path: indexLockPath(at: boardRoot)) }
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - GitBranchSwitcher
|
||||
|
||||
/// **The branch switch, in the order 06-history-undo.md ▸ Branch switching fixes it** — settle the
|
||||
/// editors, flush the pending commit, stamp the intent, check out, reseed undo — with every step that
|
||||
/// needs a window, a store, or a banner arriving as a seam.
|
||||
///
|
||||
/// ### Why the sequence is an object rather than a method
|
||||
///
|
||||
/// Because five of its six steps belong to somebody else. Settling editors is the card windows'
|
||||
/// (`SessionSettleGate`), flushing is the committer's, bracketing is the store's, reseeding is the
|
||||
/// undo provider's, and the in-progress row is the banner strip's — and 06 fixes the *order* they run
|
||||
/// in, which is the one thing none of them can hold. `GitHistoryProvider` is the same shape for the
|
||||
/// same reason, and its seams are wired from the same place (`AppModel.wireGitUndo`).
|
||||
///
|
||||
/// A `nil` seam is always the honest degenerate case rather than a disabled feature: a board with no
|
||||
/// card windows has nothing to settle, a repository-level test has no store to bracket with, and a
|
||||
/// board whose popover is closed has no spinner to update. The sequence runs the same way through all
|
||||
/// of them.
|
||||
///
|
||||
/// ### What it deliberately does not do
|
||||
///
|
||||
/// Nothing remote. Tracking, ahead/behind, Pull, Push and push-on-commit follow the current branch
|
||||
/// (06 ▸ Branch switching) and are 07-sync-collab.md's own card; this object moves HEAD and tells the
|
||||
/// undo stack, and the remote half will join by reading the same `didSwitch` seam.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class GitBranchSwitcher {
|
||||
|
||||
/// The board this switches branches on — in git mode, the repository's working-tree root.
|
||||
public let boardRoot: URL
|
||||
|
||||
// MARK: - Seams
|
||||
|
||||
/// **The save-or-discard step, over every open session** (06 ▸ Branch switching: "if any open card
|
||||
/// window has one … the switch presents a save-or-discard step").
|
||||
///
|
||||
/// Unlike the undo restore's, this gate is **not** narrowed by a diff. A restore materializes only
|
||||
/// the paths it changes, so a session the diff never touches is genuinely unaffected; a branch
|
||||
/// switch moves the whole tree out from under every session at once, and the raw-source hazard 06
|
||||
/// names — "its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new
|
||||
/// branch's card" — does not care whether the checkout touched that card at all. So the seam takes
|
||||
/// no paths, and `SessionSettleGate.settleAll()` is what production passes.
|
||||
@ObservationIgnored
|
||||
public var settleSessions: (@MainActor () async -> SessionSettleOutcome)?
|
||||
|
||||
/// The pending auto-commit, flushed once the sessions are settled — "with sessions settled, the
|
||||
/// pending auto-commit flushes (flush-before-overwrite) and checkout runs on a truly settled tree:
|
||||
/// it cannot fail dirty".
|
||||
@ObservationIgnored
|
||||
public var flushPendingCommit: (@MainActor () async -> Void)?
|
||||
|
||||
/// Stops and restarts the auto-commit debounce around the checkout, so a timer cannot fire
|
||||
/// mid-materialization. `GitHistoryProvider`'s pair, for its reason.
|
||||
@ObservationIgnored
|
||||
public var suspendCommitting: (@MainActor () -> Void)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var resumeCommitting: (@MainActor () -> Void)?
|
||||
|
||||
/// **The undo/redo reseed** (06 ▸ Branch switching: "The undo/redo stack does not survive a
|
||||
/// switch. It is discarded and reseeded from the new HEAD's first-parent ancestry … redo starts
|
||||
/// empty") — `GitHistoryProvider.reseed`, which is already exactly that.
|
||||
@ObservationIgnored
|
||||
public var reseedUndo: (@MainActor () async -> Void)?
|
||||
|
||||
/// The store's wholesale bracket: watcher suspended, one full reload at the end, the board locked
|
||||
/// read-only if that reload fails (02-architecture.md; `BoardStore.performWholesale(announcing:awaiting:)`).
|
||||
@ObservationIgnored
|
||||
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
|
||||
|
||||
/// The in-progress banner row: begin, relabel (the lock's waiting state), end.
|
||||
///
|
||||
/// Three seams rather than one object because the banner is the *store's*, and this type is
|
||||
/// composed on boards that have none. Relabelling is its own call because a held lock must change
|
||||
/// what the row says without replacing the row: "contention outlasting the brief retry surfaces as
|
||||
/// a *waiting* state in the operation's in-progress banner row" — the same operation, still
|
||||
/// running, now explaining itself.
|
||||
@ObservationIgnored
|
||||
public var beginProgress: (@MainActor (String) -> UUID)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var updateProgress: (@MainActor (UUID, String) -> Void)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var endProgress: (@MainActor (UUID) -> Void)?
|
||||
|
||||
/// A clean failure — "surfaces as a one-shot banner failure naming the operation and the error,
|
||||
/// the tree left as it was" (06 ▸ Interaction with external writers).
|
||||
///
|
||||
/// The banner rather than an inline caption, deliberately, and 06 draws the line: the
|
||||
/// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote —
|
||||
/// forms that live in the board popover's Git tab; they moved to the settings sheet with the
|
||||
/// 2026-07-31 popover/sheet split and came back with the 2026-08-07 reversal),
|
||||
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
|
||||
/// one surface" — which a branch switch is by construction, since its bracket locks the board and
|
||||
/// its completion is announced.
|
||||
///
|
||||
/// **Which row that is, settled 2026-07-31** (02-architecture.md ▸ The banner surface): the
|
||||
/// one-shot failure class's message-carrying git shape — error tone, failure rank, dismissable
|
||||
/// and untimed. What travels is the operation and the underlying message; the sentence
|
||||
/// ("Couldn't switch branches — …") is `BannerCenter`'s, which is why nothing here composes one.
|
||||
@ObservationIgnored
|
||||
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
||||
|
||||
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
|
||||
///
|
||||
/// **A warning-tone loss row, not a failure** (02 ▸ The banner surface, settled 2026-07-31):
|
||||
/// "recovery notices report a success, not a failure, and stay warning-tone" — the abort put the
|
||||
/// previous state back, and the row exists so the user learns that it happened.
|
||||
@ObservationIgnored
|
||||
public var reportRecovery: (@MainActor (String) -> Void)?
|
||||
|
||||
/// The per-board registry's stamp — read at open, written before the repository is touched, and
|
||||
/// cleared when the operation is over (`GitOperationStamp`).
|
||||
@ObservationIgnored
|
||||
public var readStamp: (@MainActor () -> GitOperationStamp?)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var writeStamp: (@MainActor (GitOperationStamp?) -> Void)?
|
||||
|
||||
/// Whether the git surface is held (`GitAutoCommitter.pause != nil`). The controls disable on it,
|
||||
/// and this is the pre-flight that keeps a click that raced the render from presenting a modal
|
||||
/// step for an operation the repository is about to refuse.
|
||||
@ObservationIgnored
|
||||
public var isHeld: (@MainActor () -> Bool)?
|
||||
|
||||
/// HEAD moved — what refreshes the popover's branch line (`HistoryStore.refreshBranch`). Called
|
||||
/// after a successful switch and after a successful abort, and by nothing else.
|
||||
@ObservationIgnored
|
||||
public var didSwitch: (@MainActor () async -> Void)?
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
/// Every local branch, as of the last refresh — the picker's contents.
|
||||
public private(set) var branches: [String] = []
|
||||
|
||||
/// Whether a switch is in flight: the controls' disabled state, and the guard that keeps a second
|
||||
/// click from starting a second checkout.
|
||||
public private(set) var isSwitching = false
|
||||
|
||||
/// The last clean failure, or `nil`. Held beside the banner it is also posted to, so the popover
|
||||
/// can show what happened while it was open without the banner having to be its only witness.
|
||||
public private(set) var lastFailure: GitOperationFailure?
|
||||
|
||||
/// Folders whose card session the settle step's **Discard** branch just abandoned — reverted to
|
||||
/// HEAD before anything else happens (see `perform`). Filled through `noteDiscarded(cardFolderName:)`,
|
||||
/// which is how `AppModel`'s gate reports each one.
|
||||
@ObservationIgnored
|
||||
private var discardedFolders: Set<String> = []
|
||||
|
||||
/// **A settle step discarded this card's session.** "Discard reverts buffers and uncommitted saves
|
||||
/// to HEAD" — the window reverted the buffer, and this is the switch remembering to revert the
|
||||
/// saves.
|
||||
public func noteDiscarded(cardFolderName: String) {
|
||||
discardedFolders.insert(cardFolderName)
|
||||
}
|
||||
|
||||
// MARK: - Tunables
|
||||
|
||||
/// The brief, silent backoff: "pull, push, branch switch, and undo restore meeting a held lock
|
||||
/// wait and retry briefly, silently" (06 ▸ Interaction with external writers). The committer's own
|
||||
/// numbers, for the committer's reason.
|
||||
@ObservationIgnored
|
||||
public var lockRetryDelay: Duration = .milliseconds(120)
|
||||
|
||||
@ObservationIgnored
|
||||
public var lockRetryAttempts = 3
|
||||
|
||||
/// The cadence the waiting state retries on, once the brief backoff is spent.
|
||||
@ObservationIgnored
|
||||
public var lockWaitInterval: Duration = .seconds(1)
|
||||
|
||||
/// How long a wait runs before the row names the lock path — "a wait that persists implausibly
|
||||
/// long names the lock path (a crashed writer's leftover is the user's to clear)".
|
||||
@ObservationIgnored
|
||||
public var lockPathNamingDelay: Duration = .seconds(5)
|
||||
|
||||
/// **The bound on the wait, recorded as a judgment call.** 06 describes a waiting state that
|
||||
/// retries on its cadence and never becomes an error dialog; it does not say when — or whether —
|
||||
/// it gives up. An unbounded wait would hold the board's wholesale bracket, and with it the
|
||||
/// read-only lock, for as long as a crashed writer's `index.lock` sits on disk, with no way out
|
||||
/// but quitting. So the wait ends, generously, at a clean failure that names the lock path — the
|
||||
/// tree untouched, the branch unchanged, the banner explaining exactly what to clear. Never a
|
||||
/// dialog, never a hammer, and never a board wedged by another process's litter.
|
||||
@ObservationIgnored
|
||||
public var lockWaitLimit: Duration = .seconds(30)
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
public init(boardRoot: URL) {
|
||||
self.boardRoot = boardRoot
|
||||
}
|
||||
|
||||
// MARK: - Phrases
|
||||
|
||||
/// The in-progress row while the checkout runs (02-architecture.md ▸ The banner surface:
|
||||
/// "Switching to 'main'…").
|
||||
public static func progressLabel(target: String) -> String {
|
||||
"Switching to '\(target)'…"
|
||||
}
|
||||
|
||||
/// The bracket's completion announcement (10-accessibility.md ▸ Live board announcements:
|
||||
/// "bracketed operations announce once, at completion").
|
||||
public static func completionAnnouncement(target: String) -> String {
|
||||
"Switched to branch '\(target)'"
|
||||
}
|
||||
|
||||
/// The waiting state, and the same sentence once the wait is long enough to name what is holding
|
||||
/// the lock.
|
||||
public static let waitingLabel = "Waiting for another writer's git lock"
|
||||
|
||||
public static func waitingLabel(path: String) -> String {
|
||||
"\(waitingLabel) (\(path))"
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// Reloads the branch list — what the popover's `.task` calls when it appears, and what every
|
||||
/// completed operation calls for itself.
|
||||
public func refreshBranches() async {
|
||||
let root = boardRoot
|
||||
branches = await Task.detached(priority: .userInitiated) {
|
||||
GitBranchOperation.localBranches(at: root)
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - The two operations
|
||||
|
||||
/// **Switches to an existing local branch.** Answers whether HEAD actually moved.
|
||||
@discardableResult
|
||||
public func switchTo(_ branch: String) async -> Bool {
|
||||
await perform(target: branch, creating: false)
|
||||
}
|
||||
|
||||
/// **Creates a branch at the current HEAD and switches to it.**
|
||||
///
|
||||
/// The full sequence runs — settle step included — and that is a judgment call, recorded. The card
|
||||
/// this was built for allows skipping the settle "only if you can prove the tree cannot change",
|
||||
/// and the proof does not hold: the new branch is created at whatever HEAD is *at that moment*,
|
||||
/// and this app shares its repository with self-committing agents by design (06 ▸ "Two writers,
|
||||
/// one repository"), so a commit landing between the flush and the create leaves a working tree
|
||||
/// that the new branch does not describe. Two smaller reasons point the same way — the flush puts
|
||||
/// pending work on the branch it was made on rather than on the branch that did not exist when it
|
||||
/// was made, and one sequence is one thing to reason about. The step costs nothing when nothing is
|
||||
/// dirty: the gate never appears unless a session is actually holding unsaved state.
|
||||
@discardableResult
|
||||
public func createAndSwitch(to branch: String) async -> Bool {
|
||||
await perform(target: branch.trimmingCharacters(in: .whitespacesAndNewlines), creating: true)
|
||||
}
|
||||
|
||||
private func perform(target: String, creating: Bool) async -> Bool {
|
||||
guard !isSwitching, !target.isEmpty else { return false }
|
||||
// A held repository disables the controls; this is the click that raced the render.
|
||||
guard isHeld?() != true else { return false }
|
||||
|
||||
isSwitching = true
|
||||
defer { isSwitching = false }
|
||||
lastFailure = nil
|
||||
discardedFolders = []
|
||||
|
||||
// **a. Settle the editors first — explicitly, never silently** (06 ▸ Branch switching). Before
|
||||
// the bracket, because the step is modal and a modal inside a suspended watcher would hold the
|
||||
// board read-only for as long as the user took to read it.
|
||||
if let settleSessions {
|
||||
switch await settleSessions() {
|
||||
case .cancelled, .failed:
|
||||
// "Cancel keeps the current branch and the sessions", and a raw buffer that will not
|
||||
// validate "cancels the whole switch with focus on the offending window, nothing
|
||||
// half-switched".
|
||||
discardedFolders = []
|
||||
return false
|
||||
case .proceed:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let root = boardRoot
|
||||
|
||||
// **a′. Discard's second half**: the windows reverted their buffers, and the *uncommitted
|
||||
// saves* those sessions left on disk go back to HEAD here — before the flush, which would
|
||||
// otherwise commit them the instant the ended session stopped being staged around
|
||||
// (`GitRestoreOperation.revertToHead`).
|
||||
let discarded = discardedFolders
|
||||
discardedFolders = []
|
||||
if !discarded.isEmpty {
|
||||
let reverted = await Task.detached(priority: .userInitiated) {
|
||||
GitRestoreOperation.revertToHead(folderNames: discarded, at: root)
|
||||
}.value
|
||||
guard reverted else {
|
||||
fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: "this board's repository could not be read"
|
||||
))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// **b. Flush the pending auto-commit** — the tree is settled from here on.
|
||||
await flushPendingCommit?()
|
||||
|
||||
// **c. Stamp the intent, before the repository is touched** (06 ▸ Rules ▸ Abnormal repo
|
||||
// states). Everything above this line is app-side; everything below can be interrupted.
|
||||
let head = await Task.detached(priority: .userInitiated) {
|
||||
GitHistoryWalk.headOID(at: root)
|
||||
}.value
|
||||
let current = await Task.detached(priority: .userInitiated) {
|
||||
GitRepository.branchName(at: root)
|
||||
}.value
|
||||
writeStamp?(GitOperationStamp(
|
||||
fromBranch: current ?? "",
|
||||
toBranch: target,
|
||||
headOID: head
|
||||
))
|
||||
|
||||
// **d. The checkout, bracketed** — watcher suspended, one full reload at the end, the board
|
||||
// locked read-only if that reload fails.
|
||||
let progress = beginProgress?(Self.progressLabel(target: target))
|
||||
var landed = false
|
||||
let work: @MainActor () async -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.suspendCommitting?()
|
||||
defer { self.resumeCommitting?() }
|
||||
|
||||
switch await self.runWaitingOutLocks(target: target, creating: creating, progress: progress) {
|
||||
case .switched:
|
||||
landed = true
|
||||
// **e. Reseed undo/redo from the new HEAD**, inside the bracket: the stack must never
|
||||
// be readable in a state where it describes the branch that is no longer checked out.
|
||||
await self.reseedUndo?()
|
||||
case let .failed(failure):
|
||||
self.fail(failure)
|
||||
case let .held(pause):
|
||||
self.fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: pause.explanation
|
||||
))
|
||||
case let .locked(path):
|
||||
self.fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: "another program is still using this repository's index (\(path))"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if let runBracketed {
|
||||
await runBracketed(Self.completionAnnouncement(target: target), work)
|
||||
} else {
|
||||
await work()
|
||||
}
|
||||
|
||||
// The operation is over, whichever way it went: a clean failure left the tree exactly as it
|
||||
// was, so there is nothing for a later open to abort.
|
||||
writeStamp?(nil)
|
||||
if let progress { endProgress?(progress) }
|
||||
await didSwitch?()
|
||||
await refreshBranches()
|
||||
return landed
|
||||
}
|
||||
|
||||
/// The checkout, with 06's lock posture around it: brief silent retries, then a waiting state in
|
||||
/// the operation's own row, then — at `lockWaitLimit` — a clean failure naming the lock path.
|
||||
private func runWaitingOutLocks(
|
||||
target: String,
|
||||
creating: Bool,
|
||||
progress: UUID?
|
||||
) async -> GitBranchOutcome {
|
||||
let root = boardRoot
|
||||
let startedWaiting = ContinuousClock.now
|
||||
var attempt = 0
|
||||
var announced = false
|
||||
var named = false
|
||||
|
||||
while true {
|
||||
let outcome = await Task.detached(priority: .userInitiated) {
|
||||
creating
|
||||
? GitBranchOperation.createAndSwitch(target, at: root)
|
||||
: GitBranchOperation.checkout(target, at: root)
|
||||
}.value
|
||||
|
||||
guard case let .locked(path) = outcome else { return outcome }
|
||||
|
||||
attempt += 1
|
||||
if attempt <= max(0, lockRetryAttempts) {
|
||||
// Brief and silent: "a held lock is another writer doing its job".
|
||||
try? await Task.sleep(for: lockRetryDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
let waited = ContinuousClock.now - startedWaiting
|
||||
guard waited < lockWaitLimit else {
|
||||
return .locked(path: path)
|
||||
}
|
||||
if !announced, let progress {
|
||||
updateProgress?(progress, Self.waitingLabel)
|
||||
announced = true
|
||||
}
|
||||
if !named, waited >= lockPathNamingDelay, let progress {
|
||||
updateProgress?(progress, Self.waitingLabel(path: path))
|
||||
named = true
|
||||
}
|
||||
try? await Task.sleep(for: lockWaitInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The app's own leftovers
|
||||
|
||||
/// **Recovers an interrupted app-run switch, at board open** (06 ▸ Rules ▸ Abnormal repo states).
|
||||
///
|
||||
/// Called once per session, beside the committer's start — which is where the pause it is looking
|
||||
/// for is first knowable, and before any of it reaches a user. Three outcomes, all of
|
||||
/// `GitOperationRecovery`'s: nothing to do, a stale stamp dropped silently, or the app's own
|
||||
/// leftover aborted with a banner.
|
||||
///
|
||||
/// **A failed abort keeps the stamp**, which is this file's second judgment call. 06 says the app
|
||||
/// "aborts its own unfinished operation … then clears the stamp"; that sentence describes the
|
||||
/// abort that worked. An abort refused by a conflicting working tree has restored nothing, and
|
||||
/// clearing the stamp would demote the leftover to somebody else's on the next open — the app
|
||||
/// would then defer forever to an operation only it ever started. So the stamp stands, the failure
|
||||
/// is surfaced, and the next open tries again.
|
||||
public func recoverInterruptedOperation() async {
|
||||
guard let stamp = readStamp?() else { return }
|
||||
let root = boardRoot
|
||||
let pause = await Task.detached(priority: .userInitiated) {
|
||||
GitCommitOperation.reading(at: root).pause
|
||||
}.value
|
||||
|
||||
switch GitOperationRecovery.decide(stamp: stamp, pause: pause) {
|
||||
case .nothingToDo:
|
||||
return
|
||||
|
||||
case .clearStamp:
|
||||
writeStamp?(nil)
|
||||
|
||||
case let .abort(stamp):
|
||||
Self.logger.notice("aborting this app's own interrupted branch switch")
|
||||
var restored = false
|
||||
let work: @MainActor () async -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.suspendCommitting?()
|
||||
defer { self.resumeCommitting?() }
|
||||
let outcome = await Task.detached(priority: .userInitiated) {
|
||||
GitBranchOperation.abort(stamp, at: root)
|
||||
}.value
|
||||
switch outcome {
|
||||
case .switched:
|
||||
restored = true
|
||||
await self.reseedUndo?()
|
||||
case let .failed(failure):
|
||||
self.fail(failure)
|
||||
case let .held(pause):
|
||||
self.fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: pause.explanation
|
||||
))
|
||||
case let .locked(path):
|
||||
self.fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: "another program is using this repository's index (\(path))"
|
||||
))
|
||||
}
|
||||
}
|
||||
if let runBracketed {
|
||||
await runBracketed(GitOperationStamp.interruptionMessage, work)
|
||||
} else {
|
||||
await work()
|
||||
}
|
||||
guard restored else { return }
|
||||
writeStamp?(nil)
|
||||
reportRecovery?(GitOperationStamp.interruptionMessage)
|
||||
await didSwitch?()
|
||||
}
|
||||
|
||||
await refreshBranches()
|
||||
}
|
||||
|
||||
// MARK: - Failure
|
||||
|
||||
private func fail(_ failure: GitOperationFailure) {
|
||||
lastFailure = failure
|
||||
Self.logger.error("branch operation failed: \(failure.description, privacy: .public)")
|
||||
reportFailure?(failure)
|
||||
}
|
||||
}
|
||||
@@ -1,784 +0,0 @@
|
||||
import Foundation
|
||||
import libgit2
|
||||
import os
|
||||
|
||||
// MARK: - Repository state
|
||||
|
||||
/// **A repo state the auto-committer holds for** (06-history-undo.md ▸ Rules ▸ Abnormal repo
|
||||
/// states): "a detached HEAD, or an in-progress merge/rebase/cherry-pick left by outside-the-app
|
||||
/// git … pauses the git surface honestly … auto-commit holds".
|
||||
///
|
||||
/// **Unborn HEAD is deliberately absent.** It is *normal* git mode — "the first auto-commit creates
|
||||
/// the root commit on the branch HEAD names, and the undo trail simply starts empty" — so it is a
|
||||
/// fact about how the next commit is shaped (`GitRepository.initialCommitSubject`), never a reason
|
||||
/// to stop.
|
||||
///
|
||||
/// Seven of the cases are libgit2's own `git_repository_state`, which reads exactly the marker files
|
||||
/// 06 names (`MERGE_HEAD`, `rebase-merge/`, `rebase-apply/`, `CHERRY_PICK_HEAD`) plus the two this
|
||||
/// version has no story for but must not commit over either (`REVERT_HEAD`, `BISECT_LOG`).
|
||||
///
|
||||
/// **The eighth is the app's own reading, and it is a pause by ruling** (06 ▸ Rules, "A `.git` that
|
||||
/// isn't a valid repository still reads as git mode — and fails loudly", ruled 2026-07-31): a `.git`
|
||||
/// libgit2 cannot open at all is not a repository *state* — there is no repository to be in one —
|
||||
/// but the posture it calls for is this one, verbatim: "the whole git surface paused (the
|
||||
/// abnormal-states posture below)". Putting it in this vocabulary is what makes that true
|
||||
/// structurally rather than by a rule somebody has to keep: every consumer of a pause already holds
|
||||
/// the auto-commit debounce (`GitAutoCommitter.execute`), disables Undo/Redo and the branch controls
|
||||
/// (`GitHistoryProvider.isHeld`, `GitBranchSwitcher.perform`), skips housekeeping
|
||||
/// (`GitHousekeeper.runNow`), and names the state in the popover — so `.unreadable` inherits all of
|
||||
/// it by construction, including the standing pause's own 15 s re-read, which is what heals it
|
||||
/// mid-session (`GitAutoCommitter.holdRecheckInterval`).
|
||||
public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
|
||||
case detachedHead
|
||||
case merge
|
||||
case revert
|
||||
case cherryPick
|
||||
case bisect
|
||||
case rebase
|
||||
case applyMailbox
|
||||
|
||||
/// **The repository could not be opened** — a corrupt `.git`, a worktree pointer aimed at
|
||||
/// nothing, or a repository this engine has no support for (a SHA-256 one, 06 ▸ Repository
|
||||
/// hygiene: "an adopted SHA-256 repo the engine cannot open takes the corrupt-repo loud-failure
|
||||
/// path"). Never a fall to mode none: detection is presence-shaped, so the board stays in git
|
||||
/// mode and this is what git mode *reads* like while the repository is unreadable.
|
||||
case unreadable
|
||||
|
||||
/// What the popover will say — **the branch-switching card's surface, phrased here** so the
|
||||
/// engine-side hold and the sentence that explains it cannot drift apart (06 ▸ Rules ▸ Abnormal
|
||||
/// repo states: "the popover's git section names the state plainly … and says resolving it
|
||||
/// belongs to the tool that created it").
|
||||
public var explanation: String {
|
||||
switch self {
|
||||
case .detachedHead: "HEAD is detached — commits would belong to no branch"
|
||||
case .merge: "a merge is in progress"
|
||||
case .revert: "a revert is in progress"
|
||||
case .cherryPick: "a cherry-pick is in progress"
|
||||
case .bisect: "a bisect is in progress"
|
||||
case .rebase: "a rebase is in progress"
|
||||
case .applyMailbox: "a patch application is in progress"
|
||||
// The clause the failure family reads with ("Adding git to this board failed: …",
|
||||
// `GitBranchOperation`'s held case), in the same voice as its siblings. The *popover's*
|
||||
// sentence for this state is its own and says more (`BoardGitBranchSurface.unreadableNote`):
|
||||
// unlike every pause above it, nothing is in progress and no tool is coming to finish it.
|
||||
case .unreadable: "this board's git repository can't be read"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one look at the repository found, before any staging is attempted.
|
||||
public struct GitRepositoryReading: Sendable, Equatable {
|
||||
|
||||
/// The pause 06 holds for, or `nil` when the repository is in a state the committer may write in.
|
||||
public let pause: GitRepositoryPause?
|
||||
|
||||
/// Whether HEAD names a branch that has no commits yet — normal git mode, and the one thing that
|
||||
/// makes the next commit a root commit.
|
||||
public let isUnborn: Bool
|
||||
|
||||
/// Whether `index.lock` is held right now. Read as a file rather than inferred from a failure so
|
||||
/// the committer can back off *before* it has written anything (06 ▸ Interaction with external
|
||||
/// writers: "`index.lock` contention is never an error").
|
||||
public let isIndexLocked: Bool
|
||||
|
||||
public init(pause: GitRepositoryPause?, isUnborn: Bool, isIndexLocked: Bool) {
|
||||
self.pause = pause
|
||||
self.isUnborn = isUnborn
|
||||
self.isIndexLocked = isIndexLocked
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - A planned commit
|
||||
|
||||
/// **Which of 06's classes a planned commit belongs to** — carried through the libgit2 work so a
|
||||
/// landed commit can be recognized by the class that planned it.
|
||||
///
|
||||
/// It exists for one consumer: the undo provider's **heal transparency** (06-history-undo.md ▸ Rules
|
||||
/// ▸ Heal commits are transparent to undo, in-session: "heal-class commits — their paths known by the
|
||||
/// Writer's heal-marked receipts — never become undo steps"). Receipts live on the main actor and are
|
||||
/// cleared the moment a window commits, so the only way the stack can ever learn *which commit* was
|
||||
/// the heal is to be told at the moment it lands.
|
||||
///
|
||||
/// A tag rather than a re-derivation, deliberately: a plan whose staging produced HEAD's tree is
|
||||
/// skipped and lands no commit at all, so the oids that come back are not positionally alignable with
|
||||
/// the plans that were submitted.
|
||||
public enum PlannedCommitKind: String, Sendable, Equatable, CaseIterable {
|
||||
/// A repository's first commit — "Initial board state", never split (06 ▸ Rules ▸ Abnormal repo
|
||||
/// states).
|
||||
case root
|
||||
case foreign
|
||||
case heal
|
||||
case user
|
||||
}
|
||||
|
||||
/// One commit that actually landed: its oid, and the class of the plan that made it.
|
||||
public struct GitLandedCommit: Sendable, Equatable {
|
||||
public let oid: String
|
||||
public let kind: PlannedCommitKind
|
||||
|
||||
public init(oid: String, kind: PlannedCommitKind) {
|
||||
self.oid = oid
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
/// One commit a flush intends to make: which paths it stages, what it says, and who it is by.
|
||||
///
|
||||
/// A value rather than a call, because the flush's whole decision — the three-way split, the
|
||||
/// ordering, the authorship — is made on the main actor from state the committer holds, and the
|
||||
/// libgit2 work is then a pure function of these (06 ▸ Interaction with external writers: the
|
||||
/// two-commit split; ruled 2026-07-29: the heal's third class).
|
||||
public struct PlannedCommit: Sendable, Equatable {
|
||||
|
||||
/// Board-root-relative paths, exactly as `ChangedPath.path` spells them.
|
||||
public let paths: [String]
|
||||
|
||||
public let message: String
|
||||
|
||||
/// **Who the change is by** — the user, `Lanework External`, or a `modified-by` agent.
|
||||
public let author: GitIdentity
|
||||
|
||||
/// **Who made the commit** — always this machine's user identity.
|
||||
///
|
||||
/// A judgment call, recorded: 06 pins the *author* ("foreign changes are committed under the
|
||||
/// pinned synthetic author … so any git client can filter, log, and blame by origin" — and both
|
||||
/// `git log --author` and `git blame` read the author field) and says nothing about the
|
||||
/// committer. Git's own convention for recording somebody else's change — `git am`, cherry-pick,
|
||||
/// every forge's merge button — keeps the author as the originator and names the actor who
|
||||
/// created the commit as committer, which is honestly what happened here: Lanework, running as
|
||||
/// this user, wrote it. Setting both to the synthetic identity would claim the repository made
|
||||
/// itself.
|
||||
public let committer: GitIdentity
|
||||
|
||||
/// Which of 06's classes planned this — carried so the landed commit can be recognized by it.
|
||||
/// See `PlannedCommitKind`; defaulted so a caller with only one class to make (add-git's root
|
||||
/// commit, the undo provider's restore) says nothing about a split it is not part of.
|
||||
public let kind: PlannedCommitKind
|
||||
|
||||
public init(
|
||||
paths: [String],
|
||||
message: String,
|
||||
author: GitIdentity,
|
||||
committer: GitIdentity,
|
||||
kind: PlannedCommitKind = .user
|
||||
) {
|
||||
self.paths = paths
|
||||
self.message = message
|
||||
self.author = author
|
||||
self.committer = committer
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
/// How a flush ended — the four outcomes 06 gives the committer, and no fifth.
|
||||
public enum GitCommitOutcome: Sendable, Equatable {
|
||||
|
||||
/// One commit per planned commit that had anything in it, oldest first — each carrying the class
|
||||
/// of the plan that made it (`PlannedCommitKind`), which is how heal transparency reaches the
|
||||
/// undo stack.
|
||||
case committed([GitLandedCommit])
|
||||
|
||||
/// **The happy path, not a malfunction** (06 ▸ Interaction with external writers): the tree had
|
||||
/// nothing to commit — an agent already committed its own work, or the window held only paths
|
||||
/// staged around.
|
||||
case nothingToCommit
|
||||
|
||||
/// **Never an error** (06): `index.lock` was held and stayed held through the brief retry. The
|
||||
/// caller re-debounces; nothing is surfaced.
|
||||
case locked
|
||||
|
||||
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Edits keep
|
||||
/// landing on disk and commit as one settled batch when it clears.
|
||||
case held(GitRepositoryPause)
|
||||
|
||||
/// A genuine failure — disk full, corruption. Surfaced per 02-architecture.md ▸ Write-failure
|
||||
/// surfacing and retried on the next debounce.
|
||||
case failed(GitOperationFailure)
|
||||
}
|
||||
|
||||
// MARK: - GitCommitOperation
|
||||
|
||||
/// **The signature-capable commit path** (06-history-undo.md ▸ Interaction with external writers:
|
||||
/// "Commit attribution is structural, not just a message convention"), written against the vendored
|
||||
/// libgit2 C API directly.
|
||||
///
|
||||
/// ### Why it is not SwiftGitX
|
||||
///
|
||||
/// SwiftGitX 0.4.0's `Repository.commit(message:)` takes no signature: its `CommitOptions` leaves
|
||||
/// `author` and `committer` null, so libgit2 falls back to `git_signature_default`, which resolves
|
||||
/// through the merged config ladder — unreadable in the sandbox, and the wrong question anyway
|
||||
/// (06 rules `~/.gitconfig` out of the identity story entirely). Per-commit authorship is this
|
||||
/// card's whole point: the user's identity on user-driven commits, `Lanework External` on foreign
|
||||
/// ones, a `modified-by` agent's on stamped ones. None of that is reachable through the wrapper, and
|
||||
/// `Repository.pointer` is `internal`, so there is no seam to borrow either.
|
||||
///
|
||||
/// The module underneath *is* reachable — SwiftGitX vendors `libgit2` as a package product, and
|
||||
/// `project.yml` names the same pin SwiftGitX pins, so this adds an import rather than a second copy
|
||||
/// of the library. Everything SwiftGitX does well (`GitRepository`'s reads) still goes through it.
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitRepository`'s rule, unchanged and for its reason: every function here is `nonisolated`, opens
|
||||
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
|
||||
/// `await`, a `Task`, or a stored property, so libgit2 never sees two threads on one handle.
|
||||
enum GitCommitOperation {
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// libgit2's global state, brought up exactly once per process.
|
||||
///
|
||||
/// SwiftGitX calls `git_libgit2_init` from `Repository.init`/`open` and pairs it with a shutdown
|
||||
/// in `deinit`, which is a refcount this file must not ride on: a flush can run when no
|
||||
/// `Repository` is alive. A `static let` is Swift's own run-once, and the matching shutdown is
|
||||
/// deliberately never called — the library stays up for the life of the process, which is what
|
||||
/// every consumer here wants.
|
||||
private static let startUp: Bool = {
|
||||
git_libgit2_init() >= 0
|
||||
}()
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// The three facts a flush checks **before every attempt** (06 ▸ Rules ▸ Abnormal repo states:
|
||||
/// "the check runs at open and again before every flush, so finishing the operation in a
|
||||
/// terminal resumes the pipeline without ceremony").
|
||||
///
|
||||
/// **A repository that cannot be opened at all is `.unreadable`** — a pause, not a shrug (06 ▸
|
||||
/// Rules, the corrupt-`.git` loud failure, ruled 2026-07-31). This line used to answer "no pause,
|
||||
/// not unborn, not locked" and let the commit attempt that followed fail with libgit2's own
|
||||
/// message; under the ruling that is exactly backwards — the failure must be loud *before* a
|
||||
/// write is attempted, and nothing may be attempted against a repository the app cannot open
|
||||
/// ("Lanework leaves the repository untouched").
|
||||
///
|
||||
/// Because every caller of this function already branches on `pause`, that one word is the whole
|
||||
/// of the pause wiring: the flush holds, housekeeping skips, the interrupted-operation recovery
|
||||
/// defers, and the popover's `refreshPause` learns it.
|
||||
/// **Presence-shaped, exactly as detection is**: `.unreadable` is what a root `.git` that will
|
||||
/// not open reads like, and a board with no `.git` at all is not in git mode in the first place
|
||||
/// — it keeps the old no-pause answer, so a caller outside git mode (`GitHousekeeping.run`'s own
|
||||
/// `.noRepository` reading, a storeless test) is not told a repository it does not have is
|
||||
/// paused.
|
||||
nonisolated static func reading(at boardRoot: URL) -> GitRepositoryReading {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else {
|
||||
return GitRepositoryReading(
|
||||
pause: BoardGitMode.hasGitEntry(at: boardRoot) ? .unreadable : nil,
|
||||
isUnborn: false,
|
||||
isIndexLocked: false
|
||||
)
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
let locked = isIndexLocked(gitDirectory: gitDirectory(of: repository))
|
||||
let unborn = git_repository_head_unborn(repository) == 1
|
||||
|
||||
// Detached HEAD is asked first because it is the state an unborn repo cannot be in and the
|
||||
// one `git_repository_state` does not model: libgit2 keeps "what operation is in progress"
|
||||
// and "where HEAD points" as separate questions.
|
||||
if !unborn, git_repository_head_detached(repository) == 1 {
|
||||
return GitRepositoryReading(pause: .detachedHead, isUnborn: false, isIndexLocked: locked)
|
||||
}
|
||||
return GitRepositoryReading(pause: pause(of: repository), isUnborn: unborn, isIndexLocked: locked)
|
||||
}
|
||||
|
||||
/// libgit2's `git_repository_state`, mapped to the pauses 06 names.
|
||||
///
|
||||
/// It reads the marker files the design lists (`rebase-merge/`, `rebase-apply/`, `MERGE_HEAD`,
|
||||
/// `REVERT_HEAD`, `CHERRY_PICK_HEAD`, `BISECT_LOG`) — which is why a test can plant one file and
|
||||
/// get the real answer rather than a mocked one.
|
||||
private static func pause(of repository: OpaquePointer) -> GitRepositoryPause? {
|
||||
switch git_repository_state(repository) {
|
||||
case Int32(GIT_REPOSITORY_STATE_NONE.rawValue): nil
|
||||
case Int32(GIT_REPOSITORY_STATE_MERGE.rawValue): .merge
|
||||
case Int32(GIT_REPOSITORY_STATE_REVERT.rawValue),
|
||||
Int32(GIT_REPOSITORY_STATE_REVERT_SEQUENCE.rawValue): .revert
|
||||
case Int32(GIT_REPOSITORY_STATE_CHERRYPICK.rawValue),
|
||||
Int32(GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE.rawValue): .cherryPick
|
||||
case Int32(GIT_REPOSITORY_STATE_BISECT.rawValue): .bisect
|
||||
case Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX.rawValue),
|
||||
Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE.rawValue): .applyMailbox
|
||||
// Every rebase flavour reads as one pause: the popover says "a rebase is in progress" and the
|
||||
// engine holds, and no consumer of either is finer-grained than that.
|
||||
default: .rebase
|
||||
}
|
||||
}
|
||||
|
||||
/// **Which paths differ between HEAD and the working tree**, `.gitignore` respected.
|
||||
///
|
||||
/// This is the commit's *condition* — "its commit condition is the *tree*, not the snapshot
|
||||
/// diff, so a stray-only window commits rather than leaving the tree dirty" (06 ▸ Commit
|
||||
/// messages ▸ Non-snapshot files commit too) — and it is also the composer's second input, which
|
||||
/// is why it comes back as values rather than as a count.
|
||||
///
|
||||
/// ### Why it stages into the index rather than reading `git_status`
|
||||
///
|
||||
/// Because of one clause: "**A folder move is not a deletion**: items match by id across the
|
||||
/// whole board … so a moved card attributes by its stamp like any changed file" (06). Rename
|
||||
/// detection is a *similarity* pass over a diff, and libgit2 only runs it where both ends are in
|
||||
/// one diff — `git_status`' `RENAMES_INDEX_TO_WORKDIR` finds a rename made **after** staging, and
|
||||
/// a plain `mv` in a working tree nobody has staged is simply a delete beside an add. Measured,
|
||||
/// not assumed: the first cut of this function used status with every rename flag set, and a
|
||||
/// re-stamped agent move still demoted to `Lanework External`.
|
||||
///
|
||||
/// So the diff is taken where the pairing can be seen: everything the working tree says is staged
|
||||
/// into the **in-memory** index (`git_index_add_all` — full `git add -A` semantics, ignores
|
||||
/// respected, deletions dropped), HEAD's tree is diffed against it, and `git_diff_find_similar`
|
||||
/// pairs the ends. **Nothing is written**: the index file on disk is untouched, which is what
|
||||
/// keeps this a read, and the index object is reset to HEAD on the way out so a caller that goes
|
||||
/// on to stage a *subset* starts from a known base rather than from everything.
|
||||
/// **`nil` means the survey could not be taken**, which is emphatically not the same answer as
|
||||
/// "nothing changed" and must never be flattened into it.
|
||||
///
|
||||
/// Staging writes blobs into the object store, so a repository whose `.git/objects` has become
|
||||
/// unwritable fails *here* rather than at the commit — and a version of this that shrugged and
|
||||
/// returned no paths would report a clean tree, no-op silently, and let history stop advancing
|
||||
/// with nothing on the banner strip. That is precisely the case 06 separates from contention:
|
||||
/// "Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk
|
||||
/// but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing."
|
||||
/// (Found by test rather than by reading: the failure suite went green-by-silence when discovery
|
||||
/// moved from `git_status` to staging.)
|
||||
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [ChangedPath]? {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return nil }
|
||||
defer { git_repository_free(repository) }
|
||||
var index: OpaquePointer?
|
||||
guard git_repository_index(&index, repository) == 0, let index else { return nil }
|
||||
defer { git_index_free(index) }
|
||||
return changedPaths(in: repository, index: index)
|
||||
}
|
||||
|
||||
/// The survey, with "could not look" folded into "nothing to do" — for the callers that have no
|
||||
/// failure channel and want the safe answer: `GitRepository.create`'s branch line, and the tests'
|
||||
/// clean-tree assertions.
|
||||
nonisolated static func changedPaths(at boardRoot: URL) -> [ChangedPath] {
|
||||
surveyChangedPaths(at: boardRoot) ?? []
|
||||
}
|
||||
|
||||
private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [ChangedPath]? {
|
||||
var pathspec = git_strarray()
|
||||
guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else {
|
||||
return nil
|
||||
}
|
||||
defer { resetIndexToHead(index, in: repository) }
|
||||
|
||||
let parent = headCommit(of: repository)
|
||||
defer { parent.map(git_commit_free) }
|
||||
var headTree: OpaquePointer?
|
||||
if let parent { git_commit_tree(&headTree, parent) }
|
||||
defer { headTree.map(git_tree_free) }
|
||||
|
||||
var diff: OpaquePointer?
|
||||
var options = git_diff_options()
|
||||
guard git_diff_options_init(&options, UInt32(GIT_DIFF_OPTIONS_VERSION)) == 0,
|
||||
git_diff_tree_to_index(&diff, repository, headTree, index, &options) == 0,
|
||||
let diff else { return nil }
|
||||
defer { git_diff_free(diff) }
|
||||
|
||||
var findOptions = git_diff_find_options()
|
||||
if git_diff_find_options_init(&findOptions, UInt32(GIT_DIFF_FIND_OPTIONS_VERSION)) == 0 {
|
||||
findOptions.flags = GIT_DIFF_FIND_RENAMES.rawValue
|
||||
// Best-effort: a diff too large for the similarity pass simply reports the unpaired
|
||||
// shape, which demotes the window to the generic external author — the safe direction,
|
||||
// and the one the guide's re-stamping advice already covers.
|
||||
_ = git_diff_find_similar(diff, &findOptions)
|
||||
}
|
||||
|
||||
var found: [String: ChangedPath] = [:]
|
||||
|
||||
func record(_ path: String?, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
|
||||
guard let path, !path.isEmpty else { return }
|
||||
let existing = found[path]
|
||||
found[path] = ChangedPath(
|
||||
path: path,
|
||||
// Present wins where two deltas disagree: staging asks "is it there now", and the
|
||||
// `modified-by` demotion must not fire for a file the window ends with.
|
||||
isDeletion: (existing?.isDeletion ?? true) && isDeletion,
|
||||
isRename: (existing?.isRename ?? false) || isRename,
|
||||
// New wins, for the mirror of that reason: one delta calling a path an addition is
|
||||
// enough to know HEAD did not have it, which is the whole content of the bit.
|
||||
isArrival: (existing?.isArrival ?? false) || isArrival
|
||||
)
|
||||
}
|
||||
|
||||
for position in 0..<git_diff_num_deltas(diff) {
|
||||
guard let delta = git_diff_get_delta(diff, position)?.pointee else { continue }
|
||||
switch delta.status {
|
||||
case GIT_DELTA_DELETED:
|
||||
record(string(delta.old_file.path), isDeletion: true, isRename: false)
|
||||
case GIT_DELTA_RENAMED:
|
||||
// Both ends, and neither is a deletion the window may be demoted by: the departure
|
||||
// has to leave the index and the arrival has to enter it. The arriving end is new at
|
||||
// its path, which is what the comment family reads a post by.
|
||||
record(string(delta.old_file.path), isDeletion: true, isRename: true)
|
||||
record(string(delta.new_file.path), isDeletion: false, isRename: true, isArrival: true)
|
||||
case GIT_DELTA_ADDED, GIT_DELTA_COPIED, GIT_DELTA_UNTRACKED:
|
||||
record(string(delta.new_file.path), isDeletion: false, isRename: false, isArrival: true)
|
||||
default:
|
||||
record(string(delta.new_file.path), isDeletion: false, isRename: false)
|
||||
}
|
||||
}
|
||||
|
||||
return found.values.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
/// Puts the index object back to exactly HEAD's tree — the base every per-class staging works up
|
||||
/// from, and what makes the discovery pass above a read.
|
||||
///
|
||||
/// On an unborn HEAD that is an empty index, which is the same statement with no tree to say it
|
||||
/// with. Note this is the *in-memory* index: nothing here calls `git_index_write`, so a caller
|
||||
/// that abandons the flush leaves the file on disk exactly as it found it, staged changes of
|
||||
/// another writer's included.
|
||||
private static func resetIndexToHead(_ index: OpaquePointer, in repository: OpaquePointer) {
|
||||
guard let parent = headCommit(of: repository) else {
|
||||
git_index_clear(index)
|
||||
return
|
||||
}
|
||||
defer { git_commit_free(parent) }
|
||||
var tree: OpaquePointer?
|
||||
guard git_commit_tree(&tree, parent) == 0, let tree else { return }
|
||||
defer { git_tree_free(tree) }
|
||||
git_index_read_tree(index, tree)
|
||||
}
|
||||
|
||||
// MARK: - Committing
|
||||
|
||||
/// **Stages and commits each plan in turn, with explicit signatures.**
|
||||
///
|
||||
/// The plans are committed **in the order given** and each is a whole commit of its own — which
|
||||
/// is how the two-commit split ("A debounce window containing both kinds is split into two
|
||||
/// commits, never mixed") and the heal's third class (ruled 2026-07-29) become one mechanism
|
||||
/// rather than three code paths.
|
||||
///
|
||||
/// A plan whose staging produces the tree HEAD already has is **skipped, not committed**: an
|
||||
/// empty commit says nothing and would make `git log` a record of the debounce timer rather than
|
||||
/// of the board. That is also the clean-tree no-op, arrived at without a special case.
|
||||
///
|
||||
/// - Parameter allowRootCommit: whether an unborn HEAD may take its root commit here. The caller
|
||||
/// passes `true`; it exists so the "first settled change on an adopted unborn repo commits the
|
||||
/// whole tree as *Initial board state*" rule stays a decision the *planner* made and is not
|
||||
/// re-derived down here.
|
||||
nonisolated static func perform(
|
||||
at boardRoot: URL,
|
||||
commits: [PlannedCommit],
|
||||
allowRootCommit: Bool = true
|
||||
) -> GitCommitOutcome {
|
||||
_ = startUp
|
||||
guard !commits.isEmpty else { return .nothingToCommit }
|
||||
guard let repository = open(boardRoot) else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
// Re-checked here, inside the same handle that is about to write, rather than trusted from
|
||||
// the caller's earlier `reading(at:)`: between the two a terminal can have started a rebase,
|
||||
// and 06's rule is that the check runs "again before every flush".
|
||||
if git_repository_head_unborn(repository) == 1 {
|
||||
guard allowRootCommit else { return .nothingToCommit }
|
||||
} else if git_repository_head_detached(repository) == 1 {
|
||||
return .held(.detachedHead)
|
||||
}
|
||||
if let pause = pause(of: repository) { return .held(pause) }
|
||||
if isIndexLocked(gitDirectory: gitDirectory(of: repository)) { return .locked }
|
||||
|
||||
var index: OpaquePointer?
|
||||
guard git_repository_index(&index, repository) == 0, let index else {
|
||||
return failure(lastErrorMessage())
|
||||
}
|
||||
defer { git_index_free(index) }
|
||||
|
||||
// **Every split starts from HEAD, not from whatever the index happened to hold.** Each plan
|
||||
// below writes the *whole* index as a tree, so a change another writer had staged but not
|
||||
// committed would otherwise ride into whichever commit came first — silently attributing it
|
||||
// to that class. Resetting makes each commit exactly HEAD plus the paths its own class
|
||||
// staged, which is what "split into two commits, never mixed" has to mean. The staged change
|
||||
// is not lost: it is a changed path like any other and is classified and committed on its
|
||||
// own terms.
|
||||
resetIndexToHead(index, in: repository)
|
||||
|
||||
var landed: [GitLandedCommit] = []
|
||||
for plan in commits {
|
||||
switch commit(plan, in: repository, index: index) {
|
||||
case let .landed(oid):
|
||||
landed.append(GitLandedCommit(oid: oid, kind: plan.kind))
|
||||
case .skipped:
|
||||
continue
|
||||
case let .stopped(outcome):
|
||||
// Whatever landed before the failure stays landed — those commits are real, and
|
||||
// reporting them is what lets the caller clear the suspension for the half that
|
||||
// worked while retrying the rest on the next debounce.
|
||||
if case let .failed(reason) = outcome, !landed.isEmpty {
|
||||
logger.error("commit split failed partway: \(reason.message, privacy: .public)")
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
return landed.isEmpty ? .nothingToCommit : .committed(landed)
|
||||
}
|
||||
|
||||
/// What one plan did.
|
||||
private enum CommitStep {
|
||||
case landed(String)
|
||||
/// Its staging produced the tree HEAD already has — an empty commit, deliberately not made.
|
||||
case skipped
|
||||
case stopped(GitCommitOutcome)
|
||||
}
|
||||
|
||||
/// One plan: stage its paths, write the tree, and create the commit if the tree is new.
|
||||
private static func commit(
|
||||
_ plan: PlannedCommit,
|
||||
in repository: OpaquePointer,
|
||||
index: OpaquePointer
|
||||
) -> CommitStep {
|
||||
// **Path by path, never a pathspec.** `git_index_add_all` would take a glob, and a card
|
||||
// titled with a `[` in its folder name is a real board; exact `add`/`remove` calls also make
|
||||
// the stage-around exact — an excluded folder is one this loop never mentions, rather than
|
||||
// one a matcher has to be trusted to miss.
|
||||
for path in plan.paths {
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: workdir(of: repository).appendingPathComponent(path).path
|
||||
)
|
||||
let status = exists
|
||||
? git_index_add_bypath(index, path)
|
||||
: git_index_remove_bypath(index, path)
|
||||
// `GIT_ENOTFOUND` on a removal is a path the index never had — an untracked file that
|
||||
// vanished inside the window. Nothing to stage and nothing wrong.
|
||||
guard status == 0 || (!exists && status == GIT_ENOTFOUND.rawValue) else {
|
||||
return .stopped(classify(status))
|
||||
}
|
||||
}
|
||||
|
||||
var treeOID = git_oid()
|
||||
guard git_index_write_tree(&treeOID, index) == 0 else { return .stopped(classify(lastErrorCode())) }
|
||||
|
||||
let parent = headCommit(of: repository)
|
||||
defer { parent.map(git_commit_free) }
|
||||
if let parent, let headTree = treeIdentity(of: parent), equal(headTree, treeOID) {
|
||||
return .skipped
|
||||
}
|
||||
|
||||
// The index is persisted **before** the commit, deliberately: this is the call `index.lock`
|
||||
// bites on, and failing here leaves an unreferenced tree object (garbage libgit2 collects)
|
||||
// rather than a commit whose index nobody can see.
|
||||
guard git_index_write(index) == 0 else { return .stopped(classify(lastErrorCode())) }
|
||||
|
||||
var tree: OpaquePointer?
|
||||
guard git_tree_lookup(&tree, repository, &treeOID) == 0, let tree else {
|
||||
return .stopped(classify(lastErrorCode()))
|
||||
}
|
||||
defer { git_tree_free(tree) }
|
||||
|
||||
guard let author = signature(plan.author), let committer = signature(plan.committer) else {
|
||||
return .stopped(failure(lastErrorMessage()))
|
||||
}
|
||||
defer {
|
||||
git_signature_free(author)
|
||||
git_signature_free(committer)
|
||||
}
|
||||
|
||||
var commitOID = git_oid()
|
||||
var parents: [OpaquePointer?] = parent.map { [$0] } ?? []
|
||||
let status = parents.withUnsafeMutableBufferPointer { buffer in
|
||||
git_commit_create(
|
||||
&commitOID,
|
||||
repository,
|
||||
// "HEAD" rather than a branch name: on an unborn HEAD this creates the branch the
|
||||
// symbolic ref names, and on a born one it advances whatever branch is checked out —
|
||||
// one call for the root commit and every commit after it.
|
||||
"HEAD",
|
||||
author,
|
||||
committer,
|
||||
nil,
|
||||
plan.message,
|
||||
tree,
|
||||
buffer.count,
|
||||
buffer.baseAddress
|
||||
)
|
||||
}
|
||||
guard status == 0 else { return .stopped(classify(status)) }
|
||||
return .landed(hex(commitOID))
|
||||
}
|
||||
|
||||
// MARK: - Identity
|
||||
|
||||
/// **Where the user's identity comes from, resolved at commit time** (06 ▸ Interaction with
|
||||
/// external writers ▸ "Where the user's git identity comes from") — repo-local `.git/config`
|
||||
/// when present, the derived default otherwise.
|
||||
///
|
||||
/// **The one place that order lives.** Until this card, `GitRepository.applyIdentity` also
|
||||
/// encoded it, by *materializing* the resolved identity into the new repository's config so that
|
||||
/// libgit2's signature-less commit would find something; that was an explicit interim and it is
|
||||
/// gone. Nothing writes `user.name`/`user.email` any more: the popover's identity fields (a
|
||||
/// later card) will, because there "the setting *is* the file", and an app that wrote the file
|
||||
/// on its own could never tell its own default from the user's choice.
|
||||
///
|
||||
/// The `.git` directory is libgit2's answer rather than `boardRoot/.git`, so a board whose
|
||||
/// `.git` is a *file* (a linked worktree — `BoardGitMode` counts those as git mode) resolves its
|
||||
/// real config instead of trying to parse a pointer.
|
||||
nonisolated static func userIdentity(at boardRoot: URL) -> GitIdentity {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else {
|
||||
return GitIdentity.resolve(repoLocal: (nil, nil), derived: .derivedDefault())
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
return GitIdentity.resolve(
|
||||
repoLocal: GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository)),
|
||||
derived: .derivedDefault()
|
||||
)
|
||||
}
|
||||
|
||||
/// **What repo-local config actually says** — the two values behind the popover's identity fields,
|
||||
/// each `nil` when the file does not name it (06 ▸ Interaction with external writers).
|
||||
///
|
||||
/// Deliberately *not* `userIdentity(at:)`: that answers "who will this commit be by", derived
|
||||
/// default included, and a field pre-filled with a derived value would turn a placeholder into a
|
||||
/// value the moment the user typed anywhere else in the popover. The fields show what the file
|
||||
/// says and nothing more; the derived default is their placeholder.
|
||||
nonisolated static func repoLocalIdentity(at boardRoot: URL) -> (name: String?, email: String?) {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return (nil, nil) }
|
||||
defer { git_repository_free(repository) }
|
||||
return GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository))
|
||||
}
|
||||
|
||||
/// **Writes the popover's identity fields into repo-local config** — the one write of those keys
|
||||
/// in the app (`GitConfigFile.writeIdentity`, where the file-format rules live).
|
||||
///
|
||||
/// The `.git` directory comes from libgit2 rather than from `boardRoot/.git`, for
|
||||
/// `userIdentity(at:)`'s reason: a board whose `.git` is a *file* (a linked worktree) has its real
|
||||
/// config somewhere else, and writing beside the pointer would be writing to nothing.
|
||||
nonisolated static func writeRepoLocalIdentity(
|
||||
name: String?,
|
||||
email: String?,
|
||||
at boardRoot: URL
|
||||
) -> Result<Void, GitOperationFailure> {
|
||||
_ = startUp
|
||||
let operation = "Saving this board's commit identity"
|
||||
guard let repository = open(boardRoot) else {
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "this board's repository could not be opened"
|
||||
))
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
do {
|
||||
try GitConfigFile.writeIdentity(
|
||||
name: name,
|
||||
email: email,
|
||||
inGitDirectory: gitDirectory(of: repository)
|
||||
)
|
||||
return .success(())
|
||||
} catch {
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: (error as NSError).localizedDescription
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private static func signature(_ identity: GitIdentity) -> UnsafeMutablePointer<git_signature>? {
|
||||
var signature: UnsafeMutablePointer<git_signature>?
|
||||
let now = Date()
|
||||
let status = git_signature_new(
|
||||
&signature,
|
||||
identity.name,
|
||||
identity.email,
|
||||
git_time_t(now.timeIntervalSince1970),
|
||||
Int32(TimeZone.current.secondsFromGMT(for: now) / 60)
|
||||
)
|
||||
return status == 0 ? signature : nil
|
||||
}
|
||||
|
||||
// MARK: - index.lock
|
||||
|
||||
/// Whether `.git/index.lock` is there right now.
|
||||
///
|
||||
/// **Never removed, whatever its age** (06 ▸ Interaction with external writers): "a crashed
|
||||
/// writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's
|
||||
/// own leftovers". The pathfinder deleted locks older than ten minutes; that heuristic is
|
||||
/// deliberately not carried over — it is precisely a mutation of repo state the app did not
|
||||
/// create.
|
||||
nonisolated static func isIndexLocked(at boardRoot: URL) -> Bool {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return false }
|
||||
defer { git_repository_free(repository) }
|
||||
return isIndexLocked(gitDirectory: gitDirectory(of: repository))
|
||||
}
|
||||
|
||||
private static func isIndexLocked(gitDirectory: URL) -> Bool {
|
||||
FileManager.default.fileExists(atPath: gitDirectory.appendingPathComponent("index.lock").path)
|
||||
}
|
||||
|
||||
// MARK: - Private plumbing
|
||||
|
||||
private static let operationName = "Recording this board's history"
|
||||
|
||||
private static func open(_ boardRoot: URL) -> OpaquePointer? {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
|
||||
var repository: OpaquePointer?
|
||||
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
|
||||
return repository
|
||||
}
|
||||
|
||||
private static func gitDirectory(of repository: OpaquePointer) -> URL {
|
||||
URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func workdir(of repository: OpaquePointer) -> URL {
|
||||
URL(fileURLWithPath: string(git_repository_workdir(repository)) ?? "", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
|
||||
var reference: OpaquePointer?
|
||||
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
|
||||
defer { git_reference_free(reference) }
|
||||
var object: OpaquePointer?
|
||||
guard git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0 else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
private static func treeIdentity(of commit: OpaquePointer) -> git_oid? {
|
||||
guard let tree = git_commit_tree_id(commit) else { return nil }
|
||||
return tree.pointee
|
||||
}
|
||||
|
||||
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
|
||||
var left = lhs
|
||||
var right = rhs
|
||||
return git_oid_cmp(&left, &right) == 0
|
||||
}
|
||||
|
||||
private static func hex(_ oid: git_oid) -> String {
|
||||
var value = oid
|
||||
var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
|
||||
git_oid_fmt(&buffer, &value)
|
||||
return String(cString: buffer)
|
||||
}
|
||||
|
||||
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
|
||||
pointer.map { String(cString: $0) }
|
||||
}
|
||||
|
||||
/// libgit2's message for whatever just failed, or a shrug when it set none.
|
||||
private static func lastErrorMessage() -> String {
|
||||
guard let error = git_error_last(), let message = error.pointee.message else {
|
||||
return "libgit2 reported no reason"
|
||||
}
|
||||
return String(cString: message)
|
||||
}
|
||||
|
||||
private static func lastErrorCode() -> Int32 {
|
||||
git_error_last() != nil ? GIT_ERROR.rawValue : GIT_ERROR.rawValue
|
||||
}
|
||||
|
||||
/// Turns a libgit2 status into the outcome 06 gives it.
|
||||
///
|
||||
/// **`GIT_ELOCKED` is the whole reason this exists**: contention is "never an error", so it must
|
||||
/// not travel the same road as a disk failure. Everything else is a genuine failure carrying
|
||||
/// libgit2's own message.
|
||||
private static func classify(_ status: Int32) -> GitCommitOutcome {
|
||||
status == GIT_ELOCKED.rawValue ? .locked : failure(lastErrorMessage())
|
||||
}
|
||||
|
||||
private static func failure(_ message: String) -> GitCommitOutcome {
|
||||
.failed(GitOperationFailure(operation: operationName, message: message))
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import Foundation
|
||||
import libgit2
|
||||
import os
|
||||
|
||||
// MARK: - GitHeadSnapshot
|
||||
|
||||
/// **The last-committed half of the composer's diff** (06-history-undo.md ▸ Commit messages: "a
|
||||
/// structural diff of two board snapshots — last-committed vs. current").
|
||||
///
|
||||
/// ### Why HEAD's tree, and not a snapshot carried forward
|
||||
///
|
||||
/// The engine could remember the board it committed last time and hand that back as "previous". It
|
||||
/// deliberately does not, for four reasons, each of which is a case the carried value would get
|
||||
/// wrong:
|
||||
///
|
||||
/// - **Launch catch-up has no previous to carry.** "Changes found pending at board open diff HEAD's
|
||||
/// tree against the working tree through the same composer" (06) — at open the app's only snapshot
|
||||
/// is the one it just loaded, which already *contains* the pending changes. The previous state
|
||||
/// exists nowhere but in the repository.
|
||||
/// - **The app is not the only writer.** An agent that commits its own work moves HEAD without the
|
||||
/// app writing anything; a carried snapshot would diff against a state that is already history.
|
||||
/// - **A failed or skipped commit does not advance history.** A carried value would advance anyway
|
||||
/// and silently under-describe the next window.
|
||||
/// - **It is checkable.** "Last committed" is a fact the repository answers; a carried value is a
|
||||
/// claim the engine makes about itself, and nothing would ever catch it drifting.
|
||||
///
|
||||
/// The cost is this file: HEAD's tree is materialized into a temporary directory and read back
|
||||
/// through the one `BoardLoader`, so the previous snapshot is produced by exactly the machinery that
|
||||
/// produced the current one. Re-parsing rather than re-deriving is the point — two loaders would be
|
||||
/// two definitions of what a board is.
|
||||
///
|
||||
/// ### What it writes
|
||||
///
|
||||
/// **`index.md` blobs in full; every other blob as a zero-byte placeholder.** The snapshot models
|
||||
/// frontmatter, bodies and *attachment names* — never attachment bytes — so materializing a board's
|
||||
/// images would copy megabytes per commit to answer a question about file names. Directories are
|
||||
/// created so the shape the loader walks is the shape HEAD has.
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in
|
||||
/// the same synchronous scope, and no handle crosses an `await`. Called from inside the flush's
|
||||
/// detached task, never from the main actor.
|
||||
enum GitHeadSnapshot {
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// libgit2's global state — `GitCommitOperation.startUp`'s twin and for its reason (a flush can
|
||||
/// run when no `Repository` is alive, so this file cannot ride on SwiftGitX's refcount).
|
||||
private static let startUp: Bool = {
|
||||
git_libgit2_init() >= 0
|
||||
}()
|
||||
|
||||
/// **The board as HEAD has it**, or `nil` when there is nothing to read one from: an unborn HEAD,
|
||||
/// an unopenable repository, a tree with no board `index.md` in it.
|
||||
///
|
||||
/// `nil` is a *shrug*, not an error — the composer that receives it simply has no previous half
|
||||
/// and falls back to describing the commit by its paths. Nothing here can fail a commit.
|
||||
nonisolated static func load(at boardRoot: URL) -> BoardModel? {
|
||||
_ = startUp
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
|
||||
var repository: OpaquePointer?
|
||||
guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return nil }
|
||||
defer { git_repository_free(repository) }
|
||||
guard git_repository_head_unborn(repository) != 1 else { return nil }
|
||||
|
||||
var reference: OpaquePointer?
|
||||
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
|
||||
defer { git_reference_free(reference) }
|
||||
var object: OpaquePointer?
|
||||
guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0, let tree = object else {
|
||||
return nil
|
||||
}
|
||||
defer { git_tree_free(tree) }
|
||||
|
||||
let scratch = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("LaneworkHeadSnapshot-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: scratch) }
|
||||
guard (try? FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)) != nil
|
||||
else { return nil }
|
||||
|
||||
materialize(tree: tree, in: repository, into: scratch, depth: 0)
|
||||
|
||||
do {
|
||||
return try BoardLoader.load(boardRoot: scratch).model
|
||||
} catch {
|
||||
// A HEAD whose tree the loader refuses — a board committed before `index.md` existed, a
|
||||
// schema from the future — is simply not a previous snapshot. The window still commits;
|
||||
// its message is composed from the paths alone.
|
||||
logger.debug("HEAD's tree did not load as a board: \(error.description, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// One tree level, recursively. Total and silent: a blob that cannot be read is skipped, because
|
||||
/// a partial previous snapshot degrades one event's wording while a thrown error would cost the
|
||||
/// commit its message entirely.
|
||||
///
|
||||
/// The depth cap is a guard against a pathological repository, not a statement about boards — a
|
||||
/// board is three levels deep, four counting `attachments/`.
|
||||
private static func materialize(
|
||||
tree: OpaquePointer,
|
||||
in repository: OpaquePointer,
|
||||
into directory: URL,
|
||||
depth: Int
|
||||
) {
|
||||
guard depth < 8 else { return }
|
||||
let manager = FileManager.default
|
||||
|
||||
for position in 0..<git_tree_entrycount(tree) {
|
||||
guard let entry = git_tree_entry_byindex(tree, position),
|
||||
let rawName = git_tree_entry_name(entry) else { continue }
|
||||
let name = String(cString: rawName)
|
||||
guard !name.isEmpty, name != ".", name != ".." else { continue }
|
||||
// A `/` in a tree entry name is impossible in a well-formed tree and would be a path
|
||||
// escape if it were not: refuse rather than interpret.
|
||||
guard !name.contains("/") else { continue }
|
||||
|
||||
switch git_tree_entry_type(entry) {
|
||||
case GIT_OBJECT_TREE:
|
||||
var child: OpaquePointer?
|
||||
guard let id = git_tree_entry_id(entry),
|
||||
git_tree_lookup(&child, repository, id) == 0,
|
||||
let child else { continue }
|
||||
defer { git_tree_free(child) }
|
||||
let folder = directory.appendingPathComponent(name, isDirectory: true)
|
||||
guard (try? manager.createDirectory(at: folder, withIntermediateDirectories: true)) != nil
|
||||
else { continue }
|
||||
materialize(tree: child, in: repository, into: folder, depth: depth + 1)
|
||||
|
||||
case GIT_OBJECT_BLOB:
|
||||
let file = directory.appendingPathComponent(name)
|
||||
// **Content for `index.md`, a placeholder for everything else.** The loader reads
|
||||
// frontmatter and bodies out of the first and only the *names* of the rest.
|
||||
guard name == IntegrityRules.indexFileName else {
|
||||
try? Data().write(to: file)
|
||||
continue
|
||||
}
|
||||
var blob: OpaquePointer?
|
||||
guard let id = git_tree_entry_id(entry),
|
||||
git_blob_lookup(&blob, repository, id) == 0,
|
||||
let blob else { continue }
|
||||
defer { git_blob_free(blob) }
|
||||
let size = Int(git_blob_rawsize(blob))
|
||||
let bytes = git_blob_rawcontent(blob)
|
||||
let data = (bytes != nil && size > 0)
|
||||
? Data(bytes: bytes!, count: size)
|
||||
: Data()
|
||||
try? data.write(to: file)
|
||||
|
||||
default:
|
||||
// Submodules and symlinks: neither is a board, and neither is followed anywhere else
|
||||
// in this app either (`BoardLoader.directoryCandidates` excludes links).
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,608 +0,0 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - GitHistoryProvider
|
||||
|
||||
/// **Pro's undo substrate: the commit trail itself** (06-history-undo.md; 12-editions.md ▸ The
|
||||
/// provider seam) — the second implementation of `HistoryProviding`, and the one the seam was
|
||||
/// designed around.
|
||||
///
|
||||
/// ### The stack is not a stack
|
||||
///
|
||||
/// "The stack **is** HEAD's first-parent ancestry, live" (06 ▸ Rules). Nothing here records a step
|
||||
/// when the board is written to; `register(_:)` is a deliberate no-op, because on a git board an undo
|
||||
/// step is a *commit* and commits are made by the auto-committer, by an agent, or by a terminal. What
|
||||
/// this object holds is a **pointer into that ancestry** — which commit ⌘Z would cross next — plus a
|
||||
/// redo list of the commits already crossed in this session. Both are caches over a repository that
|
||||
/// remains the only truth, which is what makes "no sidecar state, nothing ever lost" (14 ▸ C8) a
|
||||
/// property of the shape rather than a discipline.
|
||||
///
|
||||
/// ### Four rules, and where each one lives
|
||||
///
|
||||
/// - **Forward only.** A crossing writes an older state as a *new commit* — `GitRestoreOperation`,
|
||||
/// which cannot reset because it never resolves a reset symbol. Old commits stay reachable; refs
|
||||
/// only move forward.
|
||||
/// - **Exactly one commit per ⌘Z.** The pre-flight sync (`syncToHEAD`) re-reads HEAD before every
|
||||
/// crossing, so agents' self-commits landed since the last operation become the new top and ⌘Z
|
||||
/// steps back over *them* rather than silently reverting twenty minutes of their work.
|
||||
/// - **Any arrival clears redo.** From the pre-flight sync for commits made outside the app, and from
|
||||
/// `noteLanded(_:)` for the ones this app's committer made — with the one exception the heal rule
|
||||
/// requires (below).
|
||||
/// - **In-session and post-relaunch are one rule.** `reseed()` is the same ancestry walk from
|
||||
/// scratch, so a relaunch, a branch switch and a foreign arrival all take the same path.
|
||||
///
|
||||
/// ### Heal transparency, and its honest limit
|
||||
///
|
||||
/// Heal-class commits never become steps: the pointer passes over them, and a restore excludes the
|
||||
/// paths whose divergence is heal work — so a ⌘Z run never reverts a repair and never re-arms the
|
||||
/// healer (06 ▸ Rules ▸ Heal commits are transparent to undo, in-session). Both halves are learned
|
||||
/// from `GitAutoCommitter.reportLanded`, which fires while the Writer's heal-marked receipts still
|
||||
/// exist. **In-session is the whole of it, deliberately**: the reseed is sidecar-free, so after a
|
||||
/// relaunch old heal commits reappear as ordinary steps — the accepted one-bounce residual, named in
|
||||
/// 06 and not worked around here.
|
||||
///
|
||||
/// ### Asynchrony
|
||||
///
|
||||
/// `HistoryProviding.undo()` is synchronous because a menu item is; a restore is a settle step, a
|
||||
/// libgit2 diff, a set of writes and a commit. So the protocol methods start a `Task` and return, and
|
||||
/// `cross(_:)` is the awaitable one a test drives. Enablement never waits on any of it: `canUndo`,
|
||||
/// `canRedo` and both action names answer from the cached ancestry, so menu validation costs nothing.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class GitHistoryProvider: HistoryProviding {
|
||||
|
||||
// MARK: - Identity
|
||||
|
||||
/// The board this is the history of — in git mode, the repository's working-tree root.
|
||||
public let boardRoot: URL
|
||||
|
||||
// MARK: - Seams
|
||||
|
||||
/// **The pending auto-commit, flushed before a restore commits** (06 ▸ Rules ▸ Flush-before-
|
||||
/// overwrite, applied here by the card's own rule: settled tree first, then one more commit).
|
||||
///
|
||||
/// Without it a ⌘Z would commit an older state on top of edits that never got a commit of their
|
||||
/// own — the forward trail would be missing the very version the undo is stepping back from.
|
||||
@ObservationIgnored
|
||||
public var flushPendingCommit: (@MainActor () async -> Void)?
|
||||
|
||||
/// Whether the git surface is **held** — a detached HEAD or an in-progress merge/rebase
|
||||
/// (06 ▸ Rules ▸ Abnormal repo states: "Undo/Redo and the branch controls disable"). Reads
|
||||
/// `GitAutoCommitter.pause`, which is in-memory state, so enablement stays free.
|
||||
@ObservationIgnored
|
||||
public var isHeld: (@MainActor () -> Bool)?
|
||||
|
||||
/// Stops and restarts the auto-commit debounce around a restore, so its own writes cannot be
|
||||
/// half-committed by a timer that fires mid-materialization.
|
||||
@ObservationIgnored
|
||||
public var suspendCommitting: (@MainActor () -> Void)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var resumeCommitting: (@MainActor () -> Void)?
|
||||
|
||||
/// **The save-or-discard step** (06 ▸ Rules ▸ Undo restore vs open Edit sessions). `nil` is a
|
||||
/// board with no card windows to settle — every storeless test, and a session composed before any
|
||||
/// window opened.
|
||||
@ObservationIgnored
|
||||
public var settleSessions: (@MainActor (Set<String>) async -> SessionSettleOutcome)?
|
||||
|
||||
/// Runs the restore inside the store's wholesale bracket — watcher suspended, one full reload at
|
||||
/// the end, the board locked if that reload fails (02-architecture.md; `BoardStore.performWholesale`).
|
||||
/// `nil` runs the work bare, which is what a repository-level test wants.
|
||||
@ObservationIgnored
|
||||
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
|
||||
|
||||
/// A genuine restore failure — surfaced as 02's one-shot banner by whoever wires it.
|
||||
///
|
||||
/// **The direction travels with the failure** (02-architecture.md ▸ The banner surface, settled
|
||||
/// 2026-07-31): the one-shot failure class's second shape names the operation in the user's
|
||||
/// words — "Undo failed", "Redo failed" — and this object is the only one that knows which key
|
||||
/// was pressed. Everything past that boundary is the banner's: the closure receives the
|
||||
/// direction and libgit2's own message, never a sentence composed here.
|
||||
@ObservationIgnored
|
||||
public var reportFailure: (@MainActor (HistoryDirection, GitOperationFailure) -> Void)?
|
||||
|
||||
// MARK: - The cached stack
|
||||
|
||||
/// HEAD's first-parent ancestry as of the last sync, newest first. The *stack*, cached.
|
||||
public private(set) var ancestry: [GitCommitRecord] = []
|
||||
|
||||
/// The oid of the commit ⌘Z would cross next, or `nil` before the first seed. Not always
|
||||
/// `ancestry.first`: after an undo the pointer sits below the restore commit the undo just made,
|
||||
/// which is the whole mechanism behind "the undo-menu labels are the *crossed* commit's subject,
|
||||
/// so labels never nest" (06 ▸ Commit messages).
|
||||
public private(set) var pointerOID: String?
|
||||
|
||||
/// Commits crossed by ⌘Z in this session, oldest crossed first — ⇧⌘Z restores the state *at* the
|
||||
/// last of them. Empty on every seed: "redo starts empty" (06 ▸ Rules ▸ Undo survives relaunch).
|
||||
public private(set) var redoCommits: [GitCommitRecord] = []
|
||||
|
||||
/// The HEAD this cache was built against — the pre-flight sync's comparison.
|
||||
private var knownHead: String?
|
||||
|
||||
/// Heal-class commits landed **in this session**, which the pointer passes over.
|
||||
private var healOIDs: Set<String> = []
|
||||
|
||||
/// Paths committed as heal work in this session, which a restore never materializes.
|
||||
private var healPaths: Set<String> = []
|
||||
|
||||
/// Whether a crossing is in flight — a second ⌘Z during a restore must not start a second one.
|
||||
public private(set) var isCrossing = false
|
||||
|
||||
/// How many restores this provider has landed — the trail's own testimony, so a test need not
|
||||
/// infer a crossing from a commit walk.
|
||||
public private(set) var restoreCount = 0
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
public init(boardRoot: URL) {
|
||||
self.boardRoot = boardRoot
|
||||
}
|
||||
|
||||
// MARK: - Seeding
|
||||
|
||||
/// **Reseeds the stack from HEAD's first-parent ancestry, with an empty redo** — the API a board
|
||||
/// open, a relaunch, and a **branch switch** all enter through (06 ▸ Rules ▸ Undo survives
|
||||
/// relaunch; ▸ Branch switching: "The undo/redo stack does not survive a switch. It is discarded
|
||||
/// and reseeded from the new HEAD's first-parent ancestry … redo starts empty").
|
||||
///
|
||||
/// Synchronous and off the main actor is not an option — the walk is libgit2 — so this is the
|
||||
/// awaitable seed and `seed()` is the fire-and-forget one an open path can call.
|
||||
public func reseed() async {
|
||||
let root = boardRoot
|
||||
let records = await Task.detached(priority: .userInitiated) {
|
||||
GitHistoryWalk.ancestry(at: root)
|
||||
}.value
|
||||
adopt(records)
|
||||
}
|
||||
|
||||
/// `reseed()` without waiting — what a board open and an add-git flip call.
|
||||
public func seed() {
|
||||
Task { await reseed() }
|
||||
}
|
||||
|
||||
/// The reseed's main-actor half, split out so the sync path can reuse it.
|
||||
private func adopt(_ records: [GitCommitRecord]) {
|
||||
ancestry = records
|
||||
knownHead = records.first?.oid
|
||||
pointerOID = records.first?.oid
|
||||
redoCommits = []
|
||||
}
|
||||
|
||||
/// **The pre-flight sync** (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live):
|
||||
/// "The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the
|
||||
/// app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly
|
||||
/// **one** commit."
|
||||
///
|
||||
/// One reference read when nothing moved, a full reseed when something did. A reseed here is the
|
||||
/// same reseed a relaunch does, which is the point: "In-session and post-relaunch behavior are
|
||||
/// thereby one rule."
|
||||
private func syncToHEAD() async {
|
||||
let root = boardRoot
|
||||
let head = await Task.detached(priority: .userInitiated) {
|
||||
GitHistoryWalk.headOID(at: root)
|
||||
}.value
|
||||
guard head != knownHead else { return }
|
||||
Self.logger.debug("undo stack re-syncing: HEAD moved outside the stack's knowledge")
|
||||
await reseed()
|
||||
}
|
||||
|
||||
// MARK: - What the committer tells it
|
||||
|
||||
/// **A flush landed** — `GitAutoCommitter.reportLanded`.
|
||||
///
|
||||
/// Two behaviours, and the split between them is the heal rule:
|
||||
///
|
||||
/// - A window of **nothing but heal commits** leaves the pointer and the redo list exactly where
|
||||
/// they were, and only records what was healed. That is what keeps an undo run from being
|
||||
/// trapped on an ever-renewing top: "the fresh heal commit is in-session, transparent, and the
|
||||
/// undo run continues past it" (06).
|
||||
/// - **Anything else is an arrival**, and "any commit arriving from anywhere clears the redo
|
||||
/// stack (classic behavior)" — with the new commit becoming the top of the stack, so the next
|
||||
/// ⌘Z crosses what just happened.
|
||||
public func noteLanded(_ window: GitLandedWindow) {
|
||||
healOIDs.formUnion(window.healOIDs)
|
||||
healPaths.formUnion(window.healPaths)
|
||||
guard !window.commits.isEmpty else { return }
|
||||
|
||||
// The walk is libgit2 work and the committer reports from a synchronous outcome handler, so
|
||||
// the cache catches up on its own turn. `settled()` is how anything that must not race it
|
||||
// waits — `cross(_:)` first of all.
|
||||
refresh = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if window.isEntirelyHeal {
|
||||
// The ancestry gained a commit the pointer must be able to walk past; the pointer and
|
||||
// the redo list are untouched.
|
||||
await self.refreshAncestryKeepingPointer()
|
||||
} else {
|
||||
await self.reseed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache catch-up started by the last `noteLanded(_:)`, if it is still running.
|
||||
@ObservationIgnored
|
||||
private var refresh: Task<Void, Never>?
|
||||
|
||||
/// **Waits for the cache to have heard about the last commit** — so "⌘Z now crosses what just
|
||||
/// landed" is a fact to await rather than a race.
|
||||
///
|
||||
/// Every crossing awaits it, which is the production caller; a test awaits it to assert on
|
||||
/// enablement the instant a flush returns, where a menu would simply be validated a turn later.
|
||||
public func settled() async {
|
||||
await refresh?.value
|
||||
refresh = nil
|
||||
}
|
||||
|
||||
/// Re-reads the ancestry without disturbing the pointer or the redo list — the heal window's
|
||||
/// path, and the one every successful restore takes.
|
||||
private func refreshAncestryKeepingPointer() async {
|
||||
let root = boardRoot
|
||||
let records = await Task.detached(priority: .userInitiated) {
|
||||
GitHistoryWalk.ancestry(at: root)
|
||||
}.value
|
||||
ancestry = records
|
||||
knownHead = records.first?.oid
|
||||
if let pointerOID, !records.contains(where: { $0.oid == pointerOID }) {
|
||||
// The pointer's commit is no longer in HEAD's first-parent ancestry — a rebase remapped
|
||||
// it (07-sync-collab.md's pull). The honest answer is the seed's: start again from the
|
||||
// top, redo empty.
|
||||
adopt(records)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HistoryProviding
|
||||
|
||||
/// **Deliberately nothing — except the one thing a dropped step is owed.** On a git board an undo
|
||||
/// step is a commit, and the Writer boundary's inverse operations are the *gitless* board's
|
||||
/// substrate (13-native-undo.md — the tier axis it once read as went with 12-editions.md
|
||||
/// ▸ PIVOT 2026-08-07; the substrate is the board's mode alone). `BoardStore` registers against
|
||||
/// whatever provider the session bound, and
|
||||
/// this one has a repository to read instead — so the registrations arrive and are dropped, which
|
||||
/// is exactly what "the commit trail itself is the substrate" (14 ▸ C1) means in code.
|
||||
///
|
||||
/// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the
|
||||
/// substrate split in 13's purge rule structural rather than conditional: "on Pro the substrate is
|
||||
/// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge
|
||||
/// rides the close flush there as before" (13 ▸ Interaction with the trash). A card window's close
|
||||
/// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs
|
||||
/// immediately — at the close flush, exactly where it ran before this milestone — with no call
|
||||
/// site anywhere asking which substrate it is talking to.
|
||||
public func register(_ step: HistoryStep) {
|
||||
step.retirement?.run()
|
||||
}
|
||||
|
||||
public var canUndo: Bool {
|
||||
guard !isCrossing, isHeld?() != true else { return false }
|
||||
return crossableIndex() != nil
|
||||
}
|
||||
|
||||
public var canRedo: Bool {
|
||||
guard !isCrossing, isHeld?() != true else { return false }
|
||||
return !redoCommits.isEmpty
|
||||
}
|
||||
|
||||
/// **The crossed commit's own subject** (06 ▸ Commit messages: "the undo-menu labels are the
|
||||
/// *crossed* commit's subject, so labels never nest") — so the Edit menu reads "Undo Move card
|
||||
/// 'Fix login' to Doing", never "Undo Undo: …" for a restore this session made.
|
||||
public var undoActionName: String? {
|
||||
guard canUndo, let index = crossableIndex() else { return nil }
|
||||
return ancestry[index].subject
|
||||
}
|
||||
|
||||
public var redoActionName: String? {
|
||||
guard canRedo else { return nil }
|
||||
return redoCommits.last?.subject
|
||||
}
|
||||
|
||||
public func undo() {
|
||||
Task { await cross(.undo) }
|
||||
}
|
||||
|
||||
public func redo() {
|
||||
Task { await cross(.redo) }
|
||||
}
|
||||
|
||||
/// Drops the cache. The session's teardown, and nothing else — the *repository* is untouched, so
|
||||
/// a board reopened a second later has exactly the same trail.
|
||||
public func clear() {
|
||||
ancestry = []
|
||||
pointerOID = nil
|
||||
redoCommits = []
|
||||
knownHead = nil
|
||||
healOIDs = []
|
||||
healPaths = []
|
||||
}
|
||||
|
||||
// MARK: - The crossing
|
||||
|
||||
/// One ⌘Z or ⇧⌘Z, awaitable — the whole restore, in the order the rules fix it.
|
||||
public func cross(_ direction: HistoryDirection) async {
|
||||
guard !isCrossing, isHeld?() != true else { return }
|
||||
isCrossing = true
|
||||
defer { isCrossing = false }
|
||||
|
||||
// **The settled tree first** (06 ▸ Rules ▸ Flush-before-overwrite, and this card's own rule):
|
||||
// whatever the debounce is still holding becomes a commit of its own before a restore lands
|
||||
// on top of it, so both states exist in the trail.
|
||||
await flushPendingCommit?()
|
||||
await settled()
|
||||
await syncToHEAD()
|
||||
|
||||
switch direction {
|
||||
case .undo:
|
||||
guard let index = crossableIndex() else { return }
|
||||
let crossed = ancestry[index]
|
||||
guard let target = crossed.parentOID else { return }
|
||||
let landed = await restore(
|
||||
.undo,
|
||||
to: target,
|
||||
message: Self.restoreSubject(.undo, crossing: crossed.subject)
|
||||
)
|
||||
guard landed else { return }
|
||||
redoCommits.append(crossed)
|
||||
pointerOID = target
|
||||
await refreshAncestryKeepingPointer()
|
||||
|
||||
case .redo:
|
||||
guard let target = redoCommits.last else { return }
|
||||
let landed = await restore(
|
||||
.redo,
|
||||
to: target.oid,
|
||||
message: Self.restoreSubject(.redo, crossing: target.subject)
|
||||
)
|
||||
guard landed else { return }
|
||||
redoCommits.removeLast()
|
||||
// The commit just restored *to* is the one the next ⌘Z crosses again — the classic dance,
|
||||
// with the pointer where the undo found it.
|
||||
pointerOID = target.oid
|
||||
await refreshAncestryKeepingPointer()
|
||||
}
|
||||
}
|
||||
|
||||
/// Materializes one target state as a new commit. Answers whether the crossing may advance.
|
||||
///
|
||||
/// `message` is both the commit's subject and the bracket's completion announcement
|
||||
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
|
||||
/// completion") — one sentence, so the trail and the speech cannot disagree about what happened.
|
||||
///
|
||||
/// `direction` is carried for one reason: a failure here is the banner's git-operation shape,
|
||||
/// and it is named by the key the user pressed rather than by the subject the restore would have
|
||||
/// carried (`reportFailure`).
|
||||
private func restore(_ direction: HistoryDirection, to target: String, message: String) async -> Bool {
|
||||
let root = boardRoot
|
||||
let excluded = healPaths
|
||||
|
||||
// The **preliminary** plan: what the restore would write, which is the only thing that can
|
||||
// say whether any open session is in its way.
|
||||
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
|
||||
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
|
||||
}).value else {
|
||||
report(direction, "this board's repository could not be read")
|
||||
return false
|
||||
}
|
||||
|
||||
var reconciling: Set<String> = []
|
||||
if !preliminary.isEmpty, let settleSessions {
|
||||
switch await settleSessions(Set(preliminary.paths)) {
|
||||
case .cancelled, .failed:
|
||||
// "Cancel keeps everything" — and a raw buffer that would not validate cancels the
|
||||
// whole restore, focused on the offender (06 ▸ Branch switching).
|
||||
return false
|
||||
case .proceed:
|
||||
reconciling = discardedFolders
|
||||
discardedFolders = []
|
||||
}
|
||||
if reconciling.isEmpty {
|
||||
// **Save All ended sessions, which commits them**: the tree moved, so the plan is
|
||||
// recomputed below against the HEAD that now exists rather than the one it was
|
||||
// drafted against.
|
||||
//
|
||||
// **Discard deliberately does not flush.** Ending a session un-stages-around its
|
||||
// folder, so a flush here would commit exactly the uncommitted saves the user just
|
||||
// asked to lose — a Discard that wrote them into history forever. Nothing is left
|
||||
// behind by skipping it: everything else pending was already flushed at the top of
|
||||
// the crossing, and the discarded folder is reconciled against the working tree by
|
||||
// the plan itself.
|
||||
await flushPendingCommit?()
|
||||
await settled()
|
||||
}
|
||||
}
|
||||
|
||||
// Bound before the closure that crosses actors reads it — the settle step is over, and what
|
||||
// it decided is a value from here on.
|
||||
let folders = reconciling
|
||||
var landed = false
|
||||
let work: @MainActor () async -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.suspendCommitting?()
|
||||
defer { self.resumeCommitting?() }
|
||||
let outcome = await Task.detached(priority: .userInitiated, operation: {
|
||||
guard let plan = GitRestoreOperation.plan(
|
||||
at: root,
|
||||
target: target,
|
||||
excluding: excluded,
|
||||
reconciling: folders
|
||||
) else {
|
||||
return GitCommitOutcome.failed(GitOperationFailure(
|
||||
operation: GitRestoreOperation.operationName,
|
||||
message: "this board's repository could not be read"
|
||||
))
|
||||
}
|
||||
return GitRestoreOperation.apply(plan, at: root, message: message)
|
||||
}).value
|
||||
|
||||
switch outcome {
|
||||
case .committed:
|
||||
self.restoreCount += 1
|
||||
landed = true
|
||||
case .nothingToCommit:
|
||||
// The step was crossed and needed no bytes — every path its diff would have written
|
||||
// was heal work, or the two states are byte-identical. The pointer still advances:
|
||||
// a step that changed nothing is still a step the user asked to walk past.
|
||||
landed = true
|
||||
case .locked:
|
||||
// "Contention outlasting the brief retry surfaces as a *waiting* state" (06); the
|
||||
// in-progress banner row is the branch card's surface. Here the honest answer is to
|
||||
// leave the stack where it is so ⌘Z can simply be pressed again.
|
||||
Self.logger.debug("restore found index.lock held — the stack is unchanged")
|
||||
case let .held(pause):
|
||||
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
|
||||
case let .failed(failure):
|
||||
self.reportFailure?(direction, failure)
|
||||
}
|
||||
}
|
||||
|
||||
if let runBracketed {
|
||||
await runBracketed(message, work)
|
||||
} else {
|
||||
await work()
|
||||
}
|
||||
return landed
|
||||
}
|
||||
|
||||
/// Folders the settle step's Discard branch left for the plan to reconcile against the working
|
||||
/// tree. Filled by the gate's wiring through `noteDiscarded(_:)`.
|
||||
private var discardedFolders: Set<String> = []
|
||||
|
||||
/// **A settle step discarded this card's session** — its folder is compared against the working
|
||||
/// tree rather than against HEAD, so the uncommitted saves the user just chose to lose are
|
||||
/// reverted by the restore itself rather than by a second pass that could disagree with it
|
||||
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
|
||||
public func noteDiscarded(cardFolderName: String) {
|
||||
discardedFolders.insert(cardFolderName)
|
||||
}
|
||||
|
||||
// MARK: - The pointer
|
||||
|
||||
/// The index in `ancestry` of the commit ⌘Z would cross, or `nil` when there is none.
|
||||
///
|
||||
/// Two commits are never steps:
|
||||
///
|
||||
/// - **Heal commits**, which the pointer passes over (06 ▸ Rules ▸ Heal commits are transparent).
|
||||
/// - **The root commit** — a judgment call, recorded. It has no parent, so "the state before it"
|
||||
/// is the empty tree: crossing it would delete every file the board has ever had, in one
|
||||
/// keystroke, on a board whose entire history is that one commit. 06 says an unborn repository's
|
||||
/// "undo trail simply starts empty"; a repository with exactly one commit is that repository one
|
||||
/// commit later, and the honest reading is that the board's existence is not a step. (Nothing is
|
||||
/// lost either way: the commit stays reachable in any git client.)
|
||||
private func crossableIndex() -> Int? {
|
||||
guard !ancestry.isEmpty else { return nil }
|
||||
let start = pointerOID.flatMap { oid in ancestry.firstIndex { $0.oid == oid } } ?? 0
|
||||
for index in start..<ancestry.count {
|
||||
let commit = ancestry[index]
|
||||
guard !healOIDs.contains(commit.oid) else { continue }
|
||||
guard commit.parentOID != nil else { return nil }
|
||||
return index
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func report(_ direction: HistoryDirection, _ message: String) {
|
||||
reportFailure?(direction, GitOperationFailure(
|
||||
operation: GitRestoreOperation.operationName,
|
||||
message: message
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - The restore subject
|
||||
|
||||
/// **What a restore commit is called** — a pure function of the crossed subject and the
|
||||
/// direction, so the rule can be read (and pinned) without a repository.
|
||||
///
|
||||
/// The base rule is 06's oldest: a crossing commits the state it restored as "Undo: ⟨subject⟩"
|
||||
/// or "Redo: ⟨subject⟩". **Subjects don't nest** (06 ▸ Commit messages, settled 2026-07-31):
|
||||
/// when the crossed subject already carries a restore prefix — the post-relaunch case, where the
|
||||
/// reseed has made old restore commits ordinary steps — the composer "emits the *inverse* label
|
||||
/// instead of stacking: crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
|
||||
/// 'Undo: S'", which "caps prefixes at one across any number of relaunches".
|
||||
///
|
||||
/// ### Why the two directions read the crossed subject differently
|
||||
///
|
||||
/// The label states what the new commit's tree *does* to the base subject S: "Undo: S" is the
|
||||
/// state where S is out, "Redo: S" the state where S is in. An undo restores the crossed
|
||||
/// commit's **parent** — the state before it — so it emits that commit's inverse; a redo
|
||||
/// restores the target commit **itself**, so it emits that commit's own reading. That is what
|
||||
/// makes 06's sentence true ("undoing the restore that undid a move *re-applies* the move") and
|
||||
/// its mirror true with it: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out,
|
||||
/// and says "Undo: S" — the truer label, rather than the "Redo: S" the ⌘Z that crossed it
|
||||
/// already used for the opposite tree.
|
||||
///
|
||||
/// ### The legacy double prefix
|
||||
///
|
||||
/// "Undo: Undo: S" exists in the wild — the shipped nesting build made them — and the honest
|
||||
/// reading is this same one applied twice: the inner "Undo:" took S out, the outer one took
|
||||
/// *that* back, so the commit's tree is the one where S is in. Undoing across it therefore emits
|
||||
/// **"Undo: S"** — the tree it restores is the one without S, and saying "Redo: S" there would be
|
||||
/// exactly the euphemism 06 rules out ("This is the truer label, not a euphemism"), while
|
||||
/// "Redo: Undo: S" would keep the nesting the ruling caps at one. So each "Undo: " prefix flips
|
||||
/// the reading, each "Redo: " prefix leaves it, and what comes out carries exactly one.
|
||||
///
|
||||
/// The sniff is on the subject string, deliberately (06), so "a foreign commit that happens to
|
||||
/// open with a prefix gets the inverse label too; that's cosmetic — the restore itself is
|
||||
/// unaffected".
|
||||
public nonisolated static func restoreSubject(
|
||||
_ direction: HistoryDirection,
|
||||
crossing subject: String
|
||||
) -> String {
|
||||
let reading = RestoreSubjectReading(of: subject)
|
||||
let emitted = switch direction {
|
||||
case .undo: reading.polarity.inverse
|
||||
case .redo: reading.polarity
|
||||
}
|
||||
return "\(emitted.label): \(reading.base)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// What a subject says about its own base subject: is that change *in* the tree the subject
|
||||
/// describes, or has it been taken back out? Every restore label is one of these two readings, which
|
||||
/// is why the composer can invert rather than stack (`GitHistoryProvider.restoreSubject(_:crossing:)`).
|
||||
private enum RestorePolarity {
|
||||
/// The base subject's change is in the tree — every ordinary commit, and every "Redo: S".
|
||||
case applied
|
||||
/// The base subject's change has been taken back out — "Undo: S".
|
||||
case reverted
|
||||
|
||||
var inverse: RestorePolarity { self == .applied ? .reverted : .applied }
|
||||
|
||||
/// The word that states this reading in a subject.
|
||||
var label: String { self == .applied ? "Redo" : "Undo" }
|
||||
|
||||
/// The same word as a prefix — the only two this composer emits, and the only two it reads, so
|
||||
/// that reading and writing can never drift apart.
|
||||
var prefix: String { "\(label): " }
|
||||
}
|
||||
|
||||
/// One subject read as "a base subject, plus what its restore prefixes say about it".
|
||||
///
|
||||
/// Stripping is greedy because the legacy nesting build's subjects are (`restoreSubject`), and a
|
||||
/// prefix only counts while something is left for it to be *about*: a bare "Undo: " is somebody's
|
||||
/// subject, not a label with nothing after it.
|
||||
private struct RestoreSubjectReading {
|
||||
let base: String
|
||||
let polarity: RestorePolarity
|
||||
|
||||
init(of subject: String) {
|
||||
var base = subject
|
||||
var polarity = RestorePolarity.applied
|
||||
while true {
|
||||
let read: RestorePolarity
|
||||
if base.hasPrefix(RestorePolarity.reverted.prefix) {
|
||||
read = .reverted
|
||||
} else if base.hasPrefix(RestorePolarity.applied.prefix) {
|
||||
read = .applied
|
||||
} else {
|
||||
break
|
||||
}
|
||||
let rest = String(base.dropFirst(read.prefix.count))
|
||||
guard !rest.isEmpty else { break }
|
||||
base = rest
|
||||
// "Undo: " flips what the rest of the subject was saying; "Redo: " restates it.
|
||||
if read == .reverted { polarity = polarity.inverse }
|
||||
}
|
||||
self.base = base
|
||||
self.polarity = polarity
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftGitX
|
||||
|
||||
// MARK: - GitCommitRecord
|
||||
|
||||
/// **One commit, flattened to what a stack and a sidebar need.**
|
||||
///
|
||||
/// The undo stack reads `oid`, `parentOID` and `subject`; the card window's History section reads
|
||||
/// `subject`, `authorName` and `date` (05-card-window.md ▸ History: "semantic subject, relative date,
|
||||
/// author"). One value rather than two because they are the same walk read twice, and a second record
|
||||
/// type would be a second definition of what a commit is.
|
||||
public struct GitCommitRecord: Sendable, Equatable, Identifiable {
|
||||
|
||||
/// The full hex oid. `id` too — a commit is its hash, and nothing in this app ever shows two
|
||||
/// records for one commit.
|
||||
public let oid: String
|
||||
|
||||
/// The commit's first line, exactly as the message engine wrote it ("Move card 'Fix login' to
|
||||
/// Doing"). The undo menu's label and the History row's headline are both this string.
|
||||
public let subject: String
|
||||
|
||||
/// The **author**, which is where origin lives (06-history-undo.md ▸ Interaction with external
|
||||
/// writers: "Origin lives in the author field … not in message prose"). So a foreign commit's row
|
||||
/// reads `Lanework External` and a stamped agent's reads its own name, with no rendering rule of
|
||||
/// its own.
|
||||
public let authorName: String
|
||||
|
||||
/// The author's timestamp — what "2 days ago" is relative to.
|
||||
public let date: Date
|
||||
|
||||
/// The **first** parent, or `nil` for a root commit. First-parent only, because the whole stack
|
||||
/// is defined as first-parent ancestry and a merge's second parent is a different history.
|
||||
public let parentOID: String?
|
||||
|
||||
public var id: String { oid }
|
||||
|
||||
public init(oid: String, subject: String, authorName: String, date: Date, parentOID: String?) {
|
||||
self.oid = oid
|
||||
self.subject = subject
|
||||
self.authorName = authorName
|
||||
self.date = date
|
||||
self.parentOID = parentOID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GitHistoryWalk
|
||||
|
||||
/// **HEAD's first-parent ancestry, read** (06-history-undo.md ▸ Rules ▸ The stack is HEAD's
|
||||
/// first-parent ancestry, live) — the one walk both this milestone's surfaces are built on.
|
||||
///
|
||||
/// ### Why the walk is the stack
|
||||
///
|
||||
/// "The undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty … no sidecar
|
||||
/// state, nothing ever lost" (06 ▸ Rules ▸ Undo survives relaunch). There is therefore no persisted
|
||||
/// stack to read and nothing to keep in step with the repository: the repository *is* the stack, and
|
||||
/// this file is how it is spelled out. In-session and post-relaunch are one rule because they are one
|
||||
/// function.
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitRepository`'s rule, unchanged: every function is `nonisolated`, opens its own `Repository`, and
|
||||
/// confines the handle to its own synchronous scope. Callers reach these through `Task.detached`, so
|
||||
/// the main actor never blocks on libgit2 and libgit2 never sees two threads on one handle.
|
||||
enum GitHistoryWalk {
|
||||
|
||||
/// How far back a walk goes.
|
||||
///
|
||||
/// A cap rather than an unbounded walk for `GitRepository.pathFirstAppearanceRanks`' reason: a
|
||||
/// board with years of history must not spend a second answering "can I undo?". The cost of the
|
||||
/// cap is that the oldest steps of a very long trail are unreachable by ⌘Z, which is the same
|
||||
/// bound every undo stack has ever had, and the whole trail stays inspectable in any git client —
|
||||
/// the property 06 actually promises.
|
||||
static let defaultLimit = 512
|
||||
|
||||
/// HEAD's oid, or `nil` on an unborn HEAD or a repository that will not open.
|
||||
///
|
||||
/// **The pre-flight sync's whole question** (06: "The stack re-syncs its top to HEAD before every
|
||||
/// undo/redo — self-commits move HEAD outside the app's committer; the pre-flight sync is how the
|
||||
/// stack learns"). One reference read, which is why the sync can afford to run before every
|
||||
/// crossing.
|
||||
nonisolated static func headOID(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 nil }
|
||||
return commit.id.hex
|
||||
}
|
||||
|
||||
/// HEAD's first-parent ancestry, **newest first** — index 0 is HEAD.
|
||||
///
|
||||
/// An unborn HEAD answers `[]`, which is exactly "the undo trail simply starts empty" (06 ▸ Rules
|
||||
/// ▸ Abnormal repo states) with no case of its own.
|
||||
nonisolated static func ancestry(at boardRoot: URL, limit: Int = defaultLimit) -> [GitCommitRecord] {
|
||||
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 records: [GitCommitRecord] = []
|
||||
var current: Commit? = tip
|
||||
while let commit = current, records.count < max(0, limit) {
|
||||
let parent = (try? commit.parents)?.first
|
||||
records.append(record(commit, parent: parent))
|
||||
current = parent
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
/// **Every commit that touched one card's folder, newest first** — the card window's History
|
||||
/// section (05-card-window.md ▸ History).
|
||||
///
|
||||
/// ### Following the card is matching its own folder name
|
||||
///
|
||||
/// "The listing **follows the card across lane moves** (path changes; the UUID folder is the
|
||||
/// identity to track)." A card's folder *is* its identity: `‹lane-uuid›/‹card-uuid›/index.md`, so
|
||||
/// a lane move rewrites the first component and never the second. Matching on the card's own
|
||||
/// folder component therefore follows it across every move it can make — into another lane, into
|
||||
/// `.trash/`, back out again — with no rename detection to be defeated by a large diff, and no
|
||||
/// `--follow` heuristic to disagree with git's own answer. (`GitRepository.pathFirstAppearanceRanks`
|
||||
/// records the opposite trade for its own question: it does *not* follow renames, and says so.)
|
||||
///
|
||||
/// The walk is HEAD's first-parent ancestry, so the trail a card shows is the trail its board's
|
||||
/// current branch has — which is what makes a branch switch change it for free.
|
||||
nonisolated static func commitsTouching(
|
||||
folderNamed name: String,
|
||||
at boardRoot: URL,
|
||||
limit: Int = defaultLimit
|
||||
) -> [GitCommitRecord] {
|
||||
guard !name.isEmpty,
|
||||
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 records: [GitCommitRecord] = []
|
||||
var current: Commit? = tip
|
||||
var walked = 0
|
||||
while let commit = current, walked < max(0, limit) {
|
||||
walked += 1
|
||||
let parent = (try? commit.parents)?.first
|
||||
if touches(commit, folderNamed: name, parent: parent, in: repository) {
|
||||
records.append(record(commit, parent: parent))
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
/// Whether `path` lies inside a folder named `name` — component-exact, so a card whose id is a
|
||||
/// prefix of another's cannot borrow its history.
|
||||
nonisolated static func path(_ path: String, isInsideFolderNamed name: String) -> Bool {
|
||||
path.split(separator: "/").dropLast().contains { $0 == name }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func record(_ commit: Commit, parent: Commit?) -> GitCommitRecord {
|
||||
GitCommitRecord(
|
||||
oid: commit.id.hex,
|
||||
subject: commit.summary,
|
||||
authorName: commit.author.name,
|
||||
date: commit.author.date,
|
||||
parentOID: parent?.id.hex
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether one commit's diff against its first parent mentions the folder.
|
||||
///
|
||||
/// **A root commit is diffed against nothing**, so its whole tree counts as touched — the same
|
||||
/// reading `pathFirstAppearanceRanks` gives a walk's base, and the honest one: every file in a
|
||||
/// root commit arrived in it.
|
||||
private static func touches(
|
||||
_ commit: Commit,
|
||||
folderNamed name: String,
|
||||
parent: Commit?,
|
||||
in repository: Repository
|
||||
) -> Bool {
|
||||
guard parent != nil else {
|
||||
return treePaths(of: commit, in: repository).contains { path($0, isInsideFolderNamed: name) }
|
||||
}
|
||||
guard let diff = try? repository.diff(commit: commit) else { return false }
|
||||
return diff.changes.contains { delta in
|
||||
path(delta.newFile.path, isInsideFolderNamed: name)
|
||||
|| path(delta.oldFile.path, isInsideFolderNamed: name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every blob path under a commit's tree — `GitRepository.filePaths`' twin, kept here rather than
|
||||
/// shared because that one is `private` to a file with a different job.
|
||||
private static func treePaths(of commit: Commit, in repository: Repository) -> [String] {
|
||||
guard let tree = try? commit.tree else { return [] }
|
||||
var paths: [String] = []
|
||||
|
||||
func walk(_ tree: Tree, prefix: String, depth: Int) {
|
||||
guard depth < 8 else { return }
|
||||
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, depth: depth + 1)
|
||||
} else {
|
||||
paths.append(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree, prefix: "", depth: 0)
|
||||
return paths
|
||||
}
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
import Foundation
|
||||
import libgit2
|
||||
import os
|
||||
|
||||
// MARK: - Outcomes
|
||||
|
||||
/// **Why a housekeeping pass did nothing** — every one of these is a shrug, never a failure.
|
||||
///
|
||||
/// "Safe libgit2 housekeeping (repacking loose objects) may run periodically, but it rewrites
|
||||
/// nothing" (06-history-undo.md ▸ Repository hygiene). Nothing here reaches a user, nothing here is
|
||||
/// retried, and nothing here is worth a banner: maintenance that does not happen costs the board a
|
||||
/// slightly larger `.git` and nothing else, so every uncertainty resolves to *not now*.
|
||||
public enum GitHousekeepingSkip: String, Sendable, Equatable, CaseIterable {
|
||||
|
||||
/// No repository at the board root, or libgit2 could not open the one that is there.
|
||||
case noRepository
|
||||
|
||||
/// The repository is in a state the app does not write in (`GitRepositoryPause`) — a merge, a
|
||||
/// rebase, a detached HEAD. The commit engine holds for these; so does this, for the simpler
|
||||
/// reason that optional work has no business running beside somebody else's operation.
|
||||
case held
|
||||
|
||||
/// `index.lock` is held right now — another writer is mid-operation.
|
||||
case indexLocked
|
||||
|
||||
/// Every loose object libgit2 refused to read, so there was nothing to pack. A pass that inserts
|
||||
/// nothing writes no pack and deletes nothing.
|
||||
case nothingToPack
|
||||
|
||||
/// The pack could not be written, or the written pack could not be re-opened for verification.
|
||||
/// **Nothing is deleted on this path** — the loose objects stay exactly where they were.
|
||||
case packFailed
|
||||
}
|
||||
|
||||
/// What one repack actually did, in numbers a test can assert on.
|
||||
public struct GitHousekeepingRepack: Sendable, Equatable {
|
||||
|
||||
/// How many loose object files the pass found before it started.
|
||||
public let looseBefore: Int
|
||||
|
||||
/// How many of them libgit2 accepted into the packbuilder.
|
||||
public let inserted: Int
|
||||
|
||||
/// How many loose files were deleted — which is exactly how many were **proved** to be readable
|
||||
/// out of the newly written pack, one by one, before anything was removed.
|
||||
public let packedAway: Int
|
||||
|
||||
/// The pack's name (`pack-<name>.pack` / `.idx` under `.git/objects/pack/`).
|
||||
public let packName: String
|
||||
|
||||
public init(looseBefore: Int, inserted: Int, packedAway: Int, packName: String) {
|
||||
self.looseBefore = looseBefore
|
||||
self.inserted = inserted
|
||||
self.packedAway = packedAway
|
||||
self.packName = packName
|
||||
}
|
||||
}
|
||||
|
||||
/// How a housekeeping pass ended.
|
||||
public enum GitHousekeepingOutcome: Sendable, Equatable {
|
||||
case repacked(GitHousekeepingRepack)
|
||||
|
||||
/// The repository has fewer loose objects than the threshold — the ordinary answer, and the one
|
||||
/// almost every board gives almost every time it opens.
|
||||
case belowThreshold(loose: Int)
|
||||
|
||||
case skipped(GitHousekeepingSkip)
|
||||
}
|
||||
|
||||
// MARK: - GitHousekeeping
|
||||
|
||||
/// **Periodic safe housekeeping** (06-history-undo.md ▸ Repository hygiene: "The app may run safe
|
||||
/// libgit2 housekeeping (repacking loose objects) periodically — it rewrites nothing").
|
||||
///
|
||||
/// ### What it does, and the line it does not cross
|
||||
///
|
||||
/// libgit2 does no automatic maintenance of its own (14-git-operations.md ▸ A2 → 06), so a board
|
||||
/// that commits every settled change accumulates loose objects forever. This packs them: the same
|
||||
/// objects, byte for byte, moved from one storage form into another. **No commit, no ref, no
|
||||
/// reachable content changes** — the object graph after a pass is the graph before it, and `git log`,
|
||||
/// `git show` and every blob in every tree answer identically.
|
||||
///
|
||||
/// The whole class of destructive maintenance is **out**, permanently: nothing here prunes, expires a
|
||||
/// reflog, drops an unreachable object, or rewrites a commit. "Deleting never forgets" and "repo
|
||||
/// growth is accepted" are the design's stances (06), and a compaction that made a board smaller by
|
||||
/// forgetting something would contradict both. Unreachable loose objects are packed like any other —
|
||||
/// they stay readable by oid, which is what never-forget means at the object layer.
|
||||
///
|
||||
/// ### Why deleting a loose file is safe
|
||||
///
|
||||
/// Every deletion is *provably redundant* before it happens, and the proof is not a chain of
|
||||
/// reasoning about the packbuilder — it is a read:
|
||||
///
|
||||
/// 1. The loose set is enumerated from the filesystem (`.git/objects/<xx>/<38 hex>`), so the pass
|
||||
/// knows exactly which files it is considering and never touches anything else under `.git`.
|
||||
/// 2. Each oid is inserted into a `git_packbuilder`, which is then written into
|
||||
/// `.git/objects/pack/`. Writing a pack is purely **additive**: it creates two new files and
|
||||
/// changes nothing that exists.
|
||||
/// 3. The written `.idx` is re-opened as a standalone one-pack object database — no loose backend,
|
||||
/// no repository, nothing that could answer from the very files about to be deleted — and each
|
||||
/// oid is looked up in it. **A loose file is deleted only when that lookup says the object is in
|
||||
/// the new pack.** Anything the lookup does not confirm is left exactly where it is, forever.
|
||||
///
|
||||
/// A failure at any point returns without deleting anything, so the worst outcome of a broken pass
|
||||
/// is a stray pack file that costs disk and changes no answer.
|
||||
///
|
||||
/// ### What it deliberately does not do
|
||||
///
|
||||
/// **It never touches an existing pack** — not to delete one, not to consolidate several into one.
|
||||
/// A repository maintained only by this accumulates roughly one pack per threshold's worth of
|
||||
/// objects, forever, and that is the accepted cost: consolidating means rewriting storage the app did
|
||||
/// not write, on a schedule nobody asked for, with a failure mode (a half-repacked object database)
|
||||
/// far worse than the disk it would save. 06's stance is "repo growth is accepted", and `git gc` in a
|
||||
/// terminal remains exactly as available as it always was for a user who wants more than this.
|
||||
///
|
||||
/// **It never narrows to reachability.** Every loose object is packed, reachable or not: an object
|
||||
/// no ref can reach is still an object the repository can answer for by oid, and dropping those would
|
||||
/// be the app deciding what history is allowed to remember (06 ▸ Deleting never forgets).
|
||||
///
|
||||
/// ### Concurrency
|
||||
///
|
||||
/// The pass is additive-then-provably-redundant, which is what makes a concurrent commit harmless:
|
||||
/// objects a commit writes while this runs are not in the enumerated set, so they are never
|
||||
/// considered, and objects this deletes are readable from the pack the same odb refresh that misses
|
||||
/// the loose file will find. That is the same race `git repack -d` has always had, and the same
|
||||
/// resolution. The scheduler above (`GitHousekeeper`) additionally declines to start while a flush is
|
||||
/// in flight, and the pass itself declines under any pause or held lock — belt and braces over an
|
||||
/// operation that is already safe rather than the thing that makes it safe.
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitRepository`'s rule, unchanged: every function is `nonisolated`, opens its own handles, and
|
||||
/// frees them in the same synchronous scope. No handle crosses an `await`, a `Task`, or a stored
|
||||
/// property.
|
||||
enum GitHousekeeping {
|
||||
|
||||
/// **When a repository has enough loose objects to be worth packing** — git's own `gc.auto`
|
||||
/// default, 6700.
|
||||
///
|
||||
/// DESIGN names no number ("periodically" is all 06 says), so the number is borrowed from the
|
||||
/// tool whose reason for having one is identical: git picked 6700 as roughly where loose-object
|
||||
/// lookup and directory-scan costs start to matter, and a Lanework board's `.git` is an ordinary
|
||||
/// repository with ordinary objects in it. Borrowing it also means a board the user has been
|
||||
/// running `git gc` on by hand never sees a second opinion about when packing is due.
|
||||
///
|
||||
/// Injectable at every level above (`GitHousekeeper.threshold`) so a test can spend three objects
|
||||
/// instead of six thousand seven hundred.
|
||||
static let defaultLooseObjectThreshold = 6700
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// libgit2's global state, brought up exactly once per process — `GitCommitOperation.startUp`'s
|
||||
/// rule and its reason (a pass can run when no `Repository` is alive).
|
||||
private static let startUp: Bool = {
|
||||
git_libgit2_init() >= 0
|
||||
}()
|
||||
|
||||
// MARK: - The pass
|
||||
|
||||
/// **Runs one housekeeping pass**, or explains why it didn't.
|
||||
///
|
||||
/// Synchronous and expected to be called from a detached low-priority task — packing is real CPU
|
||||
/// and real IO, and it is the least urgent work the app does.
|
||||
nonisolated static func run(
|
||||
at boardRoot: URL,
|
||||
threshold: Int = defaultLooseObjectThreshold
|
||||
) -> GitHousekeepingOutcome {
|
||||
_ = startUp
|
||||
|
||||
// Two of the three facts every flush checks, asked in the same words
|
||||
// (`GitCommitOperation.reading`) so the engine's vocabulary for "not now" and this one cannot
|
||||
// drift — the third, an unborn HEAD, is nothing to this: a repository with no commits has no
|
||||
// loose objects worth packing and is below any threshold anyway. Housekeeping reads the two it
|
||||
// does take more strictly than the committer does: the committer *holds* and retries, this
|
||||
// simply does not happen this time.
|
||||
let reading = GitCommitOperation.reading(at: boardRoot)
|
||||
if reading.pause != nil { return .skipped(.held) }
|
||||
if reading.isIndexLocked { return .skipped(.indexLocked) }
|
||||
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return .skipped(.noRepository) }
|
||||
var repository: OpaquePointer?
|
||||
guard git_repository_open(&repository, boardRoot.path) == 0, let repository else {
|
||||
return .skipped(.noRepository)
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
let objectsDirectory = objectsDirectory(of: repository)
|
||||
let loose = looseObjects(in: objectsDirectory)
|
||||
guard loose.count >= threshold else { return .belowThreshold(loose: loose.count) }
|
||||
|
||||
return repack(loose, in: repository, objectsDirectory: objectsDirectory)
|
||||
}
|
||||
|
||||
/// **How many loose objects the repository has right now** — the gate's own reading, exposed
|
||||
/// because it is also the only honest way to assert that a pass reduced the count.
|
||||
///
|
||||
/// `0` for a board with no repository, which is the same shrug every read in `GitRepository`
|
||||
/// gives one.
|
||||
nonisolated static func looseObjectCount(at boardRoot: URL) -> Int {
|
||||
_ = startUp
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return 0 }
|
||||
var repository: OpaquePointer?
|
||||
guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return 0 }
|
||||
defer { git_repository_free(repository) }
|
||||
return looseObjects(in: objectsDirectory(of: repository)).count
|
||||
}
|
||||
|
||||
// MARK: - Repacking
|
||||
|
||||
private static func repack(
|
||||
_ loose: [LooseObject],
|
||||
in repository: OpaquePointer,
|
||||
objectsDirectory: URL
|
||||
) -> GitHousekeepingOutcome {
|
||||
var builder: OpaquePointer?
|
||||
guard git_packbuilder_new(&builder, repository) == 0, let builder else {
|
||||
return .skipped(.packFailed)
|
||||
}
|
||||
defer { git_packbuilder_free(builder) }
|
||||
|
||||
// Inserted one oid at a time — never `insert_recur`, never `insert_walk`. The set that goes
|
||||
// into the pack is exactly the set enumerated off disk, so "packed" and "considered for
|
||||
// deletion" are the same list by construction, and reachability never enters into it.
|
||||
var inserted: [LooseObject] = []
|
||||
for object in loose {
|
||||
var oid = git_oid()
|
||||
guard git_oid_fromstr(&oid, object.hex) == 0 else { continue }
|
||||
// A loose object libgit2 cannot read (a truncated write, a corrupt file) is skipped
|
||||
// rather than fatal — and, never having entered the pack, is never a deletion candidate.
|
||||
guard git_packbuilder_insert(builder, &oid, nil) == 0 else { continue }
|
||||
inserted.append(object)
|
||||
}
|
||||
guard !inserted.isEmpty else { return .skipped(.nothingToPack) }
|
||||
|
||||
// `nil` for the path: libgit2 resolves the repository's own objects/pack directory, which is
|
||||
// one fewer assumption than spelling it here. The name comes back afterwards, and the `.idx`
|
||||
// beside it is what the verification reads.
|
||||
guard git_packbuilder_write(builder, nil, 0, nil, nil) == 0,
|
||||
let namePointer = git_packbuilder_name(builder) else {
|
||||
return .skipped(.packFailed)
|
||||
}
|
||||
let packName = String(cString: namePointer)
|
||||
|
||||
let indexFile = objectsDirectory
|
||||
.appendingPathComponent("pack", isDirectory: true)
|
||||
.appendingPathComponent("pack-\(packName).idx")
|
||||
guard FileManager.default.fileExists(atPath: indexFile.path) else {
|
||||
// libgit2 said it wrote the pack and the index is not where its own naming says it is.
|
||||
// Nothing is deleted on a fact that surprising.
|
||||
return .skipped(.packFailed)
|
||||
}
|
||||
|
||||
guard let verifier = OnePackDatabase(indexFile: indexFile) else { return .skipped(.packFailed) }
|
||||
defer { verifier.close() }
|
||||
|
||||
var packedAway = 0
|
||||
for object in inserted {
|
||||
// **The proof, read rather than reasoned**: the object is in the pack file just written,
|
||||
// answered by a database that has nothing else in it — no loose backend, no repository,
|
||||
// no alternates. A `false` here (or an oid that will not even parse) leaves the loose
|
||||
// file alone, permanently.
|
||||
guard verifier.contains(object.hex) else { continue }
|
||||
guard (try? FileManager.default.removeItem(at: object.url)) != nil else { continue }
|
||||
packedAway += 1
|
||||
}
|
||||
|
||||
logger.debug("housekeeping packed \(packedAway, privacy: .public) of \(loose.count, privacy: .public) loose objects")
|
||||
return .repacked(GitHousekeepingRepack(
|
||||
looseBefore: loose.count,
|
||||
inserted: inserted.count,
|
||||
packedAway: packedAway,
|
||||
packName: packName
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - The loose set
|
||||
|
||||
/// One loose object: its full hex oid, and the file it lives in.
|
||||
private struct LooseObject {
|
||||
let hex: String
|
||||
let url: URL
|
||||
}
|
||||
|
||||
/// **Every loose object file under `objects/`**, found by reading the fanout directories.
|
||||
///
|
||||
/// ### Why the filesystem rather than `git_odb_foreach`
|
||||
///
|
||||
/// Because `git_odb_foreach` enumerates the *whole* database — packed objects included — and a
|
||||
/// pass that fed already-packed objects back into a new pack would rewrite the entire repository
|
||||
/// into a fresh pack on every run while leaving the old ones in place (nothing here deletes a
|
||||
/// pack, ever). Growth, not hygiene. The loose set is a directory listing by definition, and
|
||||
/// reading it directly is both the exact answer and the cheap one — 256 `readdir`s at background
|
||||
/// priority — and it yields the *file* to delete, which an oid alone does not.
|
||||
///
|
||||
/// **Strictly shaped, so nothing else can be caught by it**: a two-hex-character directory
|
||||
/// containing thirty-eight-hex-character names. `objects/info`, `objects/pack`, an indexer's
|
||||
/// temp file, an alternates file and anything a user has parked down there all fail the shape and
|
||||
/// are invisible to this. (Thirty-eight is SHA-1's remainder; a SHA-256 repository would simply
|
||||
/// present no loose objects to this pass, which is the safe way for it to be wrong.)
|
||||
private static func looseObjects(in objectsDirectory: URL) -> [LooseObject] {
|
||||
let manager = FileManager.default
|
||||
guard let fanouts = try? manager.contentsOfDirectory(atPath: objectsDirectory.path) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var found: [LooseObject] = []
|
||||
for fanout in fanouts where isHex(fanout, count: 2) {
|
||||
let directory = objectsDirectory.appendingPathComponent(fanout, isDirectory: true)
|
||||
guard let names = try? manager.contentsOfDirectory(atPath: directory.path) else { continue }
|
||||
for name in names where isHex(name, count: 38) {
|
||||
found.append(LooseObject(
|
||||
hex: fanout + name,
|
||||
url: directory.appendingPathComponent(name)
|
||||
))
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private static func isHex(_ string: String, count: Int) -> Bool {
|
||||
guard string.count == count else { return false }
|
||||
return string.allSatisfy { $0.isHexDigit && !$0.isUppercase }
|
||||
}
|
||||
|
||||
private static func objectsDirectory(of repository: OpaquePointer) -> URL {
|
||||
let gitDirectory = URL(
|
||||
fileURLWithPath: git_repository_path(repository).map { String(cString: $0) } ?? "",
|
||||
isDirectory: true
|
||||
)
|
||||
return gitDirectory.appendingPathComponent("objects", isDirectory: true)
|
||||
}
|
||||
|
||||
// MARK: - The verifier
|
||||
|
||||
/// **A database containing exactly one pack file and nothing else** — the deletion proof.
|
||||
///
|
||||
/// It is deliberately not the repository's odb: that one answers from the loose objects too, so
|
||||
/// "the object exists" would be true of every candidate whether or not the pack ever received
|
||||
/// it. With one backend and no alternates, a positive answer can only have come from the pack
|
||||
/// that was just written.
|
||||
private struct OnePackDatabase {
|
||||
private let database: OpaquePointer
|
||||
|
||||
init?(indexFile: URL) {
|
||||
var database: OpaquePointer?
|
||||
guard git_odb_new(&database) == 0, let database else { return nil }
|
||||
|
||||
var backend: UnsafeMutablePointer<git_odb_backend>?
|
||||
guard git_odb_backend_one_pack(&backend, indexFile.path) == 0, let backend else {
|
||||
git_odb_free(database)
|
||||
return nil
|
||||
}
|
||||
// The odb takes ownership on success and frees the backend with itself; on failure it
|
||||
// does not, and the backend's own `free` is the only way to give it back.
|
||||
guard git_odb_add_backend(database, backend, 1) == 0 else {
|
||||
backend.pointee.free?(backend)
|
||||
git_odb_free(database)
|
||||
return nil
|
||||
}
|
||||
self.database = database
|
||||
}
|
||||
|
||||
func contains(_ hex: String) -> Bool {
|
||||
var oid = git_oid()
|
||||
guard git_oid_fromstr(&oid, hex) == 0 else { return false }
|
||||
return git_odb_exists(database, &oid) == 1
|
||||
}
|
||||
|
||||
func close() {
|
||||
git_odb_free(database)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GitHousekeeper
|
||||
|
||||
/// **When a housekeeping pass runs** (06-history-undo.md ▸ Repository hygiene) — one per git-mode
|
||||
/// board session, scheduled at board open and never again.
|
||||
///
|
||||
/// ### Structurally unreachable on a board with no repository
|
||||
///
|
||||
/// One of these exists per `HistoryStore` in mode `git` and nowhere else — `GitAutoCommitter`'s
|
||||
/// rule, for its reason, including the tier gate that used to sit above it and no longer does
|
||||
/// (12-editions.md ▸ PIVOT 2026-08-07). A board the user never added git to detects `none`, so there
|
||||
/// is no housekeeper to disable and no `.git` to pack.
|
||||
///
|
||||
/// ### Off the open path, on purpose
|
||||
///
|
||||
/// Board open is where 02-architecture.md's hang-avoidance doctrine is strictest, and packing is the
|
||||
/// single most expensive thing the git layer can do. So the pass is armed with a delay rather than
|
||||
/// run, the delay outlasts the committer's launch catch-up (`GitAutoCommitter.debounceInterval`), the
|
||||
/// work itself runs `Task.detached(priority: .background)`, and the main actor only ever holds the
|
||||
/// verdict.
|
||||
///
|
||||
/// ### Once, and never retried
|
||||
///
|
||||
/// A pass that declines — a commit in flight, a paused repository, a held lock — is simply not run;
|
||||
/// nothing re-arms and nothing is surfaced. Loose objects only accumulate, so the next board open
|
||||
/// finds a threshold that is still crossed and tries again then. That is the whole retry policy, and
|
||||
/// it is the right one for work whose failure costs the user nothing.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class GitHousekeeper {
|
||||
|
||||
/// The board whose repository this maintains.
|
||||
public let boardRoot: URL
|
||||
|
||||
/// How many loose objects it takes to be worth a pass. See
|
||||
/// `GitHousekeeping.defaultLooseObjectThreshold`; settable so a test need not make 6700 objects.
|
||||
@ObservationIgnored
|
||||
public var threshold = GitHousekeeping.defaultLooseObjectThreshold
|
||||
|
||||
/// How long after board open the pass is attempted.
|
||||
///
|
||||
/// Comfortably past `GitAutoCommitter.debounceInterval` (two seconds), so the launch catch-up
|
||||
/// commit has come and gone before maintenance considers starting — the cheapest possible way to
|
||||
/// keep the two out of each other's way, and settable for the reason every other interval in this
|
||||
/// layer is: a test must not have to spend it.
|
||||
@ObservationIgnored
|
||||
public var delay: Duration = .seconds(8)
|
||||
|
||||
/// **Whether a commit is in flight right now** — asked on the main actor at the moment of
|
||||
/// dispatch, and answered by the board's own committer (`HistoryStore.activateAutoCommit` wires
|
||||
/// it).
|
||||
///
|
||||
/// `nil` where no committer exists, which reads as "no", and is the honest answer for a
|
||||
/// housekeeper with no engine beside it.
|
||||
@ObservationIgnored
|
||||
public var isCommitInFlight: (@MainActor () -> Bool)?
|
||||
|
||||
/// How the last pass ended, or `nil` if none has run. Observable state for tests and for nothing
|
||||
/// else — housekeeping has no surface, by design.
|
||||
public private(set) var lastOutcome: GitHousekeepingOutcome?
|
||||
|
||||
@ObservationIgnored
|
||||
private var pending: Task<Void, Never>?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
init(boardRoot: URL) {
|
||||
self.boardRoot = boardRoot
|
||||
}
|
||||
|
||||
/// **Arms the one pass this session gets.** Idempotent: a second call while one is armed does
|
||||
/// nothing, so a board opened twice into the same store does not queue two.
|
||||
public func schedule() {
|
||||
guard pending == nil else { return }
|
||||
let delay = delay
|
||||
pending = Task { [weak self] in
|
||||
try? await Task.sleep(for: delay)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.pending = nil
|
||||
await self.runNow()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels an armed pass — the session's teardown, so a closed board's maintenance cannot fire
|
||||
/// against a store that has gone.
|
||||
public func cancel() {
|
||||
pending?.cancel()
|
||||
pending = nil
|
||||
}
|
||||
|
||||
/// Runs a pass immediately, off the main actor. The scheduled body, and a test's way in.
|
||||
public func runNow() async {
|
||||
// **Never beside a commit.** The pass is safe next to one — it only ever adds a pack and
|
||||
// deletes files it has proved redundant — but "safe" is not "worth it", and optional work
|
||||
// that waits for the next board open costs nothing.
|
||||
if isCommitInFlight?() == true {
|
||||
Self.logger.debug("housekeeping skipped: a commit is in flight")
|
||||
return
|
||||
}
|
||||
let root = boardRoot
|
||||
let threshold = threshold
|
||||
lastOutcome = await Task.detached(priority: .background) {
|
||||
GitHousekeeping.run(at: root, threshold: threshold)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
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
|
||||
/// identity fields write exactly that file: "the setting *is* the file, portable to any git
|
||||
/// client, per-board by nature". Their home is the **board popover's Git tab** (03-board-ui.md):
|
||||
/// the popover's git section originally, the board settings sheet between the 2026-07-31
|
||||
/// popover/sheet split and the 2026-08-07 reversal that retired it, and the Git tab since — none
|
||||
/// of which changes anything about this file.
|
||||
/// 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.
|
||||
///
|
||||
/// Shared with `CommitAttribution.agentIdentity(named:)` — a `modified-by` stamp is arbitrary
|
||||
/// self-reported text and needs exactly this treatment to become an address local part
|
||||
/// ("display name verbatim, email local part slugified", 06-history-undo.md). One slug rule for
|
||||
/// both, so a name that is safe in a derived default cannot be unsafe in an agent's address.
|
||||
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.
|
||||
///
|
||||
/// **Reads take the last plain-section value** (06-history-undo.md ▸ Interaction with external
|
||||
/// writers, blessed 2026-07-31): "the reader — like git itself — takes the last plain-section
|
||||
/// value, which is exactly what an append produces."
|
||||
///
|
||||
/// *Plain* is load-bearing and is the whole of the subsection rule. `[user "work"]` is a different
|
||||
/// key in git's own model — `user.work.name`, not `user.name` — so its values are not answers to
|
||||
/// this question at all, and reading one would sign the user's commits with an identity they
|
||||
/// filed under a name this app never asked about. Last-wins still holds inside the plain
|
||||
/// sections: a later `[user]` overrides an earlier one, which is how an appended section wins
|
||||
/// without the writer ever touching what came before it.
|
||||
static func identity(inConfigText text: String) -> (name: String?, email: String?) {
|
||||
var isPlainUserSection = false
|
||||
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("[") {
|
||||
let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
|
||||
let section = header
|
||||
.split(separator: " ", maxSplits: 1)
|
||||
.first
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
isPlainUserSection = section == "user" && !header.contains("\"")
|
||||
continue
|
||||
}
|
||||
|
||||
guard isPlainUserSection, 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)
|
||||
}
|
||||
|
||||
// MARK: Writing
|
||||
|
||||
/// **The identity fields, landing in the file** (06-history-undo.md ▸ Interaction with external
|
||||
/// writers: "the identity section … exposes name/email fields that **write that repo-local
|
||||
/// config** — the setting *is* the file, portable to any git client, per-board by nature"; the
|
||||
/// fields are popover-hosted, as they were before the 2026-07-31 split and are again since the
|
||||
/// 2026-08-07 reversal).
|
||||
///
|
||||
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
|
||||
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
|
||||
/// never written into repo config", because a value the app wrote there would outrank the user's
|
||||
/// own global `~/.gitconfig` for their terminal commits in that board. What lands here is what the
|
||||
/// user typed and nothing else.
|
||||
///
|
||||
/// Skips the disk write entirely when `applying` reports no change: the settings sheet re-reads
|
||||
/// this file every 2 s while it is visible (06's visibility-scoped poll), and a write that would
|
||||
/// not change a byte must not churn the mtime that poll is watching.
|
||||
static func writeIdentity(
|
||||
name: String?,
|
||||
email: String?,
|
||||
inGitDirectory gitDirectory: URL
|
||||
) throws {
|
||||
let configURL = gitDirectory.appendingPathComponent("config")
|
||||
let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
|
||||
let updated = applying(name: name, email: email, to: existing)
|
||||
guard updated != existing else { return }
|
||||
try Data(updated.utf8).write(to: configURL, options: .atomic)
|
||||
}
|
||||
|
||||
/// The edit, over text — the pure half, which is where every rule below is decided and the only
|
||||
/// half a test needs.
|
||||
///
|
||||
/// **"Writes append, reads take the last"** (06-history-undo.md ▸ Interaction with external
|
||||
/// writers, blessed 2026-07-31): a *set* never edits or deletes an existing line. It appends one
|
||||
/// new plain `[user]` section at the very end of the file — `name` before `email` — even when
|
||||
/// plain `[user]` sections already exist; last-wins reading is exactly what makes the appended
|
||||
/// value win, "without the writer ever reformatting what it didn't create". "A write whose keys
|
||||
/// already read back at their target values is skipped whole, so revisiting the sheet never grows
|
||||
/// the file": resolution runs first, through the same `identity(inConfigText:)` this file's reads
|
||||
/// use, and a key set to its already-current value — or cleared when already absent — drops out
|
||||
/// of the pending work before anything is touched. If nothing remains pending, the input comes
|
||||
/// back byte-identical.
|
||||
///
|
||||
/// **"Clearing a key is the one sanctioned in-place edit"** (ruled 2026-08-06): "the config
|
||||
/// format spells absence one way only — the key not being there", so a clear deletes every
|
||||
/// plain-section line for that key, in every plain `[user]` section — "deleting fewer than all of
|
||||
/// them changes nothing under last-wins" — and then drops any plain `[user]` header left with no
|
||||
/// real key lines under it (only blanks/comments); a section that keeps another key (`signingkey`,
|
||||
/// say) keeps its header. **"Subsections stay untouchable in both directions"**: `[user "work"]`
|
||||
/// is never edited by a set or a clear, whatever its keys. A combined set-and-clear call does the
|
||||
/// clears in place, then appends the set section.
|
||||
static func applying(name: String?, email: String?, to text: String) -> String {
|
||||
func cleaned(_ value: String?) -> String? {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty else { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// No-op skip: resolve what the file currently says (last-wins, same reader the rest of the
|
||||
// app uses) and drop any key whose target already matches — set-to-current and
|
||||
// clear-when-absent both mean "nothing to do" for that key.
|
||||
let current = identity(inConfigText: text)
|
||||
let targets: [(key: String, target: String?, current: String?)] = [
|
||||
("name", cleaned(name), current.name),
|
||||
("email", cleaned(email), current.email)
|
||||
]
|
||||
// `nil` is "clear this key"; a key with no pending work is simply absent from the dictionary.
|
||||
var pending: [String: String?] = [:]
|
||||
for entry in targets where entry.target != entry.current {
|
||||
pending[entry.key] = entry.target
|
||||
}
|
||||
guard !pending.isEmpty else { return text }
|
||||
|
||||
// Split on `\n` and rejoin, so the file's own trailing-newline shape survives the round trip
|
||||
// (`components(separatedBy:)` renders a trailing newline as a final empty element).
|
||||
var lines = text.isEmpty ? [] : text.components(separatedBy: "\n")
|
||||
|
||||
let clearedKeys = Set(pending.compactMap { key, value in value == nil ? key : nil })
|
||||
if !clearedKeys.isEmpty {
|
||||
lines = removingKeys(clearedKeys, fromPlainUserSectionsIn: lines)
|
||||
lines = removingEmptyPlainUserSections(from: lines)
|
||||
}
|
||||
|
||||
// Name before email, always — a file this app wrote reads the same whichever field was
|
||||
// filled first.
|
||||
let additions = ["name", "email"].compactMap { key -> String? in
|
||||
guard let value = pending[key] ?? nil else { return nil }
|
||||
return "\t\(key) = \(value)"
|
||||
}
|
||||
guard !additions.isEmpty else { return lines.joined(separator: "\n") }
|
||||
|
||||
if let last = lines.last, !last.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
lines.append("")
|
||||
}
|
||||
lines.append("[user]")
|
||||
lines.append(contentsOf: additions)
|
||||
lines.append("")
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Whether a trimmed line opens the **plain** `[user]` section — the load-bearing distinction
|
||||
/// throughout this file. A subsectioned `[user "work"]` is a different key in git's own model
|
||||
/// (`user.work.name`, not `user.name`), so it must never match here: matching it would let a set
|
||||
/// or a clear reach into a scope the user filed under a name this app never asked about.
|
||||
private static func isPlainUserHeader(_ trimmedLine: String) -> Bool {
|
||||
guard trimmedLine.hasPrefix("[") else { return false }
|
||||
let header = trimmedLine.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
|
||||
let section = header
|
||||
.split(separator: " ", maxSplits: 1)
|
||||
.first
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
return section == "user" && !header.contains("\"")
|
||||
}
|
||||
|
||||
/// The clear's in-place edit: deletes every line, in every plain `[user]` section, whose key
|
||||
/// (trimmed, lowercased, before `=`) is in `keys`. Deleting fewer than all of them would change
|
||||
/// nothing under last-wins reading, so this walks the whole file rather than stopping at the
|
||||
/// first match. Lines outside a plain `[user]` section — including everything inside a `[user
|
||||
/// "…"]` subsection — are never inspected for deletion.
|
||||
private static func removingKeys(_ keys: Set<String>, fromPlainUserSectionsIn lines: [String]) -> [String] {
|
||||
var result: [String] = []
|
||||
var isPlainUserSection = false
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("[") {
|
||||
isPlainUserSection = isPlainUserHeader(trimmed)
|
||||
result.append(line)
|
||||
continue
|
||||
}
|
||||
if isPlainUserSection, let separator = trimmed.firstIndex(of: "=") {
|
||||
let key = trimmed[trimmed.startIndex..<separator]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.lowercased()
|
||||
if keys.contains(key) { continue }
|
||||
}
|
||||
result.append(line)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Drops every plain `[user]` header left with no real key lines under it (only blanks/comments)
|
||||
/// — what a clear leaves behind, and what a config the user never touched does not have. Walks
|
||||
/// the whole file rather than the first section alone: a clear can empty out more than one plain
|
||||
/// `[user]` section in the same call, and a section that still carries another key (`signingkey`,
|
||||
/// say) keeps its header.
|
||||
private static func removingEmptyPlainUserSections(from lines: [String]) -> [String] {
|
||||
var result: [String] = []
|
||||
var index = 0
|
||||
while index < lines.count {
|
||||
guard isPlainUserHeader(lines[index].trimmingCharacters(in: .whitespaces)) else {
|
||||
result.append(lines[index])
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
var end = index + 1
|
||||
var hasRealKey = false
|
||||
while end < lines.count {
|
||||
let trimmed = lines[end].trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("[") { break }
|
||||
if !trimmed.isEmpty, !trimmed.hasPrefix("#"), !trimmed.hasPrefix(";") { hasRealKey = true }
|
||||
end += 1
|
||||
}
|
||||
if hasRealKey { result.append(contentsOf: lines[index..<end]) }
|
||||
index = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - GitOperationStamp
|
||||
|
||||
/// **The app's declaration that it is about to touch the repository** (06-history-undo.md ▸ Rules
|
||||
/// ▸ Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board registry)
|
||||
/// before touching the repo, so an interrupted app-run rebase or checkout is recognizable as
|
||||
/// Lanework's").
|
||||
///
|
||||
/// ### Why it exists at all
|
||||
///
|
||||
/// The app's standing posture toward a repository it finds in a pause state is to **hold and defer**:
|
||||
/// name the state, disable the controls, and let the tool that created it finish. That posture is
|
||||
/// correct for every leftover except one — the app's own. A checkout interrupted by a crash (or by a
|
||||
/// volume vanishing mid-flight — 02-architecture.md ▸ the root-change composition) leaves a repository
|
||||
/// whose state nobody in a terminal is going to finish, and deferring to a rebase that does not exist
|
||||
/// would leave the board's git surface paused forever.
|
||||
///
|
||||
/// So the app writes down what it is about to do, **before** it does it. Finding a pause state on a
|
||||
/// later open *with* a matching stamp, it aborts its own unfinished work and says so; finding one
|
||||
/// without, the pause-and-defer stance is unchanged. The stamp is the only thing that distinguishes
|
||||
/// the two, which is why it is written before the first byte and cleared after the last.
|
||||
///
|
||||
/// ### Where it lives, and why not in the repository
|
||||
///
|
||||
/// The **per-board registry** — `BoardRecord.gitOperationStamp`, in the app's own Application Support
|
||||
/// home. Two rules pin it there. Files-first is absolute (02 ▸ Per-board app state): "no frontmatter
|
||||
/// key, no sidecar, no xattr" — a marker file in the board folder would be board content, committed by
|
||||
/// the very operation it describes. And the never-mutate rule forbids the obvious git-shaped home: a
|
||||
/// file under `.git/` would be app-written repo state, which is exactly what this mechanism exists to
|
||||
/// keep the app out of.
|
||||
///
|
||||
/// A consequence worth stating: the stamp is **per machine**, like every other registry record. A
|
||||
/// board whose switch was interrupted on one Mac and then opened on another reads as an ordinary
|
||||
/// unexplained pause — hold, name it, defer — which is the honest answer, since the second machine
|
||||
/// genuinely does not know whose leftover it is.
|
||||
public struct GitOperationStamp: Codable, Sendable, Equatable {
|
||||
|
||||
/// Which bracketed operation this stamp is for.
|
||||
///
|
||||
/// One case today. It is an enum rather than a bare marker because 06 names the mechanism for
|
||||
/// "every bracketed operation" and the pull's rebase (07-sync-collab.md) is the next one to stamp;
|
||||
/// a new case then needs no migration, because an unknown-to-old-builds case never appears in a
|
||||
/// file an old build wrote.
|
||||
public enum Kind: String, Codable, Sendable, CaseIterable {
|
||||
case branchSwitch
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
|
||||
/// The branch HEAD named **before** the operation — where an abort returns to. Empty when the
|
||||
/// repository had no branch to name (a detached HEAD the switch was starting from, which the
|
||||
/// paused-surface rule makes unreachable today).
|
||||
public let fromBranch: String
|
||||
|
||||
/// The branch the operation was heading for. Not used by the abort — recorded because a recovery
|
||||
/// that could not say what was interrupted would be a worse diagnostic than one that can.
|
||||
public let toBranch: String
|
||||
|
||||
/// HEAD's commit before the operation, or `nil` on an unborn HEAD. Recorded for the same reason:
|
||||
/// it is the fact a support question ("what was it doing?") is answered with.
|
||||
public let headOID: String?
|
||||
|
||||
public init(kind: Kind = .branchSwitch, fromBranch: String, toBranch: String, headOID: String?) {
|
||||
self.kind = kind
|
||||
self.fromBranch = fromBranch
|
||||
self.toBranch = toBranch
|
||||
self.headOID = headOID
|
||||
}
|
||||
|
||||
/// **What the banner says after a successful abort** — 06's own sentence, with the app's
|
||||
/// sentence-shaped capitalization.
|
||||
public static let interruptionMessage =
|
||||
"A branch switch was interrupted — the previous state is restored."
|
||||
}
|
||||
|
||||
// MARK: - Recovery
|
||||
|
||||
/// **What to do about a stamp found at open** — a pure decision, so the mechanism's whole rule is
|
||||
/// provable without a repository in a broken state.
|
||||
public enum GitOperationRecovery: Sendable, Equatable {
|
||||
|
||||
/// No stamp: the ordinary case, and the one every board is in. The pause-and-defer stance applies
|
||||
/// unchanged to whatever state the repository happens to be in.
|
||||
case nothingToDo
|
||||
|
||||
/// A stamp, but a repository in a state the app writes in perfectly well. The operation finished
|
||||
/// and the clear did not land — a quit between the two, or a registry write that lost a race — so
|
||||
/// there is nothing to abort and the stamp is stale. Dropping it silently is right: nothing
|
||||
/// happened that the user needs told about.
|
||||
case clearStamp
|
||||
|
||||
/// A stamp **and** a pause state: the app's own unfinished operation. Abort it, restore the
|
||||
/// pre-operation state, say so, then clear.
|
||||
case abort(GitOperationStamp)
|
||||
|
||||
/// The whole rule, in one function.
|
||||
///
|
||||
/// The conjunction is the point: a pause **without** a stamp is somebody else's operation and the
|
||||
/// app must not touch it, and a stamp **without** a pause is the app's own finished work. Only
|
||||
/// both together are "the app's own leftovers".
|
||||
///
|
||||
/// **The unreadable repository is the one pause that decides nothing** (06 ▸ Rules, the
|
||||
/// corrupt-`.git` loud failure, ruled 2026-07-31): an abort is a *write*, and there is no
|
||||
/// repository to write to — running one could only produce a second failure row beside the
|
||||
/// standing banner that already explains the board. The stamp is deliberately kept rather than
|
||||
/// cleared, on the same reasoning that keeps it after a failed abort: it is the sole evidence the
|
||||
/// leftover is this app's, and clearing it would demote the leftover to somebody else's forever.
|
||||
/// Whenever the repository becomes readable again, the next open — or the standing pause's own
|
||||
/// re-read followed by a later open — finds the stamp and the real state, and decides properly.
|
||||
public static func decide(
|
||||
stamp: GitOperationStamp?,
|
||||
pause: GitRepositoryPause?
|
||||
) -> GitOperationRecovery {
|
||||
guard let stamp else { return .nothingToDo }
|
||||
guard pause != .unreadable else { return .nothingToDo }
|
||||
guard pause != nil else { return .clearStamp }
|
||||
return .abort(stamp)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
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 and nothing commits on
|
||||
/// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on
|
||||
/// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and
|
||||
/// healed in at open), so all that survives here is a last-chance check that the file exists before
|
||||
/// the initial commit freezes the tree — see `seedGitignoreIfAbsent(at:)`.
|
||||
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.
|
||||
///
|
||||
/// **The initial branch is `main`** (06 ▸ Rules ▸ Opt-in init, blessed 2026-07-31): "the host's
|
||||
/// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it
|
||||
/// deterministically — git's modern default, 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.
|
||||
///
|
||||
/// Between them sits a last-chance `.gitignore` check — the file is the board's rather than
|
||||
/// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here
|
||||
/// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`).
|
||||
///
|
||||
/// **Create re-runs full detection and refuses anything but clean mode none** (06 ▸ Rules ▸
|
||||
/// Detection, ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and
|
||||
/// refuses unless it reads clean none, so the forbidden nested init is impossible even on a
|
||||
/// raced or stale read."
|
||||
///
|
||||
/// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it
|
||||
/// detected at board open, which can be minutes old — a `git init` in a terminal at the board root
|
||||
/// *or anywhere above it* between the two would otherwise slip past a root-only check and
|
||||
/// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole
|
||||
/// walk runs again here, at the moment of the write, so the refusal is structural rather than
|
||||
/// probable. **`.unverifiable` refuses too** — a denied ancestor check can never be told apart
|
||||
/// from a repository actually being there, so only a genuinely clean `.none` reading proceeds; a
|
||||
/// stale `.none` that has since become unverifiable is refused exactly like one that has since
|
||||
/// become repo-nested.
|
||||
///
|
||||
/// 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"
|
||||
|
||||
switch BoardGitMode.detect(boardRoot: boardRoot) {
|
||||
case .none:
|
||||
break
|
||||
case .git:
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "this board already has a git repository"
|
||||
))
|
||||
case .repoNested:
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "this board lives inside a repository; Lanework leaves it to that repository"
|
||||
))
|
||||
case .unverifiable:
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "this board's surroundings could not be fully checked, so Lanework will not add a repository here"
|
||||
))
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
// **Before the stage below, so the seed is *in* the initial commit** (06 ▸ Repository
|
||||
// hygiene). Ordering is the whole of it: written first, `.gitignore` is one of the paths
|
||||
// `git status` reports and rides into "Initial board state" like any other file — and any
|
||||
// `.DS_Store` the Finder already left under the board is ignored from the repository's very
|
||||
// first commit rather than entering history and needing to be forgotten later, which nothing
|
||||
// in this app will ever do (06 ▸ Deleting never forgets).
|
||||
seedGitignoreIfAbsent(at: boardRoot)
|
||||
|
||||
// **The root commit goes through the same signature-capable path every later commit does**
|
||||
// (`GitCommitOperation`), which is what retired this method's config materialization.
|
||||
//
|
||||
// Until the auto-commit card there was no way to hand libgit2 a signature through SwiftGitX
|
||||
// — `commit(message:)` leaves `author`/`committer` null and libgit2 falls back to
|
||||
// `git_signature_default`, which reads a merged config ladder the sandbox cannot see — so
|
||||
// add-git wrote `user.name`/`user.email` into the fresh repository's own config to give that
|
||||
// fallback something to find. That was an explicit interim, and it is gone: **nothing in the
|
||||
// app writes those keys any more.** The identity resolves at commit time, in one place
|
||||
// (`GitCommitOperation.userIdentity(at:)`), repo-local config winning over the derived
|
||||
// default exactly as 06 states — and a repository the app created now looks like one `git
|
||||
// init` made, with no opinion of ours baked into its config. The popover's identity fields
|
||||
// (a later card) are what will write that file, because there "the setting *is* the file".
|
||||
//
|
||||
// Every path `git status` reports is staged — full `git add -A` semantics, `.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").
|
||||
let identity = GitCommitOperation.userIdentity(at: boardRoot)
|
||||
let outcome = GitCommitOperation.perform(
|
||||
at: boardRoot,
|
||||
commits: [PlannedCommit(
|
||||
paths: GitCommitOperation.changedPaths(at: boardRoot).map(\.path),
|
||||
message: initialCommitSubject,
|
||||
author: identity,
|
||||
committer: identity
|
||||
)]
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
case .committed:
|
||||
return .success(branchName(at: boardRoot) ?? initialBranchName)
|
||||
case .nothingToCommit:
|
||||
// A board with no files at all — `git init` on an empty folder. The repository exists,
|
||||
// which is what add-git promised; the first settled change takes the root commit through
|
||||
// the ordinary engine (06 ▸ Rules ▸ Abnormal repo states: an unborn HEAD "is normal git
|
||||
// mode"), and the branch line has a name to show either way.
|
||||
return .success(branchName(at: boardRoot) ?? initialBranchName)
|
||||
case .locked:
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "another program is using this repository's index"
|
||||
))
|
||||
case let .held(pause):
|
||||
return .failure(GitOperationFailure(operation: operation, message: pause.explanation))
|
||||
case let .failed(failure):
|
||||
logger.error("initial commit failed at \(boardRoot.path, privacy: .public): \(failure.message, privacy: .public)")
|
||||
return .failure(GitOperationFailure(operation: operation, message: failure.message))
|
||||
}
|
||||
}
|
||||
|
||||
/// **The last-chance `.gitignore` seed, immediately before the initial commit.**
|
||||
///
|
||||
/// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md ▸ Repository hygiene,
|
||||
/// re-ruled: "`.gitignore` seeded on every board, never touched after … git or not"). Every board
|
||||
/// the app creates is born with one, and every board it opens is healed into having one
|
||||
/// (`BoardStore.seedGitignore`) — and add-git can only run on a board that is *open* and writable,
|
||||
/// so by the time this line is reached the file is essentially always already there and this call
|
||||
/// writes nothing.
|
||||
///
|
||||
/// **It stays anyway, and stays here — before the stage below.** The one case it still answers is
|
||||
/// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient
|
||||
/// failure that armed its memo, a picture that has not changed since), the initial commit would
|
||||
/// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where
|
||||
/// this app has no operation that could ever remove it (06 ▸ Deleting never forgets). One
|
||||
/// `lstat` on the one path that mints a repository is a cheap insurance policy against a
|
||||
/// permanent record.
|
||||
///
|
||||
/// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s — one seed text, one write-only-when-free
|
||||
/// rule, `lstat` semantics — so this cannot drift from what board creation and the heal write.
|
||||
///
|
||||
/// A write that fails is not a failure of add-git. The repository exists, the commit that follows
|
||||
/// simply will not carry a `.gitignore`, and the board's own heal will try again at the next
|
||||
/// open — surfacing a banner about a courtesy file would be louder than the thing it reports.
|
||||
private static func seedGitignoreIfAbsent(at boardRoot: URL) {
|
||||
do {
|
||||
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot)
|
||||
} catch {
|
||||
logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Reads
|
||||
|
||||
/// **Whether libgit2 can open the repository at the board root at all** — the detection-time
|
||||
/// probe behind 06-history-undo.md ▸ Rules' corrupt-`.git` loud failure (ruled 2026-07-31):
|
||||
/// "a corrupt or unopenable repo never falls to mode none … the failure is **loud**".
|
||||
///
|
||||
/// It is deliberately the *same* call every read here already makes (`Repository.open`), so
|
||||
/// "unreadable" means exactly what it means to the rest of this file rather than being a second
|
||||
/// opinion about the same repository. `git_repository_open` validates the layout — `HEAD`,
|
||||
/// `objects/`, `refs/` — resolves a `gitdir:` pointer file, and refuses a repository whose
|
||||
/// format version or extensions it does not implement, which is why a SHA-256 repository lands
|
||||
/// here "by construction" (06 ▸ Repository hygiene: "an adopted SHA-256 repo the engine cannot
|
||||
/// open takes the corrupt-repo loud-failure path").
|
||||
///
|
||||
/// A board with no `.git` at all answers `false` too — there is no repository to read — but that
|
||||
/// is not a state any caller reaches: the probe runs only in mode `git`, which is exactly the
|
||||
/// mode a root `.git` defines.
|
||||
///
|
||||
/// Read-only, like everything in this section: opening a repository writes nothing, and a
|
||||
/// repository that fails to open has not been touched at all.
|
||||
nonisolated static func canOpen(at boardRoot: URL) -> Bool {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return false }
|
||||
return (try? Repository.open(at: boardRoot)) != nil
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
import Foundation
|
||||
import libgit2
|
||||
import os
|
||||
|
||||
// MARK: - The plan
|
||||
|
||||
/// **One restore, as the writes it will make** — computed before anything touches the working tree,
|
||||
/// so the whole of what a ⌘Z is about to do is a value a caller can inspect, gate on, and test.
|
||||
public struct GitRestorePlan: Sendable, Equatable {
|
||||
|
||||
/// One file the restore will write or remove.
|
||||
public struct Change: Sendable, Equatable {
|
||||
/// Board-root-relative, in git's own spelling.
|
||||
public let path: String
|
||||
/// The bytes to write, or `nil` to remove the file.
|
||||
public let contents: Data?
|
||||
|
||||
public init(path: String, contents: Data?) {
|
||||
self.path = path
|
||||
self.contents = contents
|
||||
}
|
||||
}
|
||||
|
||||
public let changes: [Change]
|
||||
|
||||
public init(changes: [Change]) {
|
||||
self.changes = changes
|
||||
}
|
||||
|
||||
public var paths: [String] { changes.map(\.path) }
|
||||
|
||||
public var isEmpty: Bool { changes.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - GitRestoreOperation
|
||||
|
||||
/// **Undo and redo, as forward commits** (14-git-operations.md ▸ The forward-restore model; the
|
||||
/// load-bearing extraction): "Every restorative operation moves history forward. Nothing the app does
|
||||
/// ever rewrites a published commit: no reset, no force-push, no revert-by-rewrite."
|
||||
///
|
||||
/// ### What this file is allowed to call, and what it is not
|
||||
///
|
||||
/// It materializes an older state as **ordinary working-tree writes** and then commits them through
|
||||
/// the same signature-capable path every auto-commit takes (`GitCommitOperation.perform`). It never
|
||||
/// calls `git_reset`, never moves a reference by hand, never writes `refs/`, and never touches the
|
||||
/// reflog: the only ref movement in the whole restore is `git_commit_create`'s own advance of HEAD,
|
||||
/// which is what a commit *is*. That is the property "verifiable by trail inspection in any git
|
||||
/// client" reduces to, and it is checkable here by reading the imports: nothing below resolves a
|
||||
/// reset or a checkout symbol at all.
|
||||
///
|
||||
/// ### Only the diff, never the tree
|
||||
///
|
||||
/// "A restore materializes only the diff between the current tree and the target state, so a card
|
||||
/// whose open Edit session the diff doesn't touch is simply unaffected" (06-history-undo.md ▸ Rules
|
||||
/// ▸ Undo restore vs open Edit sessions). So the plan is HEAD's tree against the target's, file by
|
||||
/// file — never a checkout of the whole target, which would sweep every unrelated file on the board
|
||||
/// through a write it did not need.
|
||||
///
|
||||
/// Two deliberate narrowings ride on that:
|
||||
///
|
||||
/// - **`excluding`** — the heal-transparency rule's second half (06 ▸ Rules ▸ Heal commits are
|
||||
/// transparent to undo): "a restore materializing an older target **excludes paths whose divergence
|
||||
/// is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler."
|
||||
/// - **`reconciling`** — the card sessions the user chose to **Discard** at the save-or-discard step
|
||||
/// (06 ▸ Branch switching: "Discard reverts buffers and uncommitted saves to HEAD"). Those folders
|
||||
/// are compared against the **working tree** rather than against HEAD, because their uncommitted
|
||||
/// on-disk saves are precisely the state HEAD does not have — one pass that both drops the
|
||||
/// discarded saves and applies the restore, instead of a revert followed by a restore that would
|
||||
/// have to agree with it. They arrive as folder **names**, not paths; see `folderPaths(named:at:)`
|
||||
/// for why that distinction is the difference between the rule working and silently not.
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in
|
||||
/// the same synchronous scope, and no handle crosses an `await`. Called from a detached task.
|
||||
enum GitRestoreOperation {
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// The operation name a failure carries into the banner (06 ▸ Interaction with external writers:
|
||||
/// "surfaces as a one-shot banner failure naming the operation and the error").
|
||||
static let operationName = "Restoring an earlier state"
|
||||
|
||||
/// libgit2's global state — `GitCommitOperation.startUp`'s twin, and for its reason.
|
||||
private static let startUp: Bool = {
|
||||
git_libgit2_init() >= 0
|
||||
}()
|
||||
|
||||
// MARK: - Planning
|
||||
|
||||
/// **The writes that would turn the working tree into `target`'s state**, or `nil` when the
|
||||
/// repository could not be read.
|
||||
///
|
||||
/// `nil` is emphatically not "nothing to do": a restore that silently did nothing because a tree
|
||||
/// would not load is the one failure mode a forward-only undo could not explain afterwards.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - target: the oid of the commit whose state is being restored.
|
||||
/// - excluding: board-root-relative paths whose divergence is heal work — never materialized.
|
||||
/// - reconciling: card **folder names** — the ids `SettleableSession.cardFolderName` carries —
|
||||
/// whose folders are compared against the working tree rather than against HEAD (the Discard
|
||||
/// branch of the save-or-discard step). Resolved to real paths here, once, by the resolver
|
||||
/// both callers share.
|
||||
nonisolated static func plan(
|
||||
at boardRoot: URL,
|
||||
target: String,
|
||||
excluding: Set<String> = [],
|
||||
reconciling folderNames: Set<String> = []
|
||||
) -> GitRestorePlan? {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return nil }
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
// **Names in, paths out — the one resolution both Discard paths take** (the undo restore's,
|
||||
// and the branch switch's `revertToHead`). A card's folder name is its id; its *path* is
|
||||
// `<lane>/<id>`, and every live card has a lane above it, so treating the name as a path
|
||||
// matched nothing at all and made the whole Discard branch silently inert.
|
||||
let reconciling = folderPaths(named: folderNames, at: boardRoot)
|
||||
|
||||
guard let targetTree = tree(of: target, in: repository) else { return nil }
|
||||
defer { git_tree_free(targetTree) }
|
||||
var wanted: [String: git_oid] = [:]
|
||||
fileMap(of: targetTree, in: repository, prefix: "", depth: 0, into: &wanted)
|
||||
|
||||
var current: [String: git_oid] = [:]
|
||||
if let headTree = headTree(of: repository) {
|
||||
defer { git_tree_free(headTree) }
|
||||
fileMap(of: headTree, in: repository, prefix: "", depth: 0, into: ¤t)
|
||||
}
|
||||
|
||||
// The reconciled folders answer from disk instead: their committed state is beside the point,
|
||||
// because what is being discarded is exactly what is *not* committed.
|
||||
if !reconciling.isEmpty {
|
||||
for folder in reconciling {
|
||||
current = current.filter { !isInside($0.key, folder: folder) }
|
||||
}
|
||||
for path in workingTreeFiles(under: reconciling, at: boardRoot) {
|
||||
// A sentinel oid nothing can equal: the comparison below only ever asks "same or
|
||||
// different", and a working-tree file's bytes are not addressed by the object store.
|
||||
current[path] = git_oid()
|
||||
}
|
||||
}
|
||||
|
||||
var changes: [GitRestorePlan.Change] = []
|
||||
for (path, oid) in wanted.sorted(by: { $0.key < $1.key }) {
|
||||
guard !excluding.contains(path) else { continue }
|
||||
if let held = current[path], equal(held, oid), !isInside(path, folders: reconciling) { continue }
|
||||
guard let data = blob(oid, in: repository) else { continue }
|
||||
changes.append(GitRestorePlan.Change(path: path, contents: data))
|
||||
}
|
||||
for path in current.keys.sorted() where wanted[path] == nil {
|
||||
guard !excluding.contains(path) else { continue }
|
||||
changes.append(GitRestorePlan.Change(path: path, contents: nil))
|
||||
}
|
||||
return GitRestorePlan(changes: changes.sorted { $0.path < $1.path })
|
||||
}
|
||||
|
||||
// MARK: - Applying
|
||||
|
||||
/// **Writes the plan and commits it** — one new commit on the current branch, nothing rewound.
|
||||
///
|
||||
/// The commit goes through `GitCommitOperation.perform` unchanged, so it takes the ordinary
|
||||
/// signature path (06 ▸ Interaction with external writers) and is authored by the user: a restore
|
||||
/// is the user acting through the app, whatever the origin of the commit it crosses.
|
||||
///
|
||||
/// A plan that turns out to write nothing new commits nothing — `perform`'s own empty-tree skip —
|
||||
/// and answers `.nothingToCommit`, which the caller reads as "the step was crossed and needed no
|
||||
/// bytes", not as a failure.
|
||||
nonisolated static func apply(
|
||||
_ plan: GitRestorePlan,
|
||||
at boardRoot: URL,
|
||||
message: String
|
||||
) -> GitCommitOutcome {
|
||||
_ = startUp
|
||||
guard !plan.isEmpty else { return .nothingToCommit }
|
||||
|
||||
if let failure = materialize(plan, at: boardRoot) {
|
||||
return .failed(failure)
|
||||
}
|
||||
|
||||
let identity = GitCommitOperation.userIdentity(at: boardRoot)
|
||||
return GitCommitOperation.perform(
|
||||
at: boardRoot,
|
||||
commits: [PlannedCommit(
|
||||
paths: plan.paths,
|
||||
message: message,
|
||||
author: identity,
|
||||
committer: identity,
|
||||
kind: .user
|
||||
)],
|
||||
allowRootCommit: false
|
||||
)
|
||||
}
|
||||
|
||||
/// **The writes, without the commit** — the plan materialized onto disk. `nil` means every change
|
||||
/// landed.
|
||||
///
|
||||
/// Split out of `apply` for the branch switch's Discard branch (`revertToHead(folders:at:)`),
|
||||
/// which needs the bytes moved and emphatically does *not* want a commit attempted over them.
|
||||
nonisolated static func materialize(_ plan: GitRestorePlan, at boardRoot: URL) -> GitOperationFailure? {
|
||||
let manager = FileManager.default
|
||||
for change in plan.changes {
|
||||
let url = boardRoot.appendingPathComponent(change.path)
|
||||
guard let contents = change.contents else {
|
||||
try? manager.removeItem(at: url)
|
||||
pruneEmptyFolders(above: url, upTo: boardRoot)
|
||||
continue
|
||||
}
|
||||
let folder = url.deletingLastPathComponent()
|
||||
do {
|
||||
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try contents.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
logger.error("restore could not write \(change.path, privacy: .public)")
|
||||
return GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: (error as NSError).localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// **"Discard reverts buffers and uncommitted saves to HEAD"** (06-history-undo.md ▸ Branch
|
||||
/// switching) — the *uncommitted saves* half, for the operation that has no restore plan to fold
|
||||
/// it into.
|
||||
///
|
||||
/// An undo restore reconciles a discarded card's folder inside its own plan, because it is
|
||||
/// materializing a target state anyway and one pass that does both cannot disagree with itself. A
|
||||
/// branch switch materializes nothing — libgit2's checkout does the moving — so the discard has to
|
||||
/// be its own step, and it has to run **before** the pending auto-commit is flushed: `discard`
|
||||
/// ends the Edit session, which un-stages-around the card's folder, so a flush over a folder still
|
||||
/// holding those saves would commit exactly the text the user just asked to lose.
|
||||
///
|
||||
/// It is expressed as a restore *to HEAD* with the folders reconciled against the working tree,
|
||||
/// which is the same machinery under a different target: every path outside those folders compares
|
||||
/// HEAD against HEAD and produces nothing, and inside them the working tree's own files are what
|
||||
/// the plan replaces. Nothing is committed — by construction there is nothing new to commit, since
|
||||
/// the tree afterwards is HEAD's.
|
||||
///
|
||||
/// Answers whether the revert ran cleanly; `false` is a repository that could not be read, which
|
||||
/// the caller reports as its operation's clean failure.
|
||||
///
|
||||
/// - Parameter folderNames: card **folder names** — the ids `SettleableSession.cardFolderName`
|
||||
/// carries, not paths. Resolved against the tree here for that property's own reason: "a card's
|
||||
/// own folder component never changes, only the lane above it", so a session that began before a
|
||||
/// lane move is still matched afterwards.
|
||||
nonisolated static func revertToHead(folderNames: Set<String>, at boardRoot: URL) -> Bool {
|
||||
_ = startUp
|
||||
guard !folderNames.isEmpty else { return true }
|
||||
guard let head = GitHistoryWalk.headOID(at: boardRoot) else { return false }
|
||||
// A card whose folder is not on disk resolves to nothing, plans nothing, and writes nothing:
|
||||
// it was deleted, or it never existed, and either way there are no uncommitted saves to
|
||||
// revert.
|
||||
guard let plan = plan(at: boardRoot, target: head, reconciling: folderNames) else { return false }
|
||||
return materialize(plan, at: boardRoot) == nil
|
||||
}
|
||||
|
||||
/// **Board-root-relative paths of every folder whose last component is one of `names`** — the one
|
||||
/// place a card id becomes a place on disk.
|
||||
///
|
||||
/// Component-exact, which is the same match `SessionSettleGate` uses to decide *which* sessions an
|
||||
/// operation reaches (`GitHistoryWalk.path(_:isInsideFolderNamed:)`) and it is chosen for that
|
||||
/// rule's own reason: "a card's own folder component never changes, only the lane above it", so a
|
||||
/// session that began before a lane move is still found afterwards. Matching a name as a path
|
||||
/// prefix instead is what made the Discard branch inert — a bug this resolver exists to make
|
||||
/// unrepeatable, since both callers now go through it.
|
||||
///
|
||||
/// `.git` is never walked — it is not part of any board's tree, and nothing here may write into
|
||||
/// it.
|
||||
private static func folderPaths(named names: Set<String>, at boardRoot: URL) -> Set<String> {
|
||||
guard let walker = FileManager.default.enumerator(
|
||||
at: boardRoot,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsPackageDescendants]
|
||||
) else { return [] }
|
||||
|
||||
var found: Set<String> = []
|
||||
for case let url as URL in walker {
|
||||
let name = url.lastPathComponent
|
||||
if name == ".git" {
|
||||
walker.skipDescendants()
|
||||
continue
|
||||
}
|
||||
guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true,
|
||||
names.contains(name),
|
||||
let relative = relativePath(of: url, under: boardRoot) else { continue }
|
||||
found.insert(relative)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// MARK: - Private plumbing
|
||||
|
||||
private static func open(_ boardRoot: URL) -> OpaquePointer? {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
|
||||
var repository: OpaquePointer?
|
||||
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
|
||||
return repository
|
||||
}
|
||||
|
||||
private static func tree(of oid: String, in repository: OpaquePointer) -> OpaquePointer? {
|
||||
var id = git_oid()
|
||||
guard git_oid_fromstr(&id, oid) == 0 else { return nil }
|
||||
var commit: OpaquePointer?
|
||||
guard git_commit_lookup(&commit, repository, &id) == 0, let commit else { return nil }
|
||||
defer { git_commit_free(commit) }
|
||||
var tree: OpaquePointer?
|
||||
guard git_commit_tree(&tree, commit) == 0 else { return nil }
|
||||
return tree
|
||||
}
|
||||
|
||||
private static func headTree(of repository: OpaquePointer) -> OpaquePointer? {
|
||||
guard git_repository_head_unborn(repository) != 1 else { return nil }
|
||||
var reference: OpaquePointer?
|
||||
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
|
||||
defer { git_reference_free(reference) }
|
||||
var object: OpaquePointer?
|
||||
guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0 else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
/// Every blob under a tree, board-root-relative, with its object id.
|
||||
///
|
||||
/// The depth cap is `GitHeadSnapshot.materialize`'s, for its reason: a guard against a
|
||||
/// pathological repository, not a statement about boards.
|
||||
private static func fileMap(
|
||||
of tree: OpaquePointer,
|
||||
in repository: OpaquePointer,
|
||||
prefix: String,
|
||||
depth: Int,
|
||||
into map: inout [String: git_oid]
|
||||
) {
|
||||
guard depth < 8 else { return }
|
||||
for position in 0..<git_tree_entrycount(tree) {
|
||||
guard let entry = git_tree_entry_byindex(tree, position),
|
||||
let rawName = git_tree_entry_name(entry),
|
||||
let id = git_tree_entry_id(entry) else { continue }
|
||||
let name = String(cString: rawName)
|
||||
// A `/` in a tree entry name is impossible in a well-formed tree and would be a path
|
||||
// escape if it were not: refuse rather than interpret (`GitHeadSnapshot`'s rule).
|
||||
guard !name.isEmpty, name != ".", name != "..", !name.contains("/") else { continue }
|
||||
let path = prefix.isEmpty ? name : prefix + "/" + name
|
||||
|
||||
switch git_tree_entry_type(entry) {
|
||||
case GIT_OBJECT_TREE:
|
||||
var child: OpaquePointer?
|
||||
guard git_tree_lookup(&child, repository, id) == 0, let child else { continue }
|
||||
defer { git_tree_free(child) }
|
||||
fileMap(of: child, in: repository, prefix: path, depth: depth + 1, into: &map)
|
||||
case GIT_OBJECT_BLOB:
|
||||
map[path] = id.pointee
|
||||
default:
|
||||
// Submodules and symlinks: neither is a board, and neither is followed anywhere else
|
||||
// in this app either.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func blob(_ oid: git_oid, in repository: OpaquePointer) -> Data? {
|
||||
var id = oid
|
||||
var blob: OpaquePointer?
|
||||
guard git_blob_lookup(&blob, repository, &id) == 0, let blob else { return nil }
|
||||
defer { git_blob_free(blob) }
|
||||
let size = Int(git_blob_rawsize(blob))
|
||||
guard size > 0, let bytes = git_blob_rawcontent(blob) else { return Data() }
|
||||
return Data(bytes: bytes, count: size)
|
||||
}
|
||||
|
||||
/// Every file on disk under one of `folders`, board-root-relative. `.git` is never walked — it is
|
||||
/// not part of any board's tree and nothing here may write into it.
|
||||
private static func workingTreeFiles(under folders: Set<String>, at boardRoot: URL) -> [String] {
|
||||
var found: [String] = []
|
||||
for folder in folders {
|
||||
let root = boardRoot.appendingPathComponent(folder)
|
||||
guard let walker = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isRegularFileKey],
|
||||
options: [.skipsHiddenFiles, .skipsPackageDescendants]
|
||||
) else { continue }
|
||||
for case let url as URL in walker {
|
||||
guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
|
||||
else { continue }
|
||||
guard let relative = relativePath(of: url, under: boardRoot) else { continue }
|
||||
found.append(relative)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private static func relativePath(of url: URL, under boardRoot: URL) -> String? {
|
||||
let root = boardRoot.standardizedFileURL.path
|
||||
let path = url.standardizedFileURL.path
|
||||
guard path.hasPrefix(root + "/") else { return nil }
|
||||
return String(path.dropFirst(root.count + 1))
|
||||
}
|
||||
|
||||
private static func isInside(_ path: String, folder: String) -> Bool {
|
||||
path == folder || path.hasPrefix(folder + "/")
|
||||
}
|
||||
|
||||
private static func isInside(_ path: String, folders: Set<String>) -> Bool {
|
||||
folders.contains { isInside(path, folder: $0) }
|
||||
}
|
||||
|
||||
/// Removes folders emptied by a deletion, up to (never including) the board root — the same
|
||||
/// tidiness a card's own delete leaves behind, so a restore does not litter a board with empty
|
||||
/// UUID folders that the loader would then have to ignore.
|
||||
private static func pruneEmptyFolders(above file: URL, upTo boardRoot: URL) {
|
||||
let manager = FileManager.default
|
||||
let root = boardRoot.standardizedFileURL.path
|
||||
var folder = file.deletingLastPathComponent().standardizedFileURL
|
||||
while folder.path != root, folder.path.hasPrefix(root + "/") {
|
||||
let contents = (try? manager.contentsOfDirectory(atPath: folder.path)) ?? []
|
||||
guard contents.isEmpty || contents == [".DS_Store"] else { return }
|
||||
try? manager.removeItem(at: folder)
|
||||
folder = folder.deletingLastPathComponent().standardizedFileURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
|
||||
var left = lhs
|
||||
var right = rhs
|
||||
return git_oid_cmp(&left, &right) == 0
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - HistoryCommitSeam
|
||||
|
||||
/// **The three places the auto-committer touches the store's write and reload paths**
|
||||
/// (06-history-undo.md ▸ Rules ▸ Auto-commit, ▸ Flush-before-overwrite).
|
||||
///
|
||||
/// ### Why a struct of closures rather than a reference to the committer
|
||||
///
|
||||
/// `BoardStore` lives in the live store and must not learn what a repository is: most boards have no
|
||||
/// committer at all — git is opt-in per board (06 ▸ Rules), and since the 2026-08-07 pivot that is
|
||||
/// the *only* reason a board lacks one (12-editions.md) — so the engine has to be *structurally*
|
||||
/// unreachable there rather than switched off, and a store holding an optional committer would be a
|
||||
/// store that knows about git.
|
||||
/// One optional value, `nil` on every board that has no committer, is the same shape `watcherBrackets`
|
||||
/// and `history` already take, and it keeps the three orderings — before the write, after the
|
||||
/// bracket, after the landing — stated in one type instead of three properties that could drift.
|
||||
///
|
||||
/// It is also what makes the ordering testable without a repository: a test binds a seam that records
|
||||
/// its calls and asserts that a write flushed before it landed, exactly as `CloseFlushCoordinator`'s
|
||||
/// closures do for the close sequence.
|
||||
@MainActor
|
||||
public struct HistoryCommitSeam {
|
||||
|
||||
/// **Before an app write** — flush the pending auto-commit if this write could overwrite an
|
||||
/// external version that is not in history yet, so "both versions exist as commits" holds.
|
||||
///
|
||||
/// Synchronous because `performWrite` is: an ordering guarantee *before* a synchronous write can
|
||||
/// only be kept synchronously. See `GitAutoCommitter.noteWillWrite()` for the gate that keeps it
|
||||
/// rare and for the costs it carries.
|
||||
public var willWrite: () -> Void
|
||||
|
||||
/// **After a write bracket closes** — harvest the bracket's receipts and arm the debounce.
|
||||
///
|
||||
/// The harvest is why this is a signal of its own: receipts are consumed by the landing reload
|
||||
/// that classifies them, and this is the last moment they still describe a completed write
|
||||
/// (`EchoLedger.outstandingEntries`).
|
||||
public var writeBracketDidClose: () -> Void
|
||||
|
||||
/// **After a reload lands** — arm the debounce, carrying whether the reload revealed anything the
|
||||
/// ledger did not vouch for.
|
||||
public var reloadDidLand: (_ sawForeignChange: Bool) -> Void
|
||||
|
||||
public init(
|
||||
willWrite: @escaping () -> Void,
|
||||
writeBracketDidClose: @escaping () -> Void,
|
||||
reloadDidLand: @escaping (Bool) -> Void
|
||||
) {
|
||||
self.willWrite = willWrite
|
||||
self.writeBracketDidClose = writeBracketDidClose
|
||||
self.reloadDidLand = reloadDidLand
|
||||
}
|
||||
|
||||
/// The seam a session binds for a board that has a committer — the one production composition,
|
||||
/// kept beside the type so no call site spells the three wirings out.
|
||||
public static func binding(to committer: GitAutoCommitter) -> HistoryCommitSeam {
|
||||
HistoryCommitSeam(
|
||||
willWrite: { [weak committer] in committer?.noteWillWrite() },
|
||||
writeBracketDidClose: { [weak committer] in committer?.noteWriteBracketClosed() },
|
||||
reloadDidLand: { [weak committer] saw in committer?.noteReloadLanded(sawForeignChange: saw) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
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 at every open — in every tier
|
||||
///
|
||||
/// `compose(boardRoot:ledger:)` runs detection and hands back a store for **every** session
|
||||
/// (12-editions.md ▸ PIVOT 2026-08-07 — git left the paywall: "every tier composes the git stack on
|
||||
/// git-mode boards exactly as Pro did"). It used to be the gate: the free tier got no object at all,
|
||||
/// so a free session ran no detection and did not so much as `stat` a `.git` — the inert-`.git`
|
||||
/// posture made structural rather than remembered. That posture is **retired**. A `.git` at a board
|
||||
/// root is live in every tier, detection runs at every board open off the same path Pro's always
|
||||
/// used, and nothing in this type ever asks what anybody paid.
|
||||
///
|
||||
/// What the pivot does **not** change is why mode `none` still exists at all: git stays **opt-in per
|
||||
/// board** (06 ▸ Rules — "No silent auto-init, ever"). A board whose user never asked for a
|
||||
/// repository composes here, detects `none`, and builds no committer, no switcher and no
|
||||
/// housekeeper — nothing that could touch a `.git` it does not have.
|
||||
///
|
||||
/// ### 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 reads `mode` and nothing else** — the composition
|
||||
/// root binds the git provider on mode `git` and the native stack on modes `none` and `repoNested`
|
||||
/// alike (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the board, and
|
||||
/// what a repo-nested board denies is app-managed history, never ⌘Z). Auto-commit, commit messages,
|
||||
/// branch controls, the identity fields, the `.gitignore` seed and its periodic housekeeping each
|
||||
/// arrived as their own card and are composed here now; remotes are pro-m2's and deliberately still
|
||||
/// absent.
|
||||
@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 while the form that asked is still on screen, or `nil`.
|
||||
///
|
||||
/// **Form-anchored operations answer at the form first** (06 ▸ Interaction with external writers,
|
||||
/// ruled 2026-07-31): "add-git — and later sheet-asked operations like verify-remote — fail into
|
||||
/// an inline caption in the sheet's relevant section while the sheet is up … if the sheet has been
|
||||
/// dismissed before the answer arrives, the failure falls back to the one-shot banner above —
|
||||
/// inline is the primary surface, never a silence trap."
|
||||
///
|
||||
/// So this property is exactly the *inline* half: it is set only while `isFormVisible`, and
|
||||
/// dismissing the form clears it ("dismissing the sheet dismisses the stale error"). The other
|
||||
/// half is `reportFailure`, which posts the banner when the answer arrives to an empty room.
|
||||
///
|
||||
/// The form is the **popover's Git tab, in its no-repository posture** (`BoardGitAddAction`),
|
||||
/// which is where add-git has lived since the 2026-08-07 reversal — and where it lived before
|
||||
/// the 2026-07-31 popover/sheet split moved it to the sheet for the week that sheet existed.
|
||||
/// `noteFormVisible(_:)` is the one line either move re-pointed: the ruling's container changed
|
||||
/// twice, its substance neither time.
|
||||
public private(set) var lastFailure: GitOperationFailure?
|
||||
|
||||
/// Whether the form add-git was asked from is on screen right now (`noteFormVisible(_:)`).
|
||||
public private(set) var isFormVisible = false
|
||||
|
||||
/// **The auto-commit engine** (06-history-undo.md ▸ Rules ▸ Auto-commit), or `nil` on a board
|
||||
/// there is no repository to commit into.
|
||||
///
|
||||
/// Its existence is exactly `mode == .git`, and since the 2026-08-07 pivot (12-editions.md) that
|
||||
/// invariant carries the whole story on its own: what keeps a committer off a board is the
|
||||
/// board's own mode — git is opt-in per board, so a user who never asked for a repository has
|
||||
/// nothing here to disable and no flag anyone could forget. It used to rest on a tier gate one
|
||||
/// level up (no `HistoryStore` off Pro meant no committer off Pro); the gate is gone.
|
||||
///
|
||||
/// **Composed inert and started separately.** Composition happens on the board-open path, where
|
||||
/// nothing may block and where a session does not exist yet; `activateAutoCommit(_:)` is what
|
||||
/// `AppModel.beginSession` calls once the store, the banner strip and the card windows are
|
||||
/// reachable, and it is what arms the launch catch-up. A `HistoryStore` built without a session —
|
||||
/// a test, a storeless consumer — therefore has a committer that never runs.
|
||||
public private(set) var committer: GitAutoCommitter?
|
||||
|
||||
/// **The branch controls** (06-history-undo.md ▸ Branch switching), or `nil` on a board there is
|
||||
/// no repository to switch branches in.
|
||||
///
|
||||
/// Its existence is exactly `mode == .git`, the committer's rule for the committer's reason — and
|
||||
/// like the committer it is composed inert: the seams that make it a *sequence* (the settle step,
|
||||
/// the store's bracket, the undo reseed, the banner strip) arrive from the session, and a
|
||||
/// `HistoryStore` built without one has a switcher that can list branches and nothing else.
|
||||
public private(set) var switcher: GitBranchSwitcher?
|
||||
|
||||
/// **Periodic safe housekeeping** (06-history-undo.md ▸ Repository hygiene), or `nil` on a board
|
||||
/// there is no repository to maintain.
|
||||
///
|
||||
/// Its existence is exactly `mode == .git`, the committer's rule for the committer's reason, and
|
||||
/// it is composed inert for a sharper version of the committer's: packing loose objects is the
|
||||
/// most expensive thing this layer can do, and board open is where 02-architecture.md's
|
||||
/// hang-avoidance doctrine is strictest. `activateAutoCommit(_:)` is what arms it — beside the
|
||||
/// committer, so the two are one decision — and a `HistoryStore` built without a session has a
|
||||
/// housekeeper that never runs.
|
||||
public private(set) var housekeeper: GitHousekeeper?
|
||||
|
||||
// MARK: - Commit identity
|
||||
|
||||
/// **What repo-local `.git/config` says right now** — the settings sheet's two fields, as values
|
||||
/// rather than as a resolved identity (06 ▸ Interaction with external writers: "The board settings
|
||||
/// sheet's identity section … exposes name/email fields that write that repo-local config — the
|
||||
/// setting *is* the file").
|
||||
///
|
||||
/// Empty means the file names no such key, which is what an empty field means: the derived default
|
||||
/// applies, shown as the field's *placeholder*. Filling the field in with the derived value would
|
||||
/// be the app writing its own guess into the user's repository the first time they edited anything
|
||||
/// else on the sheet — the exact thing 06 rules out.
|
||||
public private(set) var identityName = ""
|
||||
|
||||
public private(set) var identityEmail = ""
|
||||
|
||||
/// **The derived default**, for the placeholders — `nil` until `refreshIdentity()` has run.
|
||||
///
|
||||
/// Deliberately not computed at composition: `GitIdentity.derivedDefault()` reads
|
||||
/// `ProcessInfo.hostName`, which can block on a machine whose name resolution is slow, and the
|
||||
/// board-open path is where 02-architecture.md's hang-avoidance doctrine is strictest. It is read
|
||||
/// off the main actor with the config, when the sheet asks.
|
||||
public private(set) var derivedIdentity: GitIdentity?
|
||||
|
||||
/// The last identity-write failure, surfaced as an inline caption on the settings sheet beside the
|
||||
/// fields — 06's form-anchored posture ("the user asked from a form still under their eye"), which
|
||||
/// is exactly where `lastFailure` above already puts add-git's.
|
||||
public private(set) var identityFailure: GitOperationFailure?
|
||||
|
||||
/// The board's write-provenance ledger, held so an add-git flip can build a committer over the
|
||||
/// same one the session's store owns.
|
||||
@ObservationIgnored
|
||||
private let ledger: EchoLedger
|
||||
|
||||
/// How the session wires a committer up, remembered so the one built by a mid-session add-git
|
||||
/// gets the same treatment as the one composed at open.
|
||||
@ObservationIgnored
|
||||
private var autoCommitWiring: ((GitAutoCommitter) -> Void)?
|
||||
|
||||
/// **The mid-session mode flip, announced** — called once, after a successful `addGit()`, and
|
||||
/// never on any other path.
|
||||
///
|
||||
/// It exists because the flip has a second consumer beyond the committer: the board's **undo
|
||||
/// substrate**. A session that composed on a mode-none board bound the native stack
|
||||
/// (`AppModel.makeHistoryProvider`), and 06 ▸ Rules ▸ Detection's one sanctioned commanded flip
|
||||
/// means the board now has a trail to be an undo stack over instead — "add-git swaps the
|
||||
/// substrate mid-session … discards the in-session native stack and seeds the git trail from the
|
||||
/// root commit" (13-native-undo.md). What that swap means is
|
||||
/// `AppModel.bindHistoryProvider(for:)`'s to decide and to justify; what this property does is
|
||||
/// keep that decision out of a git state that has no business knowing what a provider is.
|
||||
@ObservationIgnored
|
||||
public var didAddGit: (@MainActor () -> Void)?
|
||||
|
||||
/// **The banner half of the form-anchored posture** — where a form-asked failure goes when the
|
||||
/// form is gone (`BannerCenter.postGitFailure`). `nil` on a storeless `HistoryStore`, which has no
|
||||
/// strip to post to; the inline half still works there.
|
||||
@ObservationIgnored
|
||||
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
||||
|
||||
/// **The form appeared or was dismissed.** Dismissal clears the stale inline error, which is the
|
||||
/// ruling's own sentence ("dismissing the sheet dismisses the stale error, retry is right there").
|
||||
///
|
||||
/// A `Bool` rather than a count because there is one such form per board at a time: the settings
|
||||
/// sheet is modal to its board window, and opening it dismisses the popover.
|
||||
public func noteFormVisible(_ visible: Bool) {
|
||||
isFormVisible = visible
|
||||
if !visible { lastFailure = nil }
|
||||
}
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// **The repository is there and cannot be opened** (06-history-undo.md ▸ Rules: "A `.git` that
|
||||
/// isn't a valid repository still reads as git mode — and fails loudly", ruled 2026-07-31).
|
||||
///
|
||||
/// Read off the committer's pause rather than stored beside it, deliberately: the detection-time
|
||||
/// probe *seeds* that pause (`init`), every later read of the repository refreshes it — the
|
||||
/// standing pause's 15 s re-read, the popover's `refreshPause`, any flush attempt — and a second
|
||||
/// stored copy could only ever be the stale one. `false` on every board with no repository to
|
||||
/// read, which is every mode but `.git`.
|
||||
///
|
||||
/// **Never a mode change.** Detection stays presence-shaped: the board is in git mode because a
|
||||
/// `.git` is at its root, whatever condition it is in, so add-git is never offered against it
|
||||
/// ("init into a repairable repo is exactly the never-mutate hazard").
|
||||
public var isRepositoryUnreadable: Bool { committer?.pause == .unreadable }
|
||||
|
||||
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
|
||||
self.boardRoot = boardRoot
|
||||
self.mode = mode
|
||||
self.ledger = ledger
|
||||
if mode == .git {
|
||||
let committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
|
||||
self.committer = committer
|
||||
switcher = GitBranchSwitcher(boardRoot: boardRoot)
|
||||
housekeeper = GitHousekeeper(boardRoot: boardRoot)
|
||||
// **The detection-time probe** (06 ▸ Rules, the corrupt-`.git` loud failure): detection
|
||||
// answers presence, this answers readability, and the ruling wants the second answer at
|
||||
// the same moment as the first — "a standing breakage-class banner at detection …
|
||||
// never a silent placeholder discovered only in the popover".
|
||||
//
|
||||
// One `git_repository_open` per git-mode board open, which is the same call the branch
|
||||
// line makes a moment later and a handful of `stat`s in the ordinary case. That is the
|
||||
// budget 02's hang-avoidance doctrine leaves for an answer the open path cannot do
|
||||
// without: the alternative is a board that looks live until the first debounce fires.
|
||||
if !GitRepository.canOpen(at: boardRoot) {
|
||||
committer.noteRepositoryUnreadable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **Wires the committer into its session and starts it** — `AppModel.beginSession`'s call.
|
||||
///
|
||||
/// Separate from composition for two reasons that point the same way: the seams a committer needs
|
||||
/// (the banner strip, the board's snapshot, the card windows' Edit sessions) belong to a session
|
||||
/// that does not exist when `compose` runs, and arming a debounce is a side effect no *detection*
|
||||
/// should have. The wiring is remembered because add-git can produce a committer later, and a
|
||||
/// board that flipped into git mode mid-session must commit exactly like one that opened in it.
|
||||
public func activateAutoCommit(_ wire: @escaping (GitAutoCommitter) -> Void) {
|
||||
autoCommitWiring = wire
|
||||
guard let committer else { return }
|
||||
wire(committer)
|
||||
committer.start()
|
||||
armHousekeeping(beside: committer)
|
||||
}
|
||||
|
||||
/// Stops the committer, and the maintenance beside it — the session's teardown, so neither a
|
||||
/// closed board's debounce nor its housekeeping can fire against a store that has gone.
|
||||
public func stopAutoCommit() {
|
||||
committer?.stop()
|
||||
housekeeper?.cancel()
|
||||
}
|
||||
|
||||
/// **Arms the board-open housekeeping pass** (06 ▸ Repository hygiene) — one call site's worth of
|
||||
/// wiring, shared by the session's activation and by a mid-session add-git, so a board that
|
||||
/// flipped into git mode maintains itself exactly like one that opened in it.
|
||||
///
|
||||
/// The gate it hands over is the committer's own in-flight flag, read at the moment of dispatch:
|
||||
/// the simplest honest way to keep optional work from starting beside the one operation that must
|
||||
/// never be disturbed, and deliberately not a lock — see `GitHousekeeper.runNow()`.
|
||||
private func armHousekeeping(beside committer: GitAutoCommitter) {
|
||||
guard let housekeeper else { return }
|
||||
housekeeper.isCommitInFlight = { [weak committer] in committer?.isCommitInFlight ?? false }
|
||||
housekeeper.schedule()
|
||||
}
|
||||
|
||||
/// **The open-time detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
||||
/// open") — called by `AppModel.beginSession`, unconditionally, once per board.
|
||||
///
|
||||
/// ### This was the tier gate, and is not one any more
|
||||
///
|
||||
/// It took a `tier` and answered `nil` under `.free`: no git state existed for such a session, so
|
||||
/// no caller could consult one and no free open ever stat'ed a `.git`. **PIVOT 2026-08-07**
|
||||
/// (12-editions.md) retired that whole axis — "detection runs at every board open" in every tier
|
||||
/// — so the parameter is *removed* rather than ignored, and the zero-stat promise dies with it:
|
||||
/// every open now pays the same handful of `stat`s Pro's opens always paid, off this same path.
|
||||
///
|
||||
/// 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).
|
||||
///
|
||||
/// - Parameter ledger: the board's write-provenance ledger (`BoardStore.echoes`) — what the
|
||||
/// auto-committer classifies each changed file against. Defaulted to a fresh one so a
|
||||
/// store-less `HistoryStore` still composes: an empty ledger vouches for nothing, which is the
|
||||
/// honest answer for a git state with no session behind it (everything reads foreign, the
|
||||
/// launch-catch-up doctrine).
|
||||
public static func compose(boardRoot: URL, ledger: EchoLedger = EchoLedger()) -> HistoryStore {
|
||||
let mode = BoardGitMode.detect(boardRoot: boardRoot)
|
||||
logger.debug("board opened in git mode \(mode.rawValue, privacy: .public)")
|
||||
return HistoryStore(boardRoot: boardRoot, mode: mode, ledger: ledger)
|
||||
}
|
||||
|
||||
// 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 settings sheet's Git section, in every tier since the
|
||||
/// 2026-08-07 pivot (12-editions.md) — and from nowhere else:
|
||||
/// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo
|
||||
/// under every board it opened. Opt-in is what the pivot deliberately left standing: git leaving
|
||||
/// the paywall widened *who* may ask, never *whether* asking is required.
|
||||
///
|
||||
/// **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". Since the 2026-07-31 split that flow is one
|
||||
/// surface further along — the sheet's Git section becomes its Branch and Commit Identity
|
||||
/// sections, and the popover behind it gains the branch line — but the immediacy is the same. 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 sheet 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
|
||||
// **The commanded mid-session flip, carried through to the engine** (06 ▸ Rules ▸
|
||||
// Detection: "clicking it flips the open board into git mode immediately — the popover
|
||||
// flows straight into the git controls, the first auto-commit follows"). The root commit
|
||||
// has already landed inside `create`, so what `start()` arms here finds a clean tree and
|
||||
// no-ops; what it buys is that the *next* settled change commits, exactly as on a board
|
||||
// that opened in git mode.
|
||||
let committer = GitAutoCommitter(boardRoot: root, ledger: ledger)
|
||||
self.committer = committer
|
||||
autoCommitWiring?(committer)
|
||||
committer.start()
|
||||
// The branch controls appear with the repository they switch branches in — and before
|
||||
// `didAddGit`, which is what wires their seams (`AppModel.wireGitUndo`).
|
||||
switcher = GitBranchSwitcher(boardRoot: root)
|
||||
// Housekeeping too, for the committer's reason: a board that flipped mid-session behaves
|
||||
// like one that opened in git mode. A repository seconds old has a handful of loose
|
||||
// objects and will read below threshold — which is the pass doing its job, not skipping.
|
||||
housekeeper = GitHousekeeper(boardRoot: root)
|
||||
armHousekeeping(beside: committer)
|
||||
// Last, after the mode and the committer: the undo binding reads both.
|
||||
didAddGit?()
|
||||
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
||||
return true
|
||||
case .failure(let failure):
|
||||
// **Inline while the form is up, the banner when it is not** (06, ruled 2026-07-31) — the
|
||||
// answer can outlive the surface that asked for it, and a failure with nowhere to land
|
||||
// would be the silence trap the ruling names.
|
||||
if isFormVisible {
|
||||
lastFailure = failure
|
||||
} else {
|
||||
lastFailure = nil
|
||||
reportFailure?(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 (and when the settings sheet does, whose create control reads the same
|
||||
/// surface). 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: - Commit identity
|
||||
|
||||
/// Reads repo-local config and the derived default into the settings sheet's fields. A no-op
|
||||
/// outside git mode, `refreshBranch()`'s rule.
|
||||
///
|
||||
/// Both reads run off the main actor: one opens a repository, the other asks the system for the
|
||||
/// account and host names.
|
||||
public func refreshIdentity() async {
|
||||
guard mode == .git else { return }
|
||||
let root = boardRoot
|
||||
let read = await Task.detached(priority: .userInitiated) {
|
||||
(
|
||||
repoLocal: GitCommitOperation.repoLocalIdentity(at: root),
|
||||
derived: GitIdentity.derivedDefault()
|
||||
)
|
||||
}.value
|
||||
identityName = read.repoLocal.name ?? ""
|
||||
identityEmail = read.repoLocal.email ?? ""
|
||||
derivedIdentity = read.derived
|
||||
}
|
||||
|
||||
/// **Writes the fields into repo-local `.git/config`** — "the setting *is* the file".
|
||||
///
|
||||
/// An empty value clears its key rather than writing an empty string, which is what the
|
||||
/// placeholder promises: an empty field means the derived default applies. The read afterwards is
|
||||
/// not ceremony — it is how the fields end up showing what the file says rather than what was
|
||||
/// typed at it, which is the only version that survives a foreign edit landing in between.
|
||||
///
|
||||
/// **Refused against an unreadable repository** (06 ▸ Rules, the corrupt-`.git` loud failure:
|
||||
/// "Lanework leaves the repository untouched"). This is the one identity call that *writes*, and
|
||||
/// `.git/config` is the file most likely to be what is wrong with a repository libgit2 will not
|
||||
/// open. Unreachable in practice — the Git tab hosts no setup block on such a board
|
||||
/// (`BoardGitSetupSection.resolve`, empty there) — and gated anyway, because "never touched" is a
|
||||
/// promise about the repository rather than about which surfaces happen to be reachable.
|
||||
public func writeIdentity(name: String, email: String) async {
|
||||
guard mode == .git, !isRepositoryUnreadable else { return }
|
||||
let root = boardRoot
|
||||
identityFailure = nil
|
||||
let outcome = await Task.detached(priority: .userInitiated) {
|
||||
GitCommitOperation.writeRepoLocalIdentity(name: name, email: email, at: root)
|
||||
}.value
|
||||
if case let .failure(failure) = outcome {
|
||||
identityFailure = failure
|
||||
Self.logger.error("identity write failed: \(failure.description, privacy: .public)")
|
||||
}
|
||||
await refreshIdentity()
|
||||
}
|
||||
|
||||
// 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 —
|
||||
/// modes `none`, `repoNested` and `unverifiable` 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
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,46 @@ import CryptoKit
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
// MARK: - Harvested receipts
|
||||
|
||||
/// **One EchoLedger receipt, copied out for a provenance consumer** (02-architecture.md ▸
|
||||
/// Components ▸ EchoLedger; 06-history-undo.md ▸ Interaction with external writers).
|
||||
///
|
||||
/// Relocated 2026-08-08 from `CommitAttribution.swift` with the git excision — the harvest surface
|
||||
/// outlives its git consumer; it is foundation for the deferred foreign-change journal
|
||||
/// (`strategy/01-git-excision.md`).
|
||||
///
|
||||
/// ### Why a copy and not a read
|
||||
///
|
||||
/// The ledger's receipts are **consumed** by the landing reload that classifies them — "one write,
|
||||
/// one echo", which is what buys the announcer its silence. A consumer asking its question a
|
||||
/// debounce later finds every receipt for the user's own card edit already gone; reading the live
|
||||
/// ledger at that point would misattribute the user's own work to a foreign writer, which is the
|
||||
/// one misattribution this whole mechanism exists to prevent.
|
||||
///
|
||||
/// So a consumer harvests at the **close of each write bracket** — the moment a receipt describes
|
||||
/// a completed write and nothing has had a chance to consume it — and keeps its own copy for the
|
||||
/// life of the debounce window. Supersession still works: a later bracket's harvest overwrites the
|
||||
/// same key with the newer hash, exactly as the ledger's own `recordWrite` does.
|
||||
///
|
||||
/// The satisfaction check stays the ledger's rule, re-applied against disk at attribution time, so
|
||||
/// the two races 02 settles land the same way here: byte-identical foreign bytes over a fresh app
|
||||
/// write classify app-mediated, and a foreign edit that misses the hash classifies foreign.
|
||||
public struct HarvestedReceipt: Sendable, Equatable {
|
||||
|
||||
public let receipt: EchoLedger.Receipt
|
||||
|
||||
/// **Whether the write that dropped it was a heal** — the flag 06-history-undo.md (ruled
|
||||
/// 2026-07-29) keys the third commit class on: "a debounce window holding a scheduled heal's
|
||||
/// changes alongside anyone else's splits the heal's paths into their own commit".
|
||||
public let isHeal: Bool
|
||||
|
||||
public init(receipt: EchoLedger.Receipt, isHeal: Bool) {
|
||||
self.receipt = receipt
|
||||
self.isHeal = isHeal
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EchoLedger
|
||||
|
||||
/// **What the app wrote, so a landing reload can tell its own echo from someone else's edit** —
|
||||
|
||||
Reference in New Issue
Block a user