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:
2026-08-08 11:38:16 -04:00
parent adbc5ddd89
commit edaf5be706
27 changed files with 99 additions and 10020 deletions
+31
View File
@@ -936,6 +936,37 @@ enum ChangeNarrator {
return components.prefix(depth).joined(separator: "/") 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: /// **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)' /// "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 /// mechanism: a changed path under `/comments/<uuid>/` composes 'Comment on card title' / 'Edit
-234
View File
@@ -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
}
}
-281
View File
@@ -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
-342
View File
@@ -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()))
}
}
-486
View File
@@ -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)
}
}
-784
View File
@@ -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))
}
}
-158
View File
@@ -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
}
}
}
}
-608
View File
@@ -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
}
}
-213
View File
@@ -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
}
}
-479
View File
@@ -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
}
}
-378
View File
@@ -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 }
}
-120
View File
@@ -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)
}
}
-51
View File
@@ -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]
}
}
}
-441
View File
@@ -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)
}
}
-427
View File
@@ -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: &current)
}
// 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
}
}
-63
View File
@@ -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) }
)
}
}
-448
View File
@@ -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
}
}
+40
View File
@@ -2,6 +2,46 @@ import CryptoKit
import Foundation import Foundation
import Synchronization 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 // MARK: - EchoLedger
/// **What the app wrote, so a landing reload can tell its own echo from someone else's edit** /// **What the app wrote, so a landing reload can tell its own echo from someone else's edit**
File diff suppressed because it is too large Load Diff
-278
View File
@@ -1,278 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **Nearest-`.git`-wins, freshly at every open** (06-history-undo.md Rules Detection) the one
/// question every git surface starts from, pinned against real directories rather than against a
/// mocked filesystem, because what it is *about* is what is on disk.
///
/// The rule has four claims and this file is one test per claim: root wins, an ancestor is nested,
/// neither is `none`, and the answer is re-derived rather than remembered "a board can therefore
/// change mode between opens (e.g. the user ran `git init` in a terminal) the app just reflects
/// what it finds." `BoardGitEntryProbeTests` and `BoardGitModeDenialTests` below pin the
/// 2026-08-06 axis on top of it: "Denial is not absence" a check the sandbox refuses must read as
/// `.unverifiable`, never as `.none`.
// MARK: - Fixtures
/// A `.git` **directory** with a plausible ref inside what `git init` leaves.
private func makeGitDirectory(at parent: URL) throws {
let gitDirectory = parent.appendingPathComponent(".git", isDirectory: true)
try FileManager.default.createDirectory(at: gitDirectory, withIntermediateDirectories: true)
try Data("ref: refs/heads/main\n".utf8).write(to: gitDirectory.appendingPathComponent("HEAD"))
}
/// A `.git` **file** what a linked worktree or a submodule leaves. Still a repository, and the
/// reason detection asks `fileExists` rather than `isDirectory`.
private func makeGitPointerFile(at parent: URL, to target: String) throws {
try Data("gitdir: \(target)\n".utf8).write(to: parent.appendingPathComponent(".git"))
}
/// A board folder inside the fixture, so an *ancestor* can carry the repository.
private func makeSubfolder(_ fixture: WriterFixture, named name: String) throws -> URL {
let url = fixture.root.appendingPathComponent(name, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
// MARK: - Detection
@Suite("Board git mode ▸ detection")
struct BoardGitModeTests {
@Test("A `.git` at the board root is git mode")
func rootRepositoryIsGitMode() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
}
@Test("A `.git` *file* is a repository too — a worktree is not mode none")
func aWorktreePointerIsGitMode() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitPointerFile(at: fixture.root, to: "/somewhere/else/.git/worktrees/board")
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
}
@Test("No `.git` at the root and none above it is mode none")
func aPlainFolderIsModeNone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
}
@Test("A `.git` at an ancestor and none at the root is repo-nested")
func anEnclosingRepositoryIsRepoNested() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
let board = try makeSubfolder(fixture, named: "project/docs/board")
#expect(BoardGitMode.detect(boardRoot: board) == .repoNested)
#expect(BoardGitMode.enclosingRepositoryRoot(above: board)?.standardizedFileURL
== fixture.root.standardizedFileURL)
}
@Test("Nearest wins: a board with its own `.git` inside a repository is git mode, not nested")
func theNearestRepositoryWins() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
let board = try makeSubfolder(fixture, named: "board")
try makeGitDirectory(at: board)
#expect(BoardGitMode.detect(boardRoot: board) == .git)
}
@Test("The walk terminates above a board at the filesystem root, finding nothing")
func theAncestorWalkTerminates() {
// The one hazard this walk has ever had: NSURL-bridged URLs whose
// `deletingLastPathComponent` grows "/.." forever instead of settling at "/". The temp
// directory has no repository above it, so the honest answer is `nil` reached, not hung.
#expect(BoardGitMode.enclosingRepositoryRoot(above: URL(fileURLWithPath: "/")) == nil)
}
// MARK: Freshness
@Test("Detection is re-derived at every open: a board that gains a `.git` opens in git mode next time")
func modeChangesBetweenOpens() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none, "the first open")
// What a user does in a terminal under a closed board.
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git, "the next open reflects what it finds")
}
@Test("And a board that loses its `.git` opens back in mode none")
func modeChangesBackBetweenOpens() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
try FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".git"))
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
}
}
// MARK: - Entry probe classification
/// **The errno-aware probe underneath detection** (06-history-undo.md Rules Detection, "Denial
/// is not absence", ruled 2026-07-31) pinned directly, one test per classification, before the
/// walk that builds on it is asked to prove anything.
///
/// The `denied` cases chmod a real directory to `0o000` tests run unprivileged, so `EACCES` is
/// genuinely reachable this way and restore it with an explicit `defer` declared *after* the
/// fixture's own teardown defer, so it runs first (Swift's LIFO defer order):
/// `BoardDuplicatorTests.aFailedWalkRemovesThePartialSibling` is the precedent this mirrors, so a
/// failed assertion can never leave an unremovable temp directory behind.
@Suite("Board git mode ▸ entry probe")
struct BoardGitEntryProbeTests {
@Test("A `.git` directory probes as exists")
func probesExists() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.probeGitEntry(at: fixture.root) == .exists)
#expect(BoardGitMode.hasGitEntry(at: fixture.root), "the boolean convenience agrees")
}
@Test("A plain folder with no `.git` probes as absent")
func probesAbsent() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(BoardGitMode.probeGitEntry(at: fixture.root) == .absent)
#expect(!BoardGitMode.hasGitEntry(at: fixture.root))
}
@Test("A folder somewhere the sandbox denies traversal probes as denied, not absent")
func probesDenied() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let outer = fixture.root.appendingPathComponent("outer", isDirectory: true)
let inner = outer.appendingPathComponent("inner", isDirectory: true)
try FileManager.default.createDirectory(at: inner, withIntermediateDirectories: true)
// Chmod the *parent*, not the probed folder itself: resolving `inner/.git` needs search
// permission on `outer`, which a plain unix permission bit can deny for the test's own
// unprivileged user exactly as the sandbox denies an ungranted ancestor.
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: outer.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: outer.path) }
#expect(BoardGitMode.probeGitEntry(at: inner) == .denied)
#expect(!BoardGitMode.hasGitEntry(at: inner), "the boolean convenience collapses denied to false, like absent")
}
}
// MARK: - Denial-aware detection
/// **The walk semantics denial adds** (06 Rules Detection): a denied ancestor never ends the
/// walk early, because a farther ancestor's `.git` still makes repo-nested certain; only a walk that
/// finds nothing at all *and* saw a denial along the way reads `.unverifiable`.
@Suite("Board git mode ▸ denial-aware detection")
struct BoardGitModeDenialTests {
@Test("A denied board-root probe is unverifiable outright — the walk never runs")
func deniedRootProbeIsUnverifiable() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let boardRoot = fixture.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: boardRoot.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: boardRoot.path) }
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
}
@Test("A denied ancestor with nothing found anywhere else reads unverifiable")
func deniedAncestorWithNothingFoundIsUnverifiable() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let blocked = fixture.root.appendingPathComponent("blocked", isDirectory: true)
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
let walk = BoardGitMode.ancestorWalk(above: boardRoot)
#expect(walk.root == nil)
#expect(walk.sawDenial)
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
}
@Test("A denied nearer ancestor never hides a `.git` on a farther one — repo-nested is certain")
func deniedAncestorWithARepositoryFartherUpIsRepoNested() throws {
// **A note on what chmod can and cannot simulate**: the sandbox denies a *specific path*
// independently of the filesystem's own permission bits an ancestor above the board's
// grant can be denied while the board root itself, inside the grant, stays fully readable.
// POSIX `chmod`, in contrast, cascades: removing search permission from a real ancestor
// directory denies resolving *everything* beneath it, board root included, which is a
// strictly stronger (and still individually honest) denial than the sandbox's. So this test
// proves the walk's own claim directly `ancestorWalk(above:)` never touches `boardRoot`
// itself, only the candidates above it, and is unaffected by that cascade.
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
let blocked = fixture.root.appendingPathComponent("blocked", isDirectory: true)
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
// "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."
let walk = BoardGitMode.ancestorWalk(above: boardRoot)
#expect(walk.root?.standardizedFileURL == fixture.root.standardizedFileURL)
#expect(walk.sawDenial, "the denial is still recorded, even though it didn't decide the outcome")
// `detect(boardRoot:)` itself reads `.unverifiable` here not `.repoNested` but for the
// cascade reason above, not because the walk's certainty claim is false: `blocked` sits
// between the filesystem root and `boardRoot`, so chmoding it also denies **`boardRoot`'s
// own** `.git` probe, and `detect` answers that denial before the ancestor walk ever runs
// (06 Rules: "probe the board root's `.git` first denied `.unverifiable`"). A real
// sandboxed board, whose own root sits inside the grant, would not hit this path its own
// probe would succeed and the walk above is what would then run and find `.repoNested`.
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable)
}
@Test("All-clean paths are unaffected: none, git, and repo-nested still read as before")
func cleanPathsAreUnaffected() throws {
let plain = try WriterFixture()
defer { plain.tearDown() }
try plain.item("", Item.board)
#expect(BoardGitMode.detect(boardRoot: plain.root) == .none)
let gitBoard = try WriterFixture()
defer { gitBoard.tearDown() }
try makeGitDirectory(at: gitBoard.root)
#expect(BoardGitMode.detect(boardRoot: gitBoard.root) == .git)
let nested = try WriterFixture()
defer { nested.tearDown() }
try makeGitDirectory(at: nested.root)
let board = try makeSubfolder(nested, named: "project/docs/board")
#expect(BoardGitMode.detect(boardRoot: board) == .repoNested)
}
}
+25 -5
View File
@@ -16,6 +16,25 @@ import Testing
/// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider /// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider
/// grammar is `HistoryProviderTests`'. /// grammar is `HistoryProviderTests`'.
// MARK: - A substrate that keeps no steps
/// **A provider that retires every step on arrival** the minimal fake `HistoryProviding.backedContent`'s
/// own doc names ("a substrate that keeps no steps... and a test fake's"). `register(_:)` runs the
/// step's retirement immediately and keeps nothing, which is what makes "purge rides the close flush"
/// true over such a substrate with no tier check anywhere in the call path.
@MainActor
private final class NoBackingHistoryProvider: HistoryProviding {
var canUndo = false
var canRedo = false
var undoActionName: String?
var redoActionName: String?
func register(_ step: HistoryStep) { step.retirement?.run() }
func undo() {}
func redo() {}
func clear() {}
}
// MARK: - The window under test // MARK: - The window under test
@MainActor @MainActor
@@ -735,7 +754,7 @@ struct CardSessionPurgeTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (window, card) = try await closedWithADeletedComment(fixture) let (window, card) = try await closedWithADeletedComment(fixture)
// `AppModel`'s teardown, and the add-git swap, both do exactly this. // `AppModel`'s teardown does exactly this.
window.board.clear() window.board.clear()
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
} }
@@ -823,10 +842,11 @@ struct CardSessionPurgeTests {
try fixture.item(path, commentText()) try fixture.item(path, commentText())
let window = try makeWindow(fixture) let window = try makeWindow(fixture)
window.comments.reload() window.comments.reload()
// The git provider drops every registration (its substrate is the commit trail) and retires // A substrate that keeps no steps drops every registration and retires it on the way past
// it on the way past which is what makes "purge rides the close flush" true on Pro with no // which is what makes "purge rides the close flush" true structurally, with no tier check at
// tier check at any call site. Bound directly here: `register` reads no repository. // any call site. `history` is weak, so the fake is held locally for the assertion's duration.
window.store.history = GitHistoryProvider(boardRoot: fixture.root) let noBackingProvider = NoBackingHistoryProvider()
window.store.history = noBackingProvider
window.comments.delete(ItemID(rawValue: CommentIdent.one)) window.comments.delete(ItemID(rawValue: CommentIdent.one))
await window.session.endSession() await window.session.endSession()
+3 -3
View File
@@ -46,7 +46,7 @@ private func files(under root: URL) -> [String: Data] {
return found return found
} }
/// What `GitCommitOperation.surveyChangedPaths` would have reported for these two trees a plain /// The changed-path list a repository survey would have reported for these two trees a plain
/// content comparison, since nothing here has a repository to ask. /// content comparison, since nothing here has a repository to ask.
private func changedPaths(from before: URL, to after: URL) -> [ChangedPath] { private func changedPaths(from before: URL, to after: URL) -> [ChangedPath] {
let old = files(under: before) let old = files(under: before)
@@ -113,7 +113,7 @@ private func compose(
// Resolved the way a flush resolves it off the "after" tree, through the committer's own // Resolved the way a flush resolves it off the "after" tree, through the committer's own
// reader rather than hand-assembled, for the same reason both snapshots are loaded rather // reader rather than hand-assembled, for the same reason both snapshots are loaded rather
// than built: a map the flush could never produce would prove nothing about the flush. // than built: a map the flush could never produce would prove nothing about the flush.
commentTimestamps: GitAutoCommitter.commentTimestamps(for: paths, boardRoot: after.root) commentTimestamps: ChangeNarrator.commentTimestamps(for: paths, boardRoot: after.root)
)) ))
} }
@@ -993,6 +993,6 @@ struct CommitMessageRootCommitTests {
snapshot: try fixture.snapshot(), snapshot: try fixture.snapshot(),
previousSnapshot: nil previousSnapshot: nil
)) ))
#expect(message == GitRepository.initialCommitSubject) #expect(message == ChangeNarrator.rootSubject)
} }
} }
-44
View File
@@ -549,26 +549,6 @@ struct DuplicateIdentityDetectionTests {
#expect(result.duplicateIdentities.map(\.winner) == ["\(Ident.lane1)/\(Dup.lower)"]) #expect(result.duplicateIdentities.map(\.winner) == ["\(Ident.lane1)/\(Dup.lower)"])
} }
/// The same straddle with git in the picture: an -drag restore leaves the *tracked* path in the
/// trash, so the ghost is the one history knows and still loses.
@Test("A tracked ghost still loses to the untracked live card")
func trackedGhostStillLoses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(live, date(1000))
try setBirth(ghost, date(0))
let ranker = BoardLoader.IdentityHistoryRanker { path in
path == ".trash/\(Dup.lower)" ? 1 : nil
}
let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: ranker)
#expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"])
#expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1)
}
/// **The same preference governs a trashed lane sharing a live lane's UUID** the ruling says so /// **The same preference governs a trashed lane sharing a live lane's UUID** the ruling says so
/// explicitly, and it needs no special case: a trashed lane is a `.trash/` entry like any other. /// explicitly, and it needs no special case: a trashed lane is a `.trash/` entry like any other.
@Test("A trashed lane loses to the live lane sharing its UUID") @Test("A trashed lane loses to the live lane sharing its UUID")
@@ -706,30 +686,6 @@ struct DuplicateIdentityDetectionTests {
]) ])
} }
// MARK: The history seam
/// The seam base can never fill: an injected ranker decides the winner ahead of the birth dates.
@Test("An injected history ranker outranks the filesystem")
func historyRankerDecides() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login"))
try setBirth(original, date(0))
try setBirth(copy, date(1000))
// Without a ranker the older folder wins
#expect(try BoardLoader.load(boardRoot: fixture.root).duplicateIdentities.map(\.path)
== ["\(Ident.lane2)/\(Dup.lower)"])
// and with one that says the newer path entered history first, it does not.
let ranker = BoardLoader.IdentityHistoryRanker { path in
path == "\(Ident.lane2)/\(Dup.lower)" ? 1 : nil
}
#expect(try BoardLoader.load(boardRoot: fixture.root, historyRanker: ranker).duplicateIdentities.map(\.path)
== ["\(Ident.lane1)/\(Dup.lower)"])
}
/// Three copies of one card: two are withheld, one renders and the notice folds. /// Three copies of one card: two are withheld, one renders and the notice folds.
@Test("Three occurrences leave one standing") @Test("Three occurrences leave one standing")
func threeCopiesLeaveOne() throws { func threeCopiesLeaveOne() throws {
-365
View File
@@ -1,365 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **Where the app's commits get their author from** (06-history-undo.md Interaction with external
/// writers "Where the user's git identity comes from"): repo-local `.git/config` when it names
/// one, the derived `Full Name <shortname@hostname>` default when it doesn't.
///
/// Both halves are pure functions here on purpose. The derivation takes its three strings as
/// arguments rather than reading the machine, so the *shape* is provable on any machine including
/// one whose account has no full name, which is the case the fallbacks exist for. And the config
/// read is a parse over text, so the format's edges (comments, quoting, subsections, a `[user]`
/// section that never appears) are pinned without a repository. The write side (`GitConfigFile
/// .applying`) is pinned the same way: a pure function over text, so every rule in 06's "Writes
/// append, reads take the last" paragraph is provable without a repository either.
@Suite("Git identity ▸ the derived default")
struct GitIdentityDerivationTests {
@Test("The shape is the account's full name plus shortname@hostname")
func theDerivedShape() {
let identity = GitIdentity.derived(fullName: "Ada Lovelace", accountName: "ada", hostName: "analytical.local")
#expect(identity.name == "Ada Lovelace")
#expect(identity.email == "[email protected]")
}
@Test("An account with no full name falls back to its short name rather than committing as \"\"")
func anEmptyFullNameFallsBack() {
let identity = GitIdentity.derived(fullName: " ", accountName: "ada", hostName: "analytical.local")
#expect(identity.name == "ada")
#expect(identity.email == "[email protected]")
}
@Test("Characters an address may not carry are collapsed, not passed to libgit2")
func addressComponentsAreSanitized() {
// libgit2 refuses a signature carrying a space or an angle bracket outright the commit
// fails rather than looking odd so this is a correctness fallback, not cosmetics.
let identity = GitIdentity.derived(
fullName: "Ada Lovelace",
accountName: "ada lovelace",
hostName: "Ada's <Mac>.local"
)
#expect(!identity.email.contains(" "))
#expect(!identity.email.contains("<"))
#expect(!identity.email.contains(">"))
#expect(identity.email == "[email protected]")
}
@Test("A machine with no name reads localhost, and an account with none reads user")
func emptyComponentsHaveHonestFallbacks() {
let identity = GitIdentity.derived(fullName: "", accountName: "", hostName: "")
#expect(identity.name == "Lanework")
#expect(identity.email == "user@localhost")
}
@Test("A trailing dot on a fully-qualified host name is dropped")
func aTrailingDotIsDropped() {
let identity = GitIdentity.derived(fullName: "Ada", accountName: "ada", hostName: "host.example.com.")
#expect(identity.email == "[email protected]")
}
@Test("This machine's derived default is well-formed, whatever this machine is called")
func theMachineDefaultIsWellFormed() {
let identity = GitIdentity.derivedDefault()
#expect(!identity.name.isEmpty)
#expect(identity.email.contains("@"))
#expect(!identity.email.contains(" "))
}
}
@Suite("Git identity ▸ repo-local config wins")
struct GitConfigFileTests {
@Test("A `[user]` section supplies both halves")
func bothKeysAreRead() {
let text = """
[core]
\trepositoryformatversion = 0
[user]
\tname = Ada Lovelace
\temail = ada@example.com
"""
let identity = GitConfigFile.identity(inConfigText: text)
#expect(identity.name == "Ada Lovelace")
#expect(identity.email == "[email protected]")
}
@Test("Config wins over the derived default, key by key")
func resolutionPrefersConfigPerKey() {
let derived = GitIdentity(name: "Machine Owner", email: "[email protected]")
let both = GitIdentity.resolve(repoLocal: (name: "Ada", email: "[email protected]"), derived: derived)
#expect(both == GitIdentity(name: "Ada", email: "[email protected]"))
// Half-configured is a real state it is what a `git config user.email` typo leaves and
// git resolves each key on its own.
let nameOnly = GitIdentity.resolve(repoLocal: (name: "Ada", email: nil), derived: derived)
#expect(nameOnly == GitIdentity(name: "Ada", email: "[email protected]"))
let neither = GitIdentity.resolve(repoLocal: (name: nil, email: " "), derived: derived)
#expect(neither == derived, "a blank value is not a value")
}
@Test("Comments and quoting are read the way git reads them")
func theParseHandlesTheFormatsEdges() {
let text = """
# a comment
; another
[user]
\tname = "Ada # Lovelace"
\temail = ada@example.com # trailing comment
"""
let identity = GitConfigFile.identity(inConfigText: text)
#expect(identity.name == "Ada # Lovelace", "a `#` inside quotes is content")
#expect(identity.email == "[email protected]", "an unquoted trailing comment is not")
}
@Test("Reads take the last plain-section value, and no subsection's")
func readsTakeTheLastPlainSectionValue() {
// **Writes append, reads take the last** (06 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."
let appended = """
[user]
\tname = Old Ada
\temail = old@example.com
[user]
\tname = New Ada
\temail = new@example.com
"""
#expect(GitConfigFile.identity(inConfigText: appended).name == "New Ada")
#expect(GitConfigFile.identity(inConfigText: appended).email == "[email protected]")
// A subsection is a *different key* in git's model `user.work.name`, not `user.name` so
// it is not an answer to this question however late in the file it sits. Signing the user's
// commits with an identity they filed under a name this app never asked about would be the
// worse error, and 06 says plain-section for exactly that reason.
let subsectioned = """
[user]
\tname = Ada
\temail = ada@example.com
[user "work"]
\tname = Work Ada
\temail = ada@work.example
"""
#expect(GitConfigFile.identity(inConfigText: subsectioned).name == "Ada")
#expect(GitConfigFile.identity(inConfigText: subsectioned).email == "[email protected]")
// A file with *only* a subsection names nobody, and falls through to the derived default.
let onlySubsection = "[user \"work\"]\n\tname = Work Ada\n\temail = [email protected]\n"
#expect(GitConfigFile.identity(inConfigText: onlySubsection) == (nil, nil))
}
@Test("A config with no `[user]` section, or no config at all, names nobody")
func absentConfigNamesNobody() throws {
let empty = GitConfigFile.identity(inConfigText: "[core]\n\tbare = false\n")
#expect(empty.name == nil)
#expect(empty.email == nil)
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let missing = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
#expect(missing.name == nil)
#expect(missing.email == nil)
}
@Test("The file on disk is what is read — the board root's own `.git/config`")
func theFileIsRead() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.file(".git/config", Data("[user]\n\tname = Ada\n\temail = [email protected]\n".utf8))
let identity = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
#expect(identity.name == "Ada")
#expect(identity.email == "[email protected]")
}
}
/// **The write side** (06-history-undo.md Interaction with external writers, "Where the user's
/// git identity comes from"; the clear rule ruled 2026-08-06): a set is append-only it never edits
/// or deletes an existing line, appending one new plain `[user]` section instead, even over an
/// already-populated file and a clear is the one sanctioned in-place edit, deleting every matching
/// line in every plain `[user]` section and dropping any header left empty. Both halves resolve
/// against the current parse first, so a call that would change nothing is a true no-op.
@Suite("Git identity ▸ writing repo-local config")
struct GitConfigFileWriteTests {
@Test("A set never edits an existing line — it appends a new section, and the old line survives verbatim")
func setAppendsRatherThanEditing() {
// Weird indentation and an inline comment: exactly the shape a set must leave untouched.
let original = "[user]\n name = Old Name # keep me, weird spacing and all\n"
let written = GitConfigFile.applying(name: "New Name", email: "[email protected]", to: original)
#expect(
written.contains(" name = Old Name # keep me, weird spacing and all"),
"the original line survives byte-for-byte"
)
#expect(written.contains("\tname = New Name"), "the set lands in a freshly appended section")
#expect(written.contains("\temail = [email protected]"))
#expect(
written.components(separatedBy: "[user]").count - 1 == 2,
"a second `[user]` header was appended, not merged into the first"
)
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "New Name", "last-wins reading is what makes the appended value win")
#expect(read.email == "[email protected]")
}
@Test("Setting a key to its already-current value is a true no-op — byte-identical, no growth")
func settingTheCurrentValueIsANoOp() {
let text = "[user]\n\tname = Ada Lovelace\n\temail = [email protected]\n"
let written = GitConfigFile.applying(name: "Ada Lovelace", email: "[email protected]", to: text)
#expect(written == text)
// Whitespace around an unchanged value still resolves to the same target, so it is still a
// no-op the comparison is on trimmed content, not on the caller's exact bytes.
let paddedTarget = GitConfigFile.applying(name: " Ada Lovelace ", email: " [email protected] ", to: text)
#expect(paddedTarget == text)
}
@Test("Clearing an absent key returns byte-identical text")
func clearingAnAbsentKeyIsANoOp() {
let text = "[user]\n\tname = Ada\n"
let written = GitConfigFile.applying(name: "Ada", email: nil, to: text)
#expect(written == text)
}
@Test("A clear deletes every occurrence across two plain `[user]` sections, and reads back nil")
func clearDeletesEveryOccurrence() {
let text = """
[user]
\temail = ada@one.example
[core]
\tbare = false
[user]
\temail = ada@two.example
"""
// `name` is already absent everywhere, so passing `nil` for it is a no-op; only `email` is
// genuine pending work, and it must be cleared from *both* plain sections, not just the last.
let written = GitConfigFile.applying(name: nil, email: "", to: text)
#expect(!written.contains("email"), "no occurrence survives, in either section")
#expect(written.contains("\tbare = false"), "an unrelated section is untouched")
#expect(GitConfigFile.identity(inConfigText: written).email == nil)
}
@Test("Clearing both keys drops every emptied `[user]` header, but keeps one that still has signingkey")
func clearingDropsOnlyTrulyEmptyHeaders() {
let text = """
[user]
\tname = Ada
[user]
\temail = ada@example.com
[user]
\tname = Ada C
\temail = adac@example.com
\tsigningkey = ABC123
"""
let written = GitConfigFile.applying(name: "", email: nil, to: text)
#expect(!written.contains("name ="), "no name line remains anywhere")
#expect(!written.contains("email ="), "no email line remains anywhere")
#expect(written.contains("\tsigningkey = ABC123"), "a key this app has no opinion about survives")
#expect(
written.components(separatedBy: "[user]").count - 1 == 1,
"the two now-empty headers are dropped; the section keeping signingkey keeps its header"
)
#expect(GitConfigFile.identity(inConfigText: written) == (nil, nil))
}
@Test("A combined set-and-clear call clears in place, then appends the set section")
func combinedSetAndClear() {
let original = """
[user]
\tname = Old Name
\temail = old@example.com
"""
let written = GitConfigFile.applying(name: "New Name", email: "", to: original)
#expect(written.contains("\tname = Old Name"), "the set never deletes the line it is replacing")
#expect(written.contains("\tname = New Name"), "the set lands in an appended section")
#expect(!written.contains("email"), "the clear deletes the email line in place, nothing appended for it")
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "New Name")
#expect(read.email == nil)
}
@Test("A `[user \"work\"]` subsection is untouched by a set or a clear, and never leaks into a read")
func subsectionsAreUntouchable() {
let original = """
[user "work"]
\tname = Work Ada
\temail = ada@work.example
[user]
\tname = Home Ada
\temail = ada@home.example
"""
#expect(GitConfigFile.identity(inConfigText: original).name == "Home Ada", "the subsection is not read")
let cleared = GitConfigFile.applying(name: "", email: "", to: original)
#expect(cleared.contains("[user \"work\""), "the subsection header survives")
#expect(cleared.contains("\tname = Work Ada"), "the subsection's own keys are untouched by a clear")
#expect(cleared.contains("\temail = [email protected]"))
#expect(!cleared.contains("[user]"), "the plain section is what a clear may empty out")
#expect(GitConfigFile.identity(inConfigText: cleared) == (nil, nil), "the subsection never leaks into a read")
let written = GitConfigFile.applying(name: "New Home Ada", email: "[email protected]", to: original)
#expect(written.contains("[user \"work\""), "the subsection header survives a set too")
#expect(written.contains("\tname = Work Ada"), "the subsection's own keys are untouched by a set")
#expect(written.contains("\tname = Home Ada"), "the old plain section survives verbatim — sets never edit")
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "New Home Ada", "the appended section wins by last-wins, never the subsection")
#expect(read.email == "[email protected]")
}
@Test("Writing into empty text creates just the new `[user]` section")
func writesIntoAnEmptyConfig() {
let written = GitConfigFile.applying(name: "Ada Lovelace", email: "[email protected]", to: "")
#expect(written == "[user]\n\tname = Ada Lovelace\n\temail = [email protected]\n")
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "Ada Lovelace")
#expect(read.email == "[email protected]")
}
@Test("Trailing-newline shape: a clear preserves it, a set's append normalizes it")
func trailingNewlineRoundTrip() {
// Clearing is a pure line deletion it must not add a trailing newline that was never there.
let withoutTrailingNewline = "[user]\n\tname = Ada\n\temail = [email protected]"
let clearedNoTrailingNewline = GitConfigFile.applying(name: "Ada", email: nil, to: withoutTrailingNewline)
#expect(clearedNoTrailingNewline == "[user]\n\tname = Ada", "no trailing newline was introduced")
// ...and must not drop one that was.
let withTrailingNewline = "[user]\n\tname = Ada\n\temail = [email protected]\n[core]\n\tbare = false\n"
let clearedWithTrailingNewline = GitConfigFile.applying(name: "Ada", email: nil, to: withTrailingNewline)
#expect(clearedWithTrailingNewline.hasSuffix("\tbare = false\n"), "the file's own trailing newline survives")
// A set's append always lands the current code's shape (blank-line separator, one trailing
// newline) whether or not the original file ended in one.
let appendedNoTrailingNewline = GitConfigFile.applying(name: "Ada", email: "[email protected]", to: "[core]\n\tbare = false")
let appendedWithTrailingNewline = GitConfigFile.applying(name: "Ada", email: "[email protected]", to: "[core]\n\tbare = false\n")
#expect(appendedNoTrailingNewline == "[core]\n\tbare = false\n\n[user]\n\tname = Ada\n\temail = [email protected]\n")
#expect(appendedWithTrailingNewline == appendedNoTrailingNewline, "the trailing-newline state of the input doesn't change the appended shape")
}
}
-625
View File
@@ -1,625 +0,0 @@
import Foundation
import SwiftGitX
import Testing
@testable import Kanban
/// **The board's git state** (06-history-undo.md Rules; 02-architecture.md Components
/// HistoryStore) composed for every session, detected at open, and changed afterwards by
/// exactly one thing. It was "composed under the tier" until PIVOT 2026-08-07 (12-editions.md git
/// left the paywall); the tier axis is gone from composition and from everything below it.
///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with
/// bundled libgit2 and no external git dependency", and a fixture faked out of hand-written files
/// could not tell whether that happened. Nothing in this file shells out to `git` there is no
/// `/usr/bin/git` in the promise this feature makes, so there is none in its tests either.
// MARK: - Fixtures
/// A board with one lane and one card small, and enough for a tree with three `index.md`s in it.
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
return fixture
}
/// A `.git` that is not a repository a directory with a plausible `HEAD` in it. Enough for
/// *detection*, which asks the filesystem one question, and deliberately not enough for libgit2,
/// which is how a test can tell the two apart.
private func plantGitDirectory(in fixture: WriterFixture, under parent: String = "") throws {
let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
}
/// A second (third, fourth) commit, made the way an external writer makes one SwiftGitX directly,
/// not through the app, which has no commit surface until the auto-commit card.
private func commitEverything(at boardRoot: URL, message: String) throws {
let repository = try Repository.open(at: boardRoot)
try repository.add(paths: [])
_ = try repository.commit(message: message)
}
/// Bytes and mtimes of everything under a subtree `UntouchedGitTests`' instrument, in the shape
/// this file needs it: what proves that *reading* a board's mode touched nothing.
private struct SubtreeEntry: Equatable {
let path: String
let data: Data?
let modified: Date
}
private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] {
let base = root.appendingPathComponent(".git", isDirectory: true)
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: base.path) else { return [] }
var entries: [SubtreeEntry] = []
for case let relative as String in walker {
let url = base.appendingPathComponent(relative)
let attributes = try manager.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else { continue }
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
entries.append(SubtreeEntry(
path: relative,
data: isDirectory ? nil : try Data(contentsOf: url),
modified: modified
))
}
return entries.sorted { $0.path < $1.path }
}
/// **A `.git` file aimed at nothing** the worktree/submodule pointer shape (`gitdir: `), which
/// detection reads as a repository (presence is presence, 06 Rules Detection) and libgit2 cannot
/// open, because the directory it names is not there.
private func plantDanglingGitPointer(in fixture: WriterFixture) throws {
let target = fixture.root.appendingPathComponent("nowhere/.git/worktrees/board").path
try fixture.file(".git", Data("gitdir: \(target)\n".utf8))
}
/// **A SHA-256 repository, by hand** the layout libgit2 validates, plus the two config keys
/// `git init --object-format=sha256` writes (06 Repository hygiene: "SHA-256 repositories are
/// unsupported, safely an adopted SHA-256 repo the engine cannot open takes the corrupt-repo
/// loud-failure path").
///
/// Built by hand rather than by `git init --object-format=sha256` for the file's standing reason:
/// there is no `/usr/bin/git` in this feature's promise, so there is none in its tests. What makes
/// the fixture honest is that nothing here is a mock the bytes are the ones git writes, and the
/// refusal is libgit2's own.
private func plantSHA256Repository(in fixture: WriterFixture) throws {
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
try fixture.file(".git/objects/info/.keep", Data())
try fixture.file(".git/refs/heads/.keep", Data())
try fixture.file(".git/config", Data("""
[core]
\trepositoryformatversion = 1
\tbare = false
[extensions]
\tobjectformat = sha256
""".utf8))
}
// MARK: - Composition
@MainActor
@Suite("HistoryStore ▸ composition and open-time detection")
struct HistoryStoreCompositionTests {
@Test("Composition takes no tier: a board carrying a repository opens in git mode, full stop")
func compositionIsUnconditional() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
// **PIVOT 2026-08-07** (12-editions.md git left the paywall). This was the tier gate's own
// test, and it read the other way: `compose(boardRoot:tier: .free)` answered `nil`, so a free
// session had no git state to consult and never stat'ed a `.git` (the inert posture, made
// structural). The gate is gone the parameter with it and detection now runs on this
// board for every session there is, which is what the assertion below says by having no tier
// to name.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git, "the `.git` at the root is live, not inert")
}
@Test("A plain board is mode none — and opening one never creates a repository")
func aPlainBoardIsModeNone() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none)
// "No silent auto-init, ever" (06 Rules) the deliberate pivot from the pathfinder, which
// initialized a repository under every board it opened. Composing twice is the whole test:
// two opens, no repository.
_ = HistoryStore.compose(boardRoot: fixture.root)
#expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in")
}
@Test("A board whose root already has a repository opens in git mode, silently")
func adoptionNeedsNoStep() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own
// add-git in a previous session), and the repository is simply there.
let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit())
let afterInit = try #require(GitRepository.headCommit(at: fixture.root))
let before = try snapshotGitDirectory(fixture.root)
// The next open. Adoption is not init: no dialog, no confirmation, no second step the mode
// is simply what the filesystem says, and it says git.
let second = HistoryStore.compose(boardRoot: fixture.root)
#expect(second.mode == .git)
// And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes.
// Composition is a `stat`, not an operation.
#expect(GitRepository.headCommit(at: fixture.root)?.oid == afterInit.oid)
#expect(try snapshotGitDirectory(fixture.root) == before)
}
@Test("A board nested inside a repository opens repo-nested")
func nestedBoardsAreDetectedAsNested() throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try plantGitDirectory(in: outer)
let boardRoot = outer.root.appendingPathComponent("docs/board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(git.mode == .repoNested)
}
@Test("Mode is an open-time fact: a `.git` appearing mid-session does not flip the open board")
func noMidSessionDiscoveredFlip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none)
// What a `git init` in a terminal under an open board does which is nothing, until the
// next open (06 Rules: "the running session keeps its mode, and the watcher does not scan
// for `.git` appearing").
try plantGitDirectory(in: fixture)
#expect(git.mode == .none, "the open session keeps the mode it composed with")
let nextOpen = HistoryStore.compose(boardRoot: fixture.root)
#expect(nextOpen.mode == .git, "and the next open reflects what it finds")
}
}
// MARK: - The unreadable repository
/// **A `.git` that isn't a valid repository still reads as git mode and fails loudly**
/// (06-history-undo.md Rules, ruled 2026-07-31).
///
/// The probe is `GitRepository.canOpen(at:)` the same `Repository.open` every read in that file
/// makes run at composition, seeding the committer's pause so the whole git surface is held from
/// the first moment rather than from the first debounce. Every fixture here is a real shape from the
/// wild: a half-made `.git`, a worktree pointer aimed at nothing, and a SHA-256 repository this
/// engine has no support for.
@MainActor
@Suite("HistoryStore ▸ the unreadable repository")
struct HistoryStoreUnreadableRepositoryTests {
@Test("A repository that opens reads readable, and holds nothing")
func aValidRepositoryIsReadable() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit())
// The next open, which is where the probe actually runs.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
#expect(!git.isRepositoryUnreadable)
#expect(git.committer?.pause == nil)
#expect(GitRepository.canOpen(at: fixture.root))
}
@Test("A corrupt `.git` stays git mode, reads unreadable, and holds the surface from the first moment")
func aCorruptGitDirectoryIsUnreadable() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// A `.git` with nothing in it but a plausible HEAD: enough for detection, which asks the
// filesystem one question, and not a repository at all to libgit2.
try plantGitDirectory(in: fixture)
let git = HistoryStore.compose(boardRoot: fixture.root)
// **Never a fall to mode none** "detection is presence-shaped a corrupt or unopenable
// repo never falls to mode none", which is what keeps add-git from ever being offered
// against an existing `.git` ("init into a repairable repo is exactly the never-mutate
// hazard").
#expect(git.mode == .git)
#expect(git.isRepositoryUnreadable)
#expect(!GitRepository.canOpen(at: fixture.root))
// The pause is seeded at *detection*, before anything has been attempted: the surface is
// held and the banner is raised at the open rather than a debounce later.
#expect(git.committer?.pause == .unreadable)
#expect(git.committer?.lastFailure == nil, "a pause is not a failure")
// And the one operation that could make it worse is refused, whatever the mode read.
#expect(await git.addGit() == false)
}
@Test("A worktree pointer aimed at nothing reads unreadable — the file shape, not just the directory one")
func aDanglingPointerIsUnreadable() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantDanglingGitPointer(in: fixture)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence")
#expect(git.isRepositoryUnreadable)
}
@Test("A SHA-256 repository takes the same path, by construction")
func aSHA256RepositoryIsUnreadable() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantSHA256Repository(in: fixture)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
// 06 Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the
// corrupt-repo loud-failure path never a silent fall to mode-none".
#expect(git.isRepositoryUnreadable)
}
@Test("Probing an unreadable repository touches nothing")
func theProbeIsARead() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let before = try snapshotGitDirectory(fixture.root)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.isRepositoryUnreadable)
// "Lanework leaves the repository untouched" the same bytes and the same mtimes, on the
// one path where a repair instinct would be most tempting.
#expect(try snapshotGitDirectory(fixture.root) == before)
}
@Test("The identity write is refused against a repository the app cannot open")
func identityWritesAreRefused() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let git = HistoryStore.compose(boardRoot: fixture.root)
await git.writeIdentity(name: "Ada", email: "[email protected]")
#expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open")
#expect(git.identityFailure == nil, "and nothing was attempted, so there is nothing to report")
}
/// The seam every git operation consults before it runs (`GitCommitOperation.reading`), asked
/// directly: one word is what holds the auto-commit flush, skips housekeeping, disables Undo/Redo
/// and the branch controls, and defers the interrupted-operation recovery.
@Test("The repository reading reports the pause every operation gates on")
func theReadingReportsThePause() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let reading = GitCommitOperation.reading(at: fixture.root)
#expect(reading.pause == .unreadable)
#expect(!reading.isUnborn)
#expect(!reading.isIndexLocked)
// Optional work simply does not happen (06 Repository hygiene: skipped under a pause).
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held))
// And the app never aborts its own leftover against a repository it cannot open the stamp
// is kept, not cleared, so the leftover stays recognizable as this app's.
let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: nil)
#expect(GitOperationRecovery.decide(stamp: stamp, pause: .unreadable) == .nothingToDo)
#expect(GitOperationRecovery.decide(stamp: stamp, pause: .merge) == .abort(stamp),
"every other pause still means the app's own leftover")
}
}
// MARK: - Add git
@MainActor
@Suite("HistoryStore ▸ add-git")
struct HistoryStoreAddGitTests {
@Test("Add-git initializes a repository at the board root and commits the whole tree")
func addGitInitializesAndCommits() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
#expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install")
#expect(git.mode == .git, "the one commanded mid-session flip")
let head = try #require(GitRepository.headCommit(at: fixture.root))
// "The root commit has its own subject" (06 Rules Abnormal repo states) never a folded
// diff-from-empty, because there is nothing to diff against and forty Adds would bury it.
#expect(head.subject == "Initial board state")
#expect(head.parentCount == 0, "it is the root commit")
// The whole tree, not a hand-picked set: the board, the lane, the card.
let tracked = GitRepository.trackedPaths(at: fixture.root)
#expect(tracked.contains("index.md"))
#expect(tracked.contains("\(Ident.lane1)/index.md"))
#expect(tracked.contains("\(Ident.lane1)/\(Ident.card1)/index.md"))
}
@Test("Add-git seeds a `.gitignore` into the initial commit")
func addGitSeedsTheIgnoreFile() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The one file the app ever writes into a board because of git, and the one moment it writes
// it (06 Repository hygiene). `RepositoryHygieneTests` carries the rest of the rule the
// untouched existing file, the `.DS_Store` that never enters history, adoption seeding
// nothing; here it is only the fact that add-git's tree includes it.
#expect(fixture.exists(".gitignore"))
#expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
}
@Test("The root commit is authored by the derived default when the repo names nobody")
func theRootCommitCarriesTheDerivedIdentity() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
let derived = GitIdentity.derivedDefault()
let head = try #require(GitRepository.headCommit(at: fixture.root))
#expect(head.authorName == derived.name)
#expect(head.authorEmail == derived.email)
}
@Test("The branch is deterministic, and the popover's display line reads it")
func theBranchIsMainAndReadable() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// Deterministic rather than inherited: libgit2's initial branch comes from an
// `init.defaultBranch` this app cannot read in the sandbox (`GitRepository.initialBranchName`).
#expect(git.branch == "main")
#expect(GitRepository.branchName(at: fixture.root) == "main")
await git.refreshBranch()
#expect(git.branch == "main")
}
@Test("An unborn HEAD still has a branch name — a repository with no commits is normal git mode")
func anUnbornHeadIsNormal() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// `git init` and nothing else: the shape an adopted repository can genuinely be in
// (06 Rules Abnormal repo states: "an unborn HEAD is normal git mode").
_ = try Repository.create(at: fixture.root)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
#expect(GitRepository.branchName(at: fixture.root) != nil)
#expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine")
}
@Test("Add-git refuses a board that already has a repository, and changes nothing")
func addGitRefusesAnAdoptedBoard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit())
let head = try #require(GitRepository.headCommit(at: fixture.root))
let second = HistoryStore.compose(boardRoot: fixture.root)
#expect(await second.addGit() == false, "there is nothing to add")
#expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized")
}
@Test("Add-git refuses a repo-nested board — no nested repository, ever")
func addGitRefusesANestedBoard() async throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try plantGitDirectory(in: outer)
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(await git.addGit() == false)
#expect(git.mode == .repoNested)
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
}
@Test("A refused add-git says why, in libgit2's words where it has any")
func aRefusalIsExplained() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
// Mode `git` is refused by `HistoryStore` before libgit2 is reached, so the failure the
// popover would show comes from the layer that *would* have run it.
let failure = GitRepository.create(at: fixture.root)
guard case .failure(let reason) = failure else {
Issue.record("initializing over an existing repository must be refused")
return
}
#expect(reason.operation == "Adding git to this board")
#expect(!reason.message.isEmpty)
}
@Test("Create re-runs full detection and refuses a board that became repo-nested")
func createRefusesAStaleModeNone() async throws {
// **The hardening** (06 Rules Detection, ruled 2026-07-31): "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."
let outer = try WriterFixture()
defer { outer.tearDown() }
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
// Composed while the enclosing folder is still a plain one: the store's mode is `none`, and
// that is the reading that goes stale.
let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(git.mode == .none)
// A terminal `git init` one level up, after the detection the store is holding.
try plantGitDirectory(in: outer)
#expect(await git.addGit() == false, "a root-only check would have let this through")
#expect(
!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
"no nested repository, ever"
)
#expect(git.mode == .none, "a refused add-git changes nothing, mode included")
}
@Test("Create refuses a board that a fresh detection reads unverifiable — a denied ancestor")
func createRefusesUnverifiable() throws {
// **The tightened guard** (06 Rules Detection): "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 (`createRefusesAStaleModeNone` above).
let outer = try WriterFixture()
defer { outer.tearDown() }
let blocked = outer.root.appendingPathComponent("blocked", isDirectory: true)
let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }
#expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable, "the fixture is set up correctly")
let failure = GitRepository.create(at: boardRoot)
guard case .failure(let reason) = failure else {
Issue.record("initializing where detection cannot rule out a repository must be refused")
return
}
#expect(reason.operation == "Adding git to this board")
#expect(!reason.message.isEmpty)
#expect(
!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
"no repository created on an unverifiable read"
)
}
@Test("A failure answers at the form when it is up, and at the banner when it is not")
@MainActor
func aFailureAnswersAtTheFormOrTheBanner() async throws {
// **Form-anchored operations answer at the form first** (06 Interaction with external
// writers, ruled 2026-07-31) "inline is the primary surface, never a silence trap".
let fixture = try makeBoard()
defer { fixture.tearDown() }
// Mode is read once, at composition so a store composed before a `.git` appeared still says
// `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the
// question is where the answer lands.
let git = HistoryStore.compose(boardRoot: fixture.root)
try plantGitDirectory(in: fixture)
var banners: [String] = []
git.reportFailure = { banners.append($0.message) }
// The form is up: inline, and nothing on the strip.
git.noteFormVisible(true)
#expect(await git.addGit() == false)
#expect(git.lastFailure != nil)
#expect(banners.isEmpty, "the user is looking at the form the answer belongs in")
// Dismissing it dismisses the stale error.
git.noteFormVisible(false)
#expect(git.lastFailure == nil)
// Asked again with no form on screen, the answer takes the banner instead of nobody.
#expect(await git.addGit() == false)
#expect(git.lastFailure == nil)
#expect(banners.count == 1)
}
}
// MARK: - The loader's history ranker
@MainActor
@Suite("HistoryStore ▸ the git-backed identity ranker")
struct GitPathHistoryTests {
@Test("A path that entered history earlier ranks lower; an untracked one has no rank")
func ranksFollowHistory() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// A second card, arriving in a later commit the whole point of the rung.
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try commitEverything(at: fixture.root, message: "Add card 'Second'")
// A third, on disk but never committed.
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
let ranker = try #require(git.identityHistoryRanker)
let first = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card1)"))
let second = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card2)"))
#expect(first < second, "lower is earlier")
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card3)") == nil, "untracked is no rank at all")
#expect(ranker.rank(Ident.lane1) == first, "a folder ranks with the first file that landed in it")
}
@Test("No ranker where the app manages no git")
func noRankerWithoutARepository() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let plain = HistoryStore.compose(boardRoot: fixture.root)
#expect(plain.identityHistoryRanker == nil, "mode none injects nothing")
// And the free tier has no `HistoryStore` to ask in the first place pinned in the
// composition suite above.
}
@Test("Git history decides a real duplicate-id collision through the loader")
func historyDecidesTheDuplicateWinner() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The same identity, arriving later in a second lane the copy the duplicate rule is about.
try fixture.item("\(Ident.lane2)/\(Ident.card1)", Item.rich(order: "1024", title: "First (copy)"))
try commitEverything(at: fixture.root, message: "Copy the card")
let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: git.identityHistoryRanker)
let duplicates: [DuplicateIdentity] = result.defects.compactMap {
if case .duplicateIdentity(let duplicate) = $0 { return duplicate }
return nil
}
let duplicate = try #require(duplicates.first)
#expect(duplicate.winner == "\(Ident.lane1)/\(Ident.card1)", "the path that entered history first")
#expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld")
}
}
-786
View File
@@ -1,786 +0,0 @@
import Foundation
import SwiftGitX
import Testing
import libgit2
@testable import Kanban
/// **Repository hygiene** (06-history-undo.md Repository hygiene) the behaviours that keep a
/// board's noise out of the way without ever rewriting anything: the `.gitignore` **every board**
/// carries (re-ruled 2026-07-31 the file outgrew git, so it is seeded at creation and healed in at
/// open, git or not), and the periodic repack that packs loose objects and touches nothing else.
///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2, and every assertion is read off the filesystem or out of the object database rather than
/// through a mock: a housekeeping bug corrupts repositories, so the only tests worth having are the
/// ones a corrupt repository would fail.
///
/// Nothing here shells out to `git`.
// MARK: - Fixtures
/// A board with one lane and one card enough for a tree with three `index.md`s in it.
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
return fixture
}
/// A commit made the way an external writer makes one SwiftGitX directly, so the objects under
/// test are ordinary git objects and not something the app's own path produced.
private func commitEverything(at boardRoot: URL, message: String) throws {
let repository = try Repository.open(at: boardRoot)
try repository.add(paths: [])
_ = try repository.commit(message: message)
}
/// Enough commits that the repository has a non-trivial pile of loose objects to pack.
private func churn(_ fixture: WriterFixture, commits: Int) throws {
for step in 1...commits {
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
Item.rich(order: "\(step)024", title: "Second, take \(step)")
)
try fixture.file("notes.txt", Data(String(repeating: "\(step)", count: 64).utf8))
try commitEverything(at: fixture.root, message: "Change \(step)")
}
}
// MARK: - Filesystem instruments
/// One entry under a subtree: path, bytes (nil for directories), and mtime `UntouchedGitTests`'
/// instrument. Bytes alone would pass a rewrite with identical content; the mtime is the assertion
/// that nothing opened the file for writing at all.
private struct SubtreeEntry: Equatable, CustomStringConvertible {
let path: String
let data: Data?
let modified: Date
var description: String {
"\(path) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))"
}
}
/// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `skip` prunes
/// whole branches how the working tree is snapshotted without `.git`, and `.git` without
/// `objects/`.
private func snapshot(
_ root: URL,
_ subtree: String,
skipping skip: Set<String> = []
) throws -> [SubtreeEntry] {
let base = subtree.isEmpty ? root : root.appendingPathComponent(subtree, isDirectory: true)
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: base.path) else { return [] }
var entries: [SubtreeEntry] = []
for case let relative as String in walker {
let head = relative.split(separator: "/").first.map(String.init) ?? relative
if skip.contains(head) {
walker.skipDescendants()
continue
}
let url = base.appendingPathComponent(relative)
let attributes = try manager.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else { continue }
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
entries.append(SubtreeEntry(
path: relative,
data: isDirectory ? nil : try Data(contentsOf: url),
modified: modified
))
}
return entries.sorted { $0.path < $1.path }
}
// MARK: - Object-database instruments
/// **Every object the repository can answer for**, loose and packed alike, by oid.
///
/// This is the "nothing was forgotten" instrument, and `git_odb_foreach` is the only honest way to
/// ask it: it enumerates the whole database through every backend, so a repack that packed some
/// objects and dropped others shows up as a set that shrank. Read through a repository opened
/// *after* the pass, so the answer comes from what is on disk rather than from a cached view of what
/// used to be.
private func everyObject(at boardRoot: URL) -> Set<String> {
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return [] }
defer { git_repository_free(repository) }
var database: OpaquePointer?
guard git_repository_odb(&database, repository) == 0, let database else { return [] }
defer { git_odb_free(database) }
var found = Set<String>()
withUnsafeMutablePointer(to: &found) { payload in
_ = git_odb_foreach(database, { oid, payload in
guard let oid, let payload else { return 0 }
var value = oid.pointee
var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
git_oid_fmt(&buffer, &value)
payload.assumingMemoryBound(to: Set<String>.self).pointee.insert(String(cString: buffer))
return 0
}, payload)
}
return found
}
/// The oid git would give a file's bytes as a blob `git hash-object`, computed rather than looked
/// up, so a test can ask "is *this content* still in the database" without walking a tree to find it.
private func blobOID(of data: Data) -> String? {
var oid = git_oid()
let status = data.withUnsafeBytes { buffer in
git_odb_hash(&oid, buffer.baseAddress, buffer.count, GIT_OBJECT_BLOB)
}
guard status == 0 else { return nil }
var value = oid
var text = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
git_oid_fmt(&text, &value)
return String(cString: text)
}
/// HEAD's first-parent ancestry, oldest last the history walk, as oids and subjects, so "identical"
/// means identical rather than "HEAD still resolves".
private func historyWalk(at boardRoot: URL) throws -> [String] {
let repository = try Repository.open(at: boardRoot)
guard var commit = try repository.HEAD.target as? Commit else { return [] }
var trail = ["\(commit.id.hex) \(commit.summary)"]
while let parent = try commit.parents.first {
commit = parent
trail.append("\(commit.id.hex) \(commit.summary)")
}
return trail
}
// MARK: - .gitignore seeding
/// **The add-git half.** Since 2026-07-31 the seed belongs to the *board* rather than to git (the
/// suite below this one), and what survives here is the last-chance check in front of the initial
/// commit: whatever else happened, the tree that becomes "Initial board state" carries a
/// `.gitignore`, because a `.DS_Store` that enters history can never be got out again (06 Deleting
/// never forgets).
@MainActor
@Suite("Repository hygiene ▸ the seeded .gitignore")
struct GitignoreSeedTests {
@Test("Add-git guarantees a .gitignore inside the initial commit")
func addGitSeedsTheIgnoreFile() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The file, and the whole of the file the one seed text, shared with board creation and
// the open-time heal (06 Repository hygiene: "`.DS_Store` plus the writer's temp pattern").
#expect(try fixture.data(".gitignore") == Data(BoardWriter.gitignoreSeed.utf8))
// **In "Initial board state", not after it.** Seeding after the commit would put the app's
// own file into the board's first *foreign* commit; seeding before makes it part of the
// board's beginning, which is what it is.
let head = try #require(GitRepository.headCommit(at: fixture.root))
#expect(head.subject == "Initial board state")
#expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
}
@Test("A .DS_Store already under the board never enters history at all")
func theSeedTakesEffectFromTheFirstCommit() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// What the Finder leaves behind: one per folder the user has looked at.
try fixture.file(".DS_Store", Data([0x00, 0x01, 0x42]))
try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x43]))
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// Not "removed from history later" never in it. The app has no history-rewriting operation
// and never will (06 Deleting never forgets), so the only moment this could be got right
// is the first one.
let tracked = GitRepository.trackedPaths(at: fixture.root)
#expect(!tracked.contains { $0.hasSuffix(".DS_Store") })
#expect(fixture.exists(".DS_Store"), "and the file itself is left exactly where it is")
}
@Test("A board that already has a .gitignore is left byte-for-byte alone")
func anExistingIgnoreFileIsUntouched() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let mine = Data("# mine\nbuild/\n*.tmp\n".utf8)
try fixture.file(".gitignore", mine)
let before = try snapshot(fixture.root, ".gitignore")
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// Not merged, not appended to, not reordered and not even opened for writing, which is
// what the mtime says (06: "the app never edits an existing one").
#expect(try fixture.data(".gitignore") == mine)
#expect(try snapshot(fixture.root, ".gitignore") == before)
#expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
}
@Test("The app never manages the file afterwards — commits and housekeeping leave it alone")
func theFileIsTheUsersFromThenOn() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The user edits it including deleting the line the app seeded, which is their business.
let theirs = Data("*.log\n".utf8)
try fixture.file(".gitignore", theirs)
let before = try snapshot(fixture.root, ".gitignore")
try churn(fixture, commits: 3)
_ = GitHousekeeping.run(at: fixture.root, threshold: 1)
await git.committer?.flushNow()
#expect(try fixture.data(".gitignore") == theirs, "nothing in the app re-seeds it")
#expect(try snapshot(fixture.root, ".gitignore") == before)
}
/// **The second consumer of the one noise definition** (01-storage-format.md § Fractal layout
/// Rules: "On Pro boards the same file governs the committer, so ignored noise neither relocates
/// nor commits one definition of noise, two consumers"). The committer's own condition is
/// `changedPaths`, which stages through libgit2 with ignores respected; this pins that the file
/// the loose-file gate reads is the file that decides what commits.
@Test("The committer obeys the same file — ignored noise never becomes a changed path")
func theCommitterObeysTheSameFile() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
// The user fine-tunes their own noise definition, which is exactly what the file is for.
try fixture.file(".gitignore", Data((BoardWriter.gitignoreSeed + "*.tmp\n").utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.tmp", Data("noise".utf8))
try fixture.file("\(Ident.lane1)/notes.txt", Data("a real stray".utf8))
try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x42]))
let changed = GitCommitOperation.changedPaths(at: fixture.root).map(\.path)
#expect(!changed.contains { $0.hasSuffix("scratch.tmp") })
#expect(!changed.contains { $0.hasSuffix(".DS_Store") })
#expect(changed.contains { $0.hasSuffix("notes.txt") }, "and an ordinary stray still commits")
}
/// Composing history over somebody else's repository writes nothing at all adoption is not an
/// init, and no *git* path seeds. (The board's own heal is what gives such a board its
/// `.gitignore`, at open, and it is exercised in the suite below.)
@Test("Adoption writes nothing — an adopted repository is somebody else's init")
func adoptionSeedsNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// `git init` run outside the app, exactly the shape a cloned or hand-inited board arrives in.
_ = try Repository.create(at: fixture.root)
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
#expect(!fixture.exists(".gitignore"), "composing history is not a write")
}
/// A repo-nested board gets no *git* of the app's, so no git path can seed it and the
/// enclosing repository is never written into either. What such a board does get is the ordinary
/// board-level seed at open (06's "Repo-nested boards are seeded too"), which is the suite below.
@Test("The git paths never touch a repo-nested board, or its enclosing repo")
func repoNestedBoardsGetNothingFromGit() async throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(await git.addGit() == false)
#expect(git.mode == .repoNested)
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".gitignore").path))
#expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(".gitignore").path))
}
}
// MARK: - The .gitignore every board carries
/// **"`.gitignore` seeded on every board, never touched after"** (06-history-undo.md Repository
/// hygiene, re-ruled 2026-07-31 "the file outgrew git: it is the one noise definition the
/// loose-file relocation heal obeys so every board carries it, git or not").
///
/// Three claims, and they are the whole ruling: **creation writes it**, **a board missing it gains
/// it by scheduled heal at open**, and **the app never edits an existing one** an empty file
/// included, which is the ruling's own escape hatch. The gate it feeds is
/// `LooseFileRelocationTests` the noise gate; the pattern semantics are `GitignoreRulesTests`.
@MainActor
@Suite("Repository hygiene ▸ the .gitignore every board carries")
struct BoardGitignoreSeedTests {
private func seedURL(in fixture: WriterFixture) -> URL {
fixture.root.appendingPathComponent(IntegrityRules.gitignoreFileName)
}
private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else {
throw NSError(domain: "BoardGitignoreSeedTests", code: 1)
}
return (try Data(contentsOf: url), modified)
}
@Test("Board creation writes the seed beside index.md")
func creationSeeds() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = fixture.url("New Board.kanban")
try BoardWriter.createBoard(at: root, title: "New Board")
#expect(try Data(contentsOf: root.appendingPathComponent(IntegrityRules.gitignoreFileName))
== Data(BoardWriter.gitignoreSeed.utf8))
}
@Test("A board missing the file gains it at open, silently")
func healSeedsAtOpen() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
#expect(!fixture.exists(IntegrityRules.gitignoreFileName))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8))
// A courtesy file the user did not create and may not know exists the guide's posture.
#expect(store.banners.losses.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
/// The heal's memo, doing its two jobs: a picture already acted on is not acted on again (no
/// second write), and a picture that comes *back* a foreign deletion heals again, because the
/// memo was cleared on success.
@Test("Seeding twice writes once, and a deleted file comes back")
func memoIsArmedAndCleared() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let store = try BoardStore(rootURL: fixture.root)
store.seedGitignore()
let first = try stat(seedURL(in: fixture))
#expect(store.heals.memo(for: .missingGitignore) == nil, "cleared on success")
store.seedGitignore()
#expect(try stat(seedURL(in: fixture)) == first, "not rewritten — not even opened")
// What a foreign deletion looks like: the picture "missing" is restored, and a standing memo
// would have made that deletion the one thing this could not heal.
try FileManager.default.removeItem(at: seedURL(in: fixture))
store.seedGitignore()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8))
}
@Test("An existing .gitignore is left byte-for-byte alone, mtime included")
func existingFileIsNeverRewritten() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let theirs = Data("# mine\nbuild/\n*.tmp\n".utf8)
try fixture.file(IntegrityRules.gitignoreFileName, theirs)
let before = try stat(seedURL(in: fixture))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == theirs)
#expect(try stat(seedURL(in: fixture)) == before, "never merged, never appended to, never opened")
}
/// "The escape hatch for wanting no exclusions is an *empty* file, which the app honors and never
/// rewrites" the one case where re-seeding would look most reasonable and is most wrong.
@Test("An empty .gitignore is honored and never rewritten")
func emptyFileIsHonored() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file(IntegrityRules.gitignoreFileName, Data())
let before = try stat(seedURL(in: fixture))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
store.runScheduledHeals()
#expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data())
#expect(try stat(seedURL(in: fixture)) == before)
}
/// "Repo-nested boards are seeded too (re-ruling the old no-app-`.gitignore` posture): the file
/// serves the heal there, not any app-managed git" so there is no repo-detection gate on this
/// heal, and the enclosing repository is still never written into.
@Test("A repo-nested board is seeded like any other")
func repoNestedBoardsAreSeeded() throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
try Data(AgentGuide.content.utf8).write(to: boardRoot.appendingPathComponent(AgentGuide.filename))
let store = try BoardStore(rootURL: boardRoot)
store.runScheduledHeals()
#expect(try Data(contentsOf: boardRoot.appendingPathComponent(IntegrityRules.gitignoreFileName))
== Data(BoardWriter.gitignoreSeed.utf8))
#expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(IntegrityRules.gitignoreFileName).path))
}
/// **The claimed name that does not displace** (`IntegrityRules.claimedRootNames`): a wrong-kind
/// node wearing `.gitignore` is left exactly where it is, because a board with no readable noise
/// definition simply excludes nothing nothing breaks while the name is held, so nothing of the
/// user's is moved to buy a courtesy file.
@Test("A folder wearing the name is left alone, and nothing is written through it")
func squatterIsLeftAlone() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
try fixture.file("\(IntegrityRules.gitignoreFileName)/inside.txt", Data("mine".utf8))
let store = try BoardStore(rootURL: fixture.root)
store.runScheduledHeals()
#expect(try fixture.data("\(IntegrityRules.gitignoreFileName)/inside.txt") == Data("mine".utf8))
#expect(store.banners.oneShots.isEmpty, "and no failure is reported for work nobody asked for")
#expect(store.banners.losses.isEmpty)
}
/// A board whose location cannot be written to defers rather than failing the engine's gate,
/// stated here because this heal runs at every open of every board and is the one most likely to
/// meet a read-only volume.
@Test("An unwritable board root is skipped silently")
func unwritableRootIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
let store = try BoardStore(rootURL: fixture.root)
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.root.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) }
store.seedGitignore()
#expect(!fixture.exists(IntegrityRules.gitignoreFileName))
#expect(store.banners.oneShots.isEmpty)
#expect(store.heals.memo(for: .missingGitignore) == nil, "deferred, never remembered")
}
}
// MARK: - The housekeeping pass
@MainActor
@Suite("Repository hygiene ▸ periodic housekeeping")
struct GitHousekeepingTests {
@Test("A pass repacks loose objects and alters no commit, no ref, and no reachable content")
func repackingChangesNothing() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 4)
let looseBefore = GitHousekeeping.looseObjectCount(at: fixture.root)
#expect(looseBefore > 0, "the fixture has to have something to pack")
let objectsBefore = everyObject(at: fixture.root)
let walkBefore = try historyWalk(at: fixture.root)
let trackedBefore = GitRepository.trackedPaths(at: fixture.root)
let refsBefore = try snapshot(fixture.root, ".git/refs")
let headFileBefore = try snapshot(fixture.root, ".git/HEAD")
let workingTreeBefore = try snapshot(fixture.root, "", skipping: [".git"])
let outcome = GitHousekeeping.run(at: fixture.root, threshold: 1)
guard case let .repacked(repack) = outcome else {
Issue.record("expected a repack, got \(outcome)")
return
}
// It did something
#expect(repack.looseBefore == looseBefore)
#expect(repack.packedAway > 0)
#expect(repack.packedAway == repack.inserted, "every inserted object was proved and removed")
#expect(GitHousekeeping.looseObjectCount(at: fixture.root) < looseBefore)
// and it forgot nothing. Loose objects moved into a pack are the *same* objects: the whole
// database answers for exactly the set it answered for before (06 Repository hygiene:
// "it rewrites nothing").
#expect(everyObject(at: fixture.root) == objectsBefore)
// No commit, no ref, no reachable content.
#expect(try historyWalk(at: fixture.root) == walkBefore)
#expect(GitRepository.trackedPaths(at: fixture.root) == trackedBefore)
#expect(try snapshot(fixture.root, ".git/refs") == refsBefore)
#expect(try snapshot(fixture.root, ".git/HEAD") == headFileBefore)
// And the working tree never came into it housekeeping is a fact about `.git/objects` and
// nothing else.
#expect(try snapshot(fixture.root, "", skipping: [".git"]) == workingTreeBefore)
}
@Test("Every object is still readable after a pass, one oid at a time")
func everyObjectSurvives() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
let before = everyObject(at: fixture.root)
#expect(!before.isEmpty)
_ = GitHousekeeping.run(at: fixture.root, threshold: 1)
// The set comparison above is the same claim in aggregate; this is it per object, which is
// the shape a corruption bug would actually take one blob that went nowhere.
let after = everyObject(at: fixture.root)
for oid in before {
#expect(after.contains(oid), "object \(oid) stopped being readable")
}
}
@Test("Two passes in a row are stable — the second finds nothing left to do")
func aSecondPassIsANoOp() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
_ = GitHousekeeping.run(at: fixture.root, threshold: 1)
let objects = everyObject(at: fixture.root)
let loose = GitHousekeeping.looseObjectCount(at: fixture.root)
// Nothing re-packs what is already packed, so the second pass reads below any threshold the
// first one left it under and a repository that keeps being repacked would be growth, not
// hygiene.
#expect(GitHousekeeping.run(at: fixture.root, threshold: max(1, loose + 1)) == .belowThreshold(loose: loose))
#expect(everyObject(at: fixture.root) == objects)
}
@Test("Below the threshold, the pass reads the count and does nothing at all")
func belowThresholdTouchesNothing() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
let before = try snapshot(fixture.root, ".git")
let loose = GitHousekeeping.looseObjectCount(at: fixture.root)
#expect(loose > 0)
#expect(GitHousekeeping.run(at: fixture.root, threshold: loose + 1) == .belowThreshold(loose: loose))
#expect(try snapshot(fixture.root, ".git") == before, "a gate that closed wrote nothing")
}
@Test("The default threshold is git's own gc.auto, so an ordinary board is never repacked at open")
func theDefaultThresholdIsGitsOwn() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
#expect(GitHousekeeping.defaultLooseObjectThreshold == 6700)
let loose = GitHousekeeping.looseObjectCount(at: fixture.root)
#expect(GitHousekeeping.run(at: fixture.root) == .belowThreshold(loose: loose))
}
@Test("A paused repository is skipped in silence, and stays untouched")
func aPausedRepositoryIsSkipped() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
// The marker file libgit2's own `git_repository_state` reads an outside-the-app merge, in
// progress. The committer holds for this (06 Rules Abnormal repo states); optional work
// simply does not happen.
let head = try #require(GitRepository.headCommit(at: fixture.root))
try fixture.file(".git/MERGE_HEAD", Data("\(head.oid)\n".utf8))
let before = try snapshot(fixture.root, ".git")
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held))
#expect(try snapshot(fixture.root, ".git") == before)
}
@Test("A held index.lock is skipped in silence, and stays untouched")
func aHeldLockIsSkipped() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
// Another writer, mid-operation. The lock is never removed, whatever the app is doing
// it isn't the app's (06 Interaction with external writers).
try fixture.file(".git/index.lock", Data())
let before = try snapshot(fixture.root, ".git")
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.indexLocked))
#expect(try snapshot(fixture.root, ".git") == before)
#expect(fixture.exists(".git/index.lock"), "and the lock is still somebody else's")
}
@Test("Deleting a card leaves every prior commit touching its folder fully intact")
func deletingNeverForgets() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
let cardPath = "\(Ident.lane1)/\(Ident.card1)/index.md"
let birth = try #require(GitRepository.headCommit(at: fixture.root))
let content = try fixture.data(cardPath)
let contentOID = try #require(blobOID(of: content))
#expect(everyObject(at: fixture.root).contains(contentOID), "the card's bytes are in the repository")
// A delete, in both of its shapes: into `.trash/`, then gone for good.
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)")
try commitEverything(at: fixture.root, message: "Delete card 'First'")
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card1)"))
try commitEverything(at: fixture.root, message: "Permanently delete card 'First'")
// Off the live board, and out of the working tree
#expect(!GitRepository.trackedPaths(at: fixture.root).contains(cardPath))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
// and every version of its content still reachable in the repository, with the commit that
// introduced it exactly where it was. "Deleting never forgets" (06 Repository hygiene) is
// stated design, and there is no code path in the app that could take it back: nothing
// rewrites history, and housekeeping packs rather than prunes.
_ = GitHousekeeping.run(at: fixture.root, threshold: 1)
let objects = everyObject(at: fixture.root)
#expect(objects.contains(contentOID), "the deleted card's bytes are still in the object database")
#expect(objects.contains(birth.oid), "and so is the commit that introduced them")
#expect(try historyWalk(at: fixture.root).last == "\(birth.oid) \(birth.subject)")
}
@Test("A board with no repository is skipped, and no repository appears")
func aBoardWithNoRepositoryIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
#expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.noRepository))
#expect(!fixture.exists(".git"))
}
}
// MARK: - The scheduler
@MainActor
@Suite("Repository hygiene ▸ when housekeeping runs")
struct GitHousekeeperSchedulingTests {
@Test("A git-mode store composes a housekeeper; a mode-none one composes none")
func compositionFollowsMode() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let plain = HistoryStore.compose(boardRoot: fixture.root)
#expect(plain.housekeeper == nil, "no repository, nothing to maintain")
#expect(await plain.addGit())
#expect(plain.housekeeper != nil, "the mid-session flip maintains itself like any git board")
let reopened = HistoryStore.compose(boardRoot: fixture.root)
#expect(reopened.housekeeper != nil)
// The mode is the *whole* condition. A companion test used to sit beside this one pinning
// that the free tier maintained nothing anywhere structurally, since `compose` answered
// `nil` off Pro and PIVOT 2026-08-07 (12-editions.md) retired both the gate and the claim:
// there is no tier to compose under, so this suite's one axis is the one above.
}
@Test("A commit in flight defers the pass entirely — it is never retried")
func aCommitInFlightDefersThePass() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
try churn(fixture, commits: 3)
let housekeeper = try #require(git.housekeeper)
housekeeper.threshold = 1
housekeeper.isCommitInFlight = { true }
let before = try snapshot(fixture.root, ".git")
await housekeeper.runNow()
#expect(housekeeper.lastOutcome == nil, "it did not run, and recorded no verdict")
#expect(try snapshot(fixture.root, ".git") == before)
// And with the engine quiet it runs the same pass, one board-open later.
housekeeper.isCommitInFlight = { false }
await housekeeper.runNow()
guard case .repacked = housekeeper.lastOutcome else {
Issue.record("expected a repack once the committer was quiet, got \(String(describing: housekeeper.lastOutcome))")
return
}
}
@Test("Activating auto-commit arms the pass, and teardown cancels it")
func activationArmsAndTeardownCancels() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit())
try churn(fixture, commits: 3)
let git = HistoryStore.compose(boardRoot: fixture.root)
let housekeeper = try #require(git.housekeeper)
housekeeper.threshold = 1
housekeeper.delay = .milliseconds(20)
git.committer?.debounceInterval = .seconds(60)
git.activateAutoCommit { _ in }
try await Task.sleep(for: .milliseconds(400))
guard case .repacked = housekeeper.lastOutcome else {
Issue.record("board open arms one pass, got \(String(describing: housekeeper.lastOutcome))")
return
}
// Teardown cancels an armed one, so a closed board's maintenance cannot fire against a store
// that has gone.
let second = HistoryStore.compose(boardRoot: fixture.root)
let secondKeeper = try #require(second.housekeeper)
secondKeeper.threshold = 1
secondKeeper.delay = .milliseconds(200)
second.activateAutoCommit { _ in }
second.stopAutoCommit()
try await Task.sleep(for: .milliseconds(500))
#expect(secondKeeper.lastOutcome == nil)
}
@Test("The wired gate is the committer's own in-flight flag")
func theGateIsTheCommittersOwnFlag() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit())
let housekeeper = try #require(git.housekeeper)
let committer = try #require(git.committer)
#expect(committer.isCommitInFlight == false, "a quiet engine")
#expect(housekeeper.isCommitInFlight?() == false, "and the housekeeper reads it, not a copy")
}
}