diff --git a/Kanban/Changes/ChangeNarrator.swift b/Kanban/Changes/ChangeNarrator.swift index 6a226fe..07dc313 100644 --- a/Kanban/Changes/ChangeNarrator.swift +++ b/Kanban/Changes/ChangeNarrator.swift @@ -936,6 +936,37 @@ enum ChangeNarrator { return components.prefix(depth).joined(separator: "/") } + /// **When each of this window's comments was created**, keyed by `commentFolder(of:)`'s own + /// spelling — the chronology a commit's comment bullets sort by (06 ▸ Rules ▸ Auto-commit, blessed + /// 2026-07-31: "by the comments' own `created`, folder name on ties"). + /// + /// Relocated 2026-08-08 from `GitAutoCommitter.swift` with the git excision: the flush that called + /// this is gone, but the read itself is git-free — one `index.md` per touched comment folder, off + /// the working tree — so it stays beside the vocabulary that consumes it + /// (`ChangeNarrationRequest.commentTimestamps`) rather than leaving with its old caller. + /// + /// **Missing is normal, not a defect.** A comment whose folder left the tree in this very window + /// (the close purge), one whose `index.md` does not parse, one written by hand with no `created` + /// at all — each is simply absent here and sorts after its dated siblings in folder-name order, + /// which is `CommentThread.sorted`'s own fallback for the same field. Nothing here is reported — + /// a commit message is the wrong place to discover a defect. + static func commentTimestamps(for changed: [ChangedPath], boardRoot: URL) -> [String: Date] { + var timestamps: [String: Date] = [:] + var seen: Set = [] + for path in changed { + guard let folder = commentFolder(of: path.path), seen.insert(folder).inserted else { continue } + let index = boardRoot + .appendingPathComponent(folder) + .appendingPathComponent(IntegrityRules.indexFileName) + guard let data = try? Data(contentsOf: index), + let document = try? BoardLoader.parseDocument(data, path: folder), + let created = document.created.value + else { continue } + timestamps[folder] = created + } + return timestamps + } + /// **The comment verb family** (01-storage-format.md § Enhanced schema, the `kind: comment` block: /// "foreign comment changes are described by **path shape** — the 'Update agent guide (vN)' /// mechanism: a changed path under `…/comments//` composes 'Comment on ⟨card title⟩' / 'Edit diff --git a/Kanban/Git/BoardGitMode.swift b/Kanban/Git/BoardGitMode.swift deleted file mode 100644 index 5dab905..0000000 --- a/Kanban/Git/BoardGitMode.swift +++ /dev/null @@ -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 - } -} diff --git a/Kanban/Git/CommitAttribution.swift b/Kanban/Git/CommitAttribution.swift deleted file mode 100644 index 85a2536..0000000 --- a/Kanban/Git/CommitAttribution.swift +++ /dev/null @@ -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 = "external@lanework.invalid" - - /// 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 = "integrity@lanework.invalid" - - /// 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 `@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 `. - 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 = [] - 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) - } -} diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift deleted file mode 100644 index 9815e09..0000000 --- a/Kanban/Git/GitAutoCommitter.swift +++ /dev/null @@ -1,1025 +0,0 @@ -import Foundation -import os - -// MARK: - GitLandedWindow - -/// **One debounce window's commits, as the undo stack needs to hear about them** — see -/// `GitAutoCommitter.reportLanded`. -/// -/// The heal halves are separated because the two rules they serve are separate: `healOIDs` is what -/// makes the *pointer* pass over a heal commit, and `healPaths` is what makes a restore's -/// materialized diff **exclude** the paths whose divergence is heal work (06-history-undo.md ▸ Rules -/// ▸ Heal commits are transparent to undo, in-session — both halves, stated in one sentence). -public struct GitLandedWindow: Sendable, Equatable { - - /// Every commit the window landed, oldest first. - public let commits: [GitLandedCommit] - - /// Board-root-relative paths this window committed as heal work. - public let healPaths: Set - - public init(commits: [GitLandedCommit], healPaths: Set) { - self.commits = commits - self.healPaths = healPaths - } - - /// The oids of the heal-class commits — the ones the undo pointer passes over. - public var healOIDs: Set { - Set(commits.filter { $0.kind == .heal }.map(\.oid)) - } - - /// Whether *everything* this window landed was heal work. - /// - /// The distinction the stack acts on: a window of nothing but heal commits must leave the undo - /// pointer and the redo stack exactly where they were — "the fresh heal commit is in-session, - /// transparent, and the undo run continues past it" (06). A window carrying anything else is an - /// ordinary arrival, and arrivals clear redo. - public var isEntirelyHeal: Bool { - !commits.isEmpty && commits.allSatisfy { $0.kind == .heal } - } -} - -// MARK: - GitAutoCommitter - -/// **Every settled change becomes a commit** (06-history-undo.md ▸ Rules ▸ Auto-commit), debounced -/// past drag and typing churn, on git-mode boards and nowhere else. -/// -/// ### Structurally unreachable on a board with no repository -/// -/// One of these exists per `HistoryStore` in mode `git` and nowhere else. Until the 2026-08-07 pivot -/// there was a second gate above it — a `HistoryStore` existed only under Pro — and that one is gone -/// (12-editions.md ▸ PIVOT 2026-08-07: git left the paywall, and every tier composes the git stack -/// on git-mode boards). What remains is the stronger of the two anyway, because it never depended on -/// a subscription: git is **opt-in per board** (06 ▸ Rules), so a board the user never added git to -/// detects `none`, composes no committer, and has no debounce to cancel and no `.git` to touch. The -/// file layer's own indifference to `.git` is pinned against real bytes by `UntouchedGitTests`. -/// -/// ### What arms it -/// -/// Two signals, both from `BoardStore` through `HistoryCommitSeam`, and both meaning "the tree may -/// have moved": -/// -/// - **A write bracket closed.** The app just wrote. This is also where receipts are *harvested* — -/// see `HarvestedReceipt` for why the committer cannot simply read the ledger two seconds later. -/// - **A reload landed.** Which covers foreign changes on the same debounce as app-mediated ones: -/// "Agent and hand edits arrive through the watcher like any change and get auto-committed on the -/// same debounce" (06 ▸ Interaction with external writers). It covers strays too — the reload -/// lands whether or not the snapshot changed, and "its commit condition is the *tree*, not the -/// snapshot diff, so a stray-only window commits". -/// -/// The committer's own commits do not re-arm it: `FolderWatcher` filters `.git`'s internals, so -/// writing an index, an object and a ref produces no event at all. The pathfinder relied on the -/// clean-tree no-op to break that echo; here there is no echo to break. -/// -/// ### Isolation -/// -/// `@MainActor` for the state — the debounce task, the harvest, the session registry — and every -/// piece of libgit2 work runs in a `Task.detached` over `Sendable` values (`FlushInput`), which is -/// `GitRepository`'s rule restated: the main actor never blocks on libgit2, and libgit2 never sees -/// two threads on one handle. The **one** deliberate exception is `noteWillWrite()`; see its note. -@MainActor -@Observable -public final class GitAutoCommitter { - - // MARK: - Identity - - /// The board this commits, which in git mode is also the repository's working-tree root. - public let boardRoot: URL - - /// **This board's write-provenance ledger** (`BoardStore.echoes`), read — never written — at the - /// close of every write bracket. - @ObservationIgnored - private let ledger: EchoLedger - - // MARK: - Seams - - /// **The debounce** — how long the tree must be quiet before a commit. - /// - /// Two seconds is the pathfinder's interval, kept because the cadence constraint (06 ▸ Rules) - /// asks the same thing of it as the pathfinder did: long enough that a drag, a multi-select - /// delete and a burst of typing each land as one commit, short enough that a board's history is - /// never far behind its files. Settable for `CardBodyEditSession.debounceInterval`'s reason - /// exactly — a test must not have to spend it. - @ObservationIgnored - public var debounceInterval: Duration = .seconds(2) - - /// How long to wait between attempts when `index.lock` is held, and how many times. - /// - /// "If the auto-committer finds the index locked (an agent's commit in flight), it backs off - /// briefly and retries; if the lock persists, it simply re-debounces" (06 ▸ Interaction with - /// external writers). *Briefly* is the operative word: a held lock is another writer doing its - /// job, and the pending changes lose nothing by waiting for the next quiet moment. - @ObservationIgnored - public var lockRetryDelay: Duration = .milliseconds(120) - - @ObservationIgnored - public var lockRetryAttempts = 3 - - /// How long a held repository waits before re-checking its own state. - /// - /// **Not a retry** — nothing is attempted — but the pause has to end somehow: "edits keep landing - /// on disk and commit as one settled batch when the state clears", and a rebase finished in a - /// terminal that moves only refs produces no watcher event at all (`.git` is filtered), so - /// nothing else would ever nudge this board again. A `git_repository_state` read is a handful of - /// `stat`s; at this cadence, only while a pause stands, it is the cheapest thing that keeps the - /// promise. 07's "never hammer" is about not retrying the *operation*, which this never does. - @ObservationIgnored - public var holdRecheckInterval: Duration = .seconds(15) - - /// **What a commit says** — the seam, holding the semantic composer by default - /// (06 ▸ Commit messages). Settable so a test can inject a fake and assert *that* a message was - /// asked for without asserting what it said. - @ObservationIgnored - public var composer: any ChangeNarrating = SemanticChangeNarration() - - /// The board as the app last read it, for the composer's "current" half. `nil` where no store is - /// attached, which is every storeless test. - @ObservationIgnored - public var currentSnapshot: (@MainActor () -> BoardModel?)? - - /// **The reload pipeline settling** — `BoardStore.awaitQuiescence()`, and `nil` on a storeless - /// committer. - /// - /// Read only by `awaitCoveringSnapshot()`, whose whole correctness rests on it: it is what makes - /// the *next* walk a walk that started after this flush's changes were on disk. - @ObservationIgnored - public var awaitReloadQuiescence: (@MainActor () async -> Void)? - - /// **How many tree walks the board has landed** — `BoardStore.landedReloads`, incremented by - /// every reload that completed with a snapshot in hand. - /// - /// **The walk, not the applied snapshot**, and the distinction is load-bearing since 2026-07-31: - /// a reload whose tree turned out to be value-equal skips the snapshot assignment and its counter - /// (02-architecture.md § Live-reload resilience), and a gate watching *that* counter would sit out - /// its whole deadline on a flush whose covering walk had already landed. What covers a flush is a - /// walk that started after its writes reached disk, and a - /// completed walk covers them whether or not it found anything different to show. - /// - /// `nil` — the closure absent, or answering `nil` because the store has gone — means there is no - /// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless - /// committer. - @ObservationIgnored - public var landedReloads: (@MainActor () -> Int?)? - - /// **How long an explicit flush waits for its covering reload** before composing from the snapshot - /// it already has. - /// - /// A bound rather than an open-ended wait, and recorded as a judgment call: 06 rules that the - /// flush awaits its covering snapshot and does not say what happens if that reload never lands. It - /// normally lands within the watcher's ~200 ms debounce, and it is *scheduled unconditionally* by - /// the write bracket that closed (`FolderWatcher.endBracket`, "the mandatory single post-bracket - /// reload … even if not one filesystem event was seen"), so the wait is short and certain in every - /// ordinary case. What it must not be is unbounded: this flush runs on the close and quit paths, - /// and a board whose watcher stream failed to start (`BoardStoreRegistry.acquire` logs and carries - /// on) would otherwise make the app unquittable. So the wait ends, generously, and the commit is - /// composed from the snapshot in hand — one stale subject in a degraded configuration, against a - /// hang. - @ObservationIgnored - public var coveringSnapshotDeadline: Duration = .seconds(1) - - /// How often the wait re-reads the generation. Polled rather than signalled for - /// `CloseFlushCoordinator.drainCardWindows`' reason: the point of this wait is that it *ends*, and - /// a continuation resumed by a reload that never lands has no way to. - @ObservationIgnored - public var coveringSnapshotPollInterval: Duration = .milliseconds(10) - - /// **A genuine commit failure** — disk full, repo corruption (06: "files stay safe on disk but - /// history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried - /// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`. - /// - /// Deliberately **not** called for lock contention, which "is never an error", nor for a held - /// repository, whose surface is the popover's badge (the branch-switching card's). - @ObservationIgnored - public var reportFailure: (@MainActor (GitOperationFailure) -> Void)? - - /// History is advancing again — the standing suspension's clearing rule - /// (`BannerCenter.clearHistorySuspension`), which is "the ordinary shape of commit succeeded". - @ObservationIgnored - public var reportRecovery: (@MainActor () -> Void)? - - /// **The repository became unreadable, or readable again** — the standing breakage banner's - /// raise and heal (06-history-undo.md ▸ Rules, the corrupt-`.git` loud failure, ruled - /// 2026-07-31: "a standing breakage-class banner at detection … the banner clears when a later - /// open or reload finds the repo readable"). - /// - /// Called on the *transition only*, with the new answer — so a board that stands unreadable for - /// an hour posts one row rather than one per 15 s re-read, and a repository repaired in a - /// terminal heals the row on the first re-read that opens it. - /// - /// Separate from `reportFailure` because the two conditions are different rows saying different - /// things: a failed commit is "history stopped advancing, here is the error" (a retry away), - /// while this is "there is no repository the app can read at all". Wired by - /// `AppModel.beginSession` to `BoardStore.noteRepositoryUnreadable(_:)`. - @ObservationIgnored - public var reportRepositoryUnreadable: (@MainActor (Bool) -> Void)? - - /// **What a flush landed, and which of it was heal work** — the undo stack's in-session ear - /// (06-history-undo.md ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live; ▸ Heal commits - /// are transparent to undo, in-session). - /// - /// Two facts travel here and nowhere else can carry them. **Liveness**: "foreign commits … - /// push onto the in-session undo stack as ordinary steps as they land", and a commit this engine - /// made is the one kind of arrival the stack could otherwise only discover by polling HEAD. - /// **Heal transparency**: the heal class is known from the Writer's heal-marked receipts, which - /// this engine clears the instant a window commits — so the moment of landing is the only moment - /// at which "that commit was the heal" is knowable at all. - /// - /// `nil` wherever no `GitHistoryProvider` is listening — which in practice is nowhere a committer - /// exists at all: a committer's existence is exactly mode `git`, and mode `git` is exactly where - /// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none and - /// repo-nested boards alike have a native stack and no committer. - @ObservationIgnored - public var reportLanded: (@MainActor (GitLandedWindow) -> Void)? - - // MARK: - Observable state - - /// **The repository state the engine is holding for**, or `nil` when it is free to commit - /// (06 ▸ Rules ▸ Abnormal repo states). - /// - /// Queryable rather than merely internal because the *UI* surface of the pause — the popover's - /// badge and its plain-language explanation, and disabling Undo/Redo, the branch controls, Pull - /// and Push with it — is the branch-switching card's, and it needs exactly this fact. What this - /// card owns is the hold itself. - public private(set) var pause: GitRepositoryPause? - - /// The last genuine failure, or `nil` if history is advancing. Beside `pause` for the popover's - /// sake, and because `HistoryStore.lastFailure` is add-git's, not this. - public private(set) var lastFailure: GitOperationFailure? - - /// Commits this committer has landed, and the newest OID — the debounce's own testimony, which a - /// test would otherwise have to infer from a commit walk. - public private(set) var commitCount = 0 - public private(set) var lastCommitOIDs: [String] = [] - - /// **Whether a flush is running right now** — the housekeeper's gate (`GitHousekeeper`, - /// 06 ▸ Repository hygiene). - /// - /// A read of the same flag the engine already uses to keep two flushes off each other, published - /// rather than duplicated: the alternative — a second mutual-exclusion mechanism between the - /// committer and optional maintenance — would put a new way to *not* commit into the one path - /// that must always commit. The repack is safe beside a commit either way (`GitHousekeeping` ▸ - /// Concurrency); this is what lets it be polite as well. - public var isCommitInFlight: Bool { isFlushing } - - // MARK: - Private state - - /// Receipts copied out of the ledger at bracket close, keyed by absolute path. Cleared when a - /// flush commits them — the window is over, and a stale receipt would vouch for the next window's - /// changes. - @ObservationIgnored - private var harvested: [String: HarvestedReceipt] = [:] - - /// **Whether this window holds a change nobody vouched for** — the flush-before-overwrite gate. - @ObservationIgnored - private var holdsForeignChanges = false - - /// **Whether an app write has closed with no reload landed since** — the covering await's entry - /// gate (`awaitCoveringSnapshot()`). - /// - /// Set at every write-bracket close and cleared by every landing, so it answers exactly "is - /// `currentSnapshot` known to be behind the tree". Without it an explicit flush on a quiet board - /// would wait out the whole deadline for a reload nothing has any reason to schedule. - /// - /// **The one corner it does not cover, recorded rather than discovered**: a reload that was - /// already *in flight* when the write bracket closed walked the pre-write tree, and its landing - /// clears this flag all the same — the store's landing signal carries no such distinction - /// (`HistoryCommitSeam.reloadDidLand`). A flush inside that gap composes from a snapshot one walk - /// behind, which is the pre-ruling behaviour for a window narrower than it used to be: the write - /// bracket's own mandatory post-bracket reload is already scheduled and lands ~200 ms later, and - /// closing the gap properly needs a fact only `BoardStore` has (whether a walk was running). - @ObservationIgnored - private var holdsUncoveredWrites = false - - /// Open **card-window sessions**, each answering with the folder to stage around *right now*. - /// - /// A closure per session rather than a stored URL, because a card can move lane, or into the - /// trash, in the middle of a session — its folder is a fact about the current snapshot, not - /// about when the window opened. - @ObservationIgnored - private var cardSessions: [UUID: @MainActor () -> URL?] = [:] - - @ObservationIgnored - private var pending: Task? - - @ObservationIgnored - private var isFlushing = false - - /// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`. - @ObservationIgnored - private var flushWaiters: [CheckedContinuation] = [] - - private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") - - init(boardRoot: URL, ledger: EchoLedger) { - self.boardRoot = boardRoot - self.ledger = ledger - } - - // MARK: - Lifecycle - - /// **Launch catch-up** (06 ▸ Commit messages: "changes found pending at board open … through the - /// same composer, instead of committing blind"). - /// - /// One armed debounce and nothing else: a board that opens clean spends one `git status` and - /// commits nothing, and a board that opens dirty commits through the ordinary engine — same - /// split, same authorship, same message seam. Everything found pending classifies **foreign**, - /// which is not a shortcut but the doctrine: the ledger is empty because the app was not running, - /// and "the app never vouches for changes it didn't witness". - public func start() { - // **The ledger may already hold something, and exactly once it does.** An ordinary board's - // ledger is empty here — the app has not written to it, which is the whole of the - // launch-catch-up doctrine — so this harvest costs a dictionary copy of nothing. - // - // The exception is a board the **decision surface repaired** (01-storage-format.md - // § Malformed input): those writes happened before this board had a store at all, and their - // heal-marked receipts were adopted into the store's ledger a moment ago - // (`EchoLedger.adopt`, `BoardWindowHost`). Without this line the only harvest is at a write - // bracket's close, and no bracket has closed — so the debounce this arms would find the - // repaired files unvouched-for and author the app's own repair `Lanework External`, which is - // the one misattribution the mechanism exists to prevent. - harvest() - arm() - } - - /// Stops the engine and forgets the window. Called at teardown so a closed board's debounce - /// cannot fire against a store that has gone. - public func stop() { - pending?.cancel() - pending = nil - } - - // MARK: - Inbound signals - - /// **A write bracket closed** — harvest, then arm. - /// - /// The harvest is the whole reason this signal exists separately from the reload: receipts - /// describe a completed write and are consumed by the landing reload that classifies them, so - /// this is the only moment at which the committer can still see them (`HarvestedReceipt`). - public func noteWriteBracketClosed() { - harvest() - // The snapshot the composer diffs is now known to be behind the tree until a reload lands — - // see `holdsUncoveredWrites` and `awaitCoveringSnapshot()`. - holdsUncoveredWrites = true - arm() - } - - /// **A reload landed** — the tree settled, and here is whether any of it was somebody else's. - /// - /// - Parameter sawForeignChange: what the landing reload's own `EchoLedger.verdicts` concluded. - /// It arms flush-before-overwrite and nothing else; the commit split re-derives provenance per - /// *file* at flush time, because this is one bit about a whole reload. - public func noteReloadLanded(sawForeignChange: Bool) { - if sawForeignChange { holdsForeignChanges = true } - holdsUncoveredWrites = false - arm() - } - - /// **Flush-before-overwrite** (06 ▸ Rules): "before an app write overwrites on-disk state that - /// differs from the last-loaded snapshot … the pending auto-commit is flushed so the external - /// version enters history first. *Both versions exist as commits* is thereby a guarantee, not a - /// likelihood." - /// - /// ### The gate - /// - /// It fires **only when the window holds a change the app does not vouch for**. That is exactly - /// the condition under which overwriting can bury someone else's uncommitted version; a window - /// of nothing but the app's own writes has nothing to protect, and flushing there would commit - /// once per gesture and make the cadence constraint's "unbearable shared log" come true. - /// - /// ### The two costs, recorded - /// - /// **It runs on the main actor, synchronously.** `performWrite` is synchronous — it is a - /// gesture's write path — so an ordering guarantee *before* it can only be kept by a synchronous - /// commit. 02's hang-avoidance doctrine and 06's ordering guarantee genuinely conflict here, and - /// the guarantee wins for an operation that is rare (foreign change pending), bounded (one - /// stage-and-commit over a board-sized tree), and load-bearing (the alternative is losing a - /// version of somebody's file with no commit to recover it from). - /// - /// **The semantic composer widened that bound**, and it is recorded rather than discovered: this - /// flush now also materializes HEAD's tree and reads it back through `BoardLoader` - /// (`composition(for:input:)`), so the synchronous cost is a few board-sized walks rather than - /// one. Still bounded and still rare — and the alternative, a placeholder message on exactly the - /// commit that preserves somebody else's version, would be the worst message in the trail. - /// - /// **A foreign write the watcher has not delivered yet is invisible to it.** The gate learns - /// about foreign changes from landed reloads, so a write that lands inside the watcher's own - /// debounce is not yet known to be pending. Bounded by that debounce, and the same window - /// 05-card-window.md's dirty-buffer rule already calls last-writer-wins — but it is a real gap in - /// "guarantee", and it is recorded here rather than discovered later. - public func noteWillWrite() { - guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return } - isFlushing = true - defer { endFlushing() } - pending?.cancel() - pending = nil - // One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means - // the foreign version commits on the next quiet debounce instead — which is the same - // "re-debounce" answer contention gets everywhere else. - let result = Self.execute(input) - apply(result.outcome, healPaths: result.healPaths) - } - - // MARK: - Card-window sessions - - /// **Registers an open card window's folder** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31 — - /// "Board history sees **card-window sessions, not gestures**"): - /// - /// > while a card's window is open, everything happening inside it — the body editor's ~700 ms - /// > crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes — - /// > stays **uncommitted**, and the committer **stages around the whole open card folder** (the - /// > former Edit-session stage-around, widened; comments included). - /// - /// So the unit is the **window**, not the body's Edit session: the token is minted when the - /// window joins its board and released when its session ends, and everything the window writes in - /// between — body saves, comment posts and deletes, inline comment edits, the composer's draft, - /// the `comments/.trash/` purge — is inside one folder that no interim flush can see. - /// - /// The exclusion is absolute where it applies: "whole-root staging widening *what* commits, never - /// overriding the exclusion" (06 ▸ Commit messages ▸ Non-snapshot files commit too). A stray - /// dropped inside the session card's folder therefore waits for the session to end, along with - /// everything else under it — a foreign write to the same card included, which is what makes the - /// close flush's two-commit split the *first* moment that change can land (06 ▸ Rules ▸ - /// Auto-commit: "The EchoLedger's two-commit split still applies at close when the held window - /// mixes foreign changes to that card with the app's own"). - /// - /// - Parameters: - /// - token: the window's identity, so ending twice is idempotent. - /// - cardFolder: asked at every flush rather than stored, so a card moved mid-session is staged - /// around at wherever it now is. - public func beginCardSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) { - cardSessions[token] = cardFolder - } - - /// Ends one, and **nudges** — which is what makes "window close flushes the session as one - /// commit" true: the session's writes committed nothing while the window stood, and this is the - /// moment its whole diff becomes committable (06 ▸ Rules ▸ Auto-commit). - /// - /// Called after the session's own last writes have landed (`CardWindowSession.endSession()` runs - /// to completion first — `AppModel.unregisterCardWindow`), so the diff this arms over is the - /// session's *final* state rather than its second-to-last. - public func endCardSession(_ token: UUID) { - guard cardSessions.removeValue(forKey: token) != nil else { return } - arm() - } - - // MARK: - The pause, asked for - - /// **Re-reads the repository's state without attempting anything** — what the popover's git - /// section calls when it appears (06 ▸ Rules ▸ Abnormal repo states: "the popover's git section - /// names the state plainly"). - /// - /// The engine learns about a pause by *trying to commit* and being held, which is the right - /// cadence for committing and the wrong one for a surface: a board opened into a detached HEAD - /// would show live branch controls for as long as the debounce takes to fire. This is the same - /// read the flush takes (`GitCommitOperation.reading`), asked by a surface instead of by a write, - /// and it changes nothing else — no arming, no retry, no commit. - /// - /// A flush landing while this is in flight wins, which is correct: it read the repository later - /// and it read it in order to write. - public func refreshPause() async { - let root = boardRoot - let read = await Task.detached(priority: .userInitiated) { - GitCommitOperation.reading(at: root).pause - }.value - setPause(read) - } - - /// **The detection-time probe's answer, seeded before anything has been attempted** - /// (06-history-undo.md ▸ Rules, the corrupt-`.git` loud failure: "a standing breakage-class - /// banner **at detection**"). - /// - /// The engine's ordinary way of learning a pause is to try to commit and be held, which is the - /// right cadence for committing and far too late for this one: the ruling's whole point is that - /// the failure is loud at the open rather than discovered a debounce later — or, worse, only in - /// the popover. `HistoryStore`'s composition probes (`GitRepository.canOpen`) and calls this. - /// - /// It is deliberately the *same* state a held flush would have reached, not a parallel flag: one - /// pause, one surface, and the first re-read either confirms it or heals it. - public func noteRepositoryUnreadable() { - setPause(.unreadable) - } - - /// The one place `pause` is assigned, so the raise-and-heal seam cannot be forgotten by a path - /// that sets it (`reportRepositoryUnreadable`). Fires on the transition only — entering - /// `.unreadable` from anything else, or leaving it for anything else, `nil` included. - private func setPause(_ new: GitRepositoryPause?) { - let was = pause == .unreadable - pause = new - let now = new == .unreadable - guard was != now else { return } - reportRepositoryUnreadable?(now) - } - - /// Whether a card window's folder is currently staged around — the stage-around rule, made - /// assertable without reaching into private state. - public var stagedAroundFolders: [URL] { - cardSessions.values.compactMap { $0() } - } - - // MARK: - Flushing - - /// **Commits now**, cancelling the debounce — the close/quit path, and File ▸ Duplicate's - /// pending-work step. - /// - /// 02-architecture.md § Windows fixes where it sits: "closing a board window (and app quit) first - /// closes the board's card windows — each open Edit session ends with its normal session commit — - /// then flushes pending debounced work, editor saves before the pending auto-commit, before the - /// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the - /// sessions have ended, so nothing is staged around any more. - public func flushNow() async { - await awaitCoveringSnapshot() - // **Queued behind an in-flight flush, never skipped** — see `awaitFlushInFlight()`. - await awaitFlushInFlight() - await flush() - } - - /// **Suspends until no flush is running** — what makes `flushNow()` a promise rather than an - /// attempt (06-history-undo.md ▸ Rules ▸ Auto-commit: "nothing settled is ever left unsaved or - /// uncommitted by closing"). - /// - /// ### The bug this exists for - /// - /// `flush()` skips when one is already running, which is exactly right for the **debounce** — a - /// timer firing into a commit already in progress has nothing to add, and coalescing is the - /// cadence rule. It was catastrophically wrong for the **explicit** flush, which is the close - /// flush, the quit flush, the branch switch's pre-checkout flush and File ▸ Duplicate's: those - /// callers are not asking for a commit *soon*, they are asking to be told when the pipeline is - /// empty, and a `return` gave them that answer while it was still full. - /// - /// It was reachable, and by a *narrow* margin in one direction and a wide one in the other. The - /// close sequence releases each session's stage-around and then nudges the committer - /// (`endCardSession`), which arms a fresh debounce; `CloseFlushCoordinator` then spends up to its - /// card-drain deadline before reaching `committerFlush`. With the two intervals both at two - /// seconds the debounce fired *into* the drain's last moments about half the time — and the flush - /// it started had, in the worst case, planned its commit while the session's folder was still - /// staged around. So the in-flight flush committed nothing of the session, the close flush skipped - /// behind it, and teardown stopped the committer: the window's whole session was left uncommitted, - /// permanently, with no later flush anywhere that could have picked it up. Even in the benign - /// interleaving `closeBoard` returned — and at quit, `applicationShouldTerminate` replied — while - /// the commit was still detached work in flight. - /// - /// ### The shape - /// - /// A queue of waiters rather than a lock, `BoardStore.awaitQuiescence()`'s own shape and for its - /// reason: this type is `@MainActor`, so there is no data race to exclude — only a *suspension* to - /// wait out — and the thing a caller wants is "tell me when it is over", which is what a resumed - /// continuation is. The loop re-checks rather than trusting one resumption, so a flush that armed - /// another on its way out cannot slip between the resume and the caller's own attempt. - private func awaitFlushInFlight() async { - while isFlushing { - await withCheckedContinuation { flushWaiters.append($0) } - } - } - - /// Ends one flush and releases whoever was queued behind it. The single exit for both flushing - /// paths — the debounced one and the synchronous flush-before-overwrite — so a waiter can never be - /// left suspended by a path that forgot it. - private func endFlushing() { - isFlushing = false - let waiters = flushWaiters - flushWaiters.removeAll() - for waiter in waiters { waiter.resume() } - } - - /// **The flush awaits the snapshot that covers it** (06-history-undo.md ▸ Rules ▸ Auto-commit, - /// ruled 2026-07-31). - /// - /// > "The composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot - /// > generation covering its changed paths before the committer runs — the commit's subject can - /// > never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the - /// > practical cushion, never the guarantee." - /// - /// ### What "covering its changed paths" means to this store - /// - /// A reload is a **whole tree walk** — the store has no changed-path channel at all - /// (02-architecture.md; `BoardStore.refreshCommentIndex`'s own note) — so a walk that *started* - /// after this flush's writes were on disk covers every path they touched, by construction. There - /// is nothing narrower to ask for and nothing narrower to wait on, and that is what makes the - /// generation counter a sufficient answer rather than an approximation of one. - /// - /// Two steps, in this order, are what turn it into a guarantee: - /// - /// 1. **Quiesce.** A walk already in flight may have started *before* the writes, so its landing - /// proves nothing. `BoardStore.awaitQuiescence()` returns when none is running and none is - /// owed, which is the moment after which every walk is a walk that started later. - /// 2. **Wait for one generation.** The write bracket that produced these changes already - /// scheduled the reload that will supply it — unconditionally, whether or not FSEvents said - /// anything (`FolderWatcher.endBracket`) — so this is a bounded wait on work already in the - /// pipeline, not a hope. - /// - /// ### Why only the explicit flush - /// - /// This is `flushNow()`'s alone: the close and quit paths, the branch switch's pre-checkout flush, - /// File ▸ Duplicate's pending-work step, and the undo restore's. Those are the flushes that run - /// *because* something just finished, which is exactly when the snapshot can still be one walk - /// behind. The debounced flush is re-armed by both the write and the reload and fires two seconds - /// after the later of them — 06's own "practical cushion", doing the job it is enough for — and - /// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite. - private func awaitCoveringSnapshot() async { - guard holdsUncoveredWrites, let read = landedReloads else { return } - await awaitReloadQuiescence?() - // Re-read the gate: the quiescence may itself have been the covering landing. - guard holdsUncoveredWrites, let base = read() else { return } - - let started = ContinuousClock.now - while let current = read(), current == base { - guard ContinuousClock.now - started < coveringSnapshotDeadline else { - Self.logger.notice("the covering reload did not land in time; composing from the snapshot in hand") - return - } - try? await Task.sleep(for: coveringSnapshotPollInterval) - } - } - - /// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and - /// typing churn" is one timer rather than a rule each call site remembers. - private func arm(after interval: Duration? = nil) { - pending?.cancel() - let delay = interval ?? debounceInterval - pending = Task { [weak self] in - try? await Task.sleep(for: delay) - guard !Task.isCancelled, let self else { return } - self.pending = nil - await self.flush() - } - } - - /// One flush. **Skipping when one is already running is the debounce's rule and only the - /// debounce's** — an explicit `flushNow()` has already waited its turn (`awaitFlushInFlight()`) - /// before it gets here, so this guard can only ever coalesce a timer. - private func flush() async { - guard !isFlushing else { return } - isFlushing = true - defer { endFlushing() } - pending?.cancel() - pending = nil - - guard let input = makeInput() else { return } - - // The brief backoff. Off the main actor for the git work, on it for the sleep, so a held - // lock costs a couple of suspended turns rather than a blocked UI. - for attempt in 0...max(0, lockRetryAttempts) { - let result = await Task.detached(priority: .utility) { Self.execute(input) }.value - if case .locked = result.outcome, attempt < max(0, lockRetryAttempts) { - try? await Task.sleep(for: lockRetryDelay) - continue - } - apply(result.outcome, healPaths: result.healPaths) - return - } - } - - // MARK: - The plan - - /// Everything one flush needs, as values — so the whole of it can cross to a detached task. - private struct FlushInput: Sendable { - let boardRoot: URL - let excludedFolders: [String] - let receipts: [String: HarvestedReceipt] - let composer: any ChangeNarrating - let snapshot: BoardModel? - } - - private func makeInput() -> FlushInput? { - FlushInput( - boardRoot: boardRoot, - excludedFolders: stagedAroundKeys, - receipts: harvested, - composer: composer, - snapshot: currentSnapshot?() - ) - } - - /// What one flush concluded — the outcome, plus the paths it committed as heal work. - /// - /// The second half exists for `reportLanded`: heal paths are known only inside the split, which - /// runs here, and the stack that needs them lives on the main actor. - private struct FlushOutput: Sendable { - let outcome: GitCommitOutcome - var healPaths: Set = [] - } - - /// **One whole flush**, off the main actor: read the state, list the tree's changes, stage around - /// the open sessions, split by provenance, compose, commit. - private nonisolated static func execute(_ input: FlushInput) -> FlushOutput { - let reading = GitCommitOperation.reading(at: input.boardRoot) - if let pause = reading.pause { return FlushOutput(outcome: .held(pause)) } - if reading.isIndexLocked { return FlushOutput(outcome: .locked) } - - // `nil` is "the survey could not be taken" — an unwritable object store, a corrupt index — - // and it must not read as a clean tree: that would no-op silently and let history stop - // advancing with nothing on the banner strip (06 ▸ Interaction with external writers, the - // genuine-failure clause). - guard let surveyed = GitCommitOperation.surveyChangedPaths(at: input.boardRoot) else { - return FlushOutput(outcome: .failed(GitOperationFailure( - operation: "Recording this board's history", - message: "this board's repository could not be read" - ))) - } - let changed = surveyed - .filter { !isExcluded($0.path, under: input.boardRoot, by: input.excludedFolders) } - guard !changed.isEmpty else { return FlushOutput(outcome: .nothingToCommit) } - - let commits = plan( - changed, - reading: reading, - input: input, - composition: composition(for: changed, input: input) - ) - return FlushOutput( - outcome: GitCommitOperation.perform(at: input.boardRoot, commits: commits), - healPaths: Set(commits.filter { $0.kind == .heal }.flatMap(\.paths)) - ) - } - - // MARK: - What the composer is handed - - /// **The composer's environment, resolved once per flush** (06 ▸ Commit messages: "a structural - /// diff of two board snapshots — last-committed vs. current"). - /// - /// Once per *flush*, not once per planned commit: a window that splits three ways - /// (foreign → heal → user) composes all three messages against the same HEAD, so materializing - /// HEAD's tree three times would be three answers to one question. Each message is then narrowed - /// to its own commit by `ChangeNarrationRequest.changedPaths`, which the split already narrows. - private struct Composition: Sendable { - var previous: BoardModel? - var current: BoardModel? - var agentGuideText: String? - var commentTimestamps: [String: Date] = [:] - } - - /// Reads the two snapshots and the guide's bytes — the only impure step in the message path, kept - /// here so `ChangeNarrator` can be a pure function of values. - /// - /// **Skipped entirely when nothing in the window could touch the model**, which is the ordinary - /// stray-only and guide-only window: those compose path-shaped events, and materializing a board - /// twice to describe a changed `.gitignore` would be work with no reader. - private nonisolated static func composition( - for changed: [ChangedPath], - input: FlushInput - ) -> Composition { - var composition = Composition() - if changed.contains(where: { $0.path == AgentGuide.filename }) { - composition.agentGuideText = try? String( - contentsOf: input.boardRoot.appendingPathComponent(AgentGuide.filename), - encoding: .utf8 - ) - } - // **The comment family needs a board but not a diff.** Comments are outside the snapshot - // entirely (01-storage-format.md § Enhanced schema), so HEAD's tree has nothing to say about - // them — but the verbs name the *card* ("Comment on 'Fix login'"), and only a board knows a - // card's title. So a comment-only window loads the current board and skips the materialization. - let touchesModel = changed.contains { ChangeNarrator.Paths.mightAffectSnapshot($0.path) } - let namesACard = changed.contains { CommentPath.classify($0.path) != nil } - // **The chronology the bullets sort by** (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31) — the - // one field of a comment the composer needs and the board snapshot cannot carry. Read beside - // the guide's bytes, for the guide's reason, and only for a window that names a comment at all. - if namesACard { - composition.commentTimestamps = commentTimestamps(for: changed, boardRoot: input.boardRoot) - } - guard touchesModel || namesACard else { return composition } - - // **The store's snapshot when there is one, disk when there is not.** A storeless committer is - // a real configuration (`HistoryStore.compose` without a session, every engine-level test), and - // a composer handed no current board could only ever shrug. Loading here rather than in - // `makeInput` keeps the read off the main actor, where every other read in this flush already - // is. - composition.current = input.snapshot ?? (try? BoardLoader.load(boardRoot: input.boardRoot).model) - guard touchesModel else { return composition } - composition.previous = GitHeadSnapshot.load(at: input.boardRoot) - return composition - } - - /// **When each comment this window touched was created**, keyed by its folder — the chronology - /// `ChangeNarrator` sorts a commit's comment bullets by (06 ▸ Rules ▸ Auto-commit, blessed - /// 2026-07-31: "by the comments' own `created`, folder name on ties"). - /// - /// One `index.md` per touched comment folder, read off the **working tree** — which is the state - /// this commit is about to stage, and the only place a comment's own fields exist at all. A folder - /// this window *removed* (the close purge) has nothing left to read, and a comment whose - /// frontmatter does not parse or carries no `created` answers nothing either: all three are - /// absent from the map and sort after their dated siblings, which is `CommentThread.sorted`'s own - /// fallback for the same field. Nothing here is a defect and nothing is reported — a commit - /// message is the wrong place to discover one (`CommentThread.searchableBodies`' rule, kept). - /// - /// Internal rather than private so the composer's own suite can resolve the chronology exactly the - /// way a flush does, instead of hand-assembling a map the flush could never produce - /// (`WriterFixture.snapshot()`'s reason, restated one field down). - nonisolated static func commentTimestamps( - for changed: [ChangedPath], - boardRoot: URL - ) -> [String: Date] { - var timestamps: [String: Date] = [:] - var seen: Set = [] - for path in changed { - guard let folder = ChangeNarrator.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 three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes. - private nonisolated static func plan( - _ changed: [ChangedPath], - reading: GitRepositoryReading, - input: FlushInput, - composition: Composition - ) -> [PlannedCommit] { - let user = GitCommitOperation.userIdentity(at: input.boardRoot) - - func request( - _ paths: [ChangedPath], - _ authorship: ChangeAuthorship, - isRootCommit: Bool = false - ) -> ChangeNarrationRequest { - ChangeNarrationRequest( - boardRoot: input.boardRoot, - changedPaths: paths, - authorship: authorship, - isRootCommit: isRootCommit, - snapshot: composition.current, - previousSnapshot: composition.previous, - agentGuideText: composition.agentGuideText, - commentTimestamps: composition.commentTimestamps - ) - } - - // **The root commit is not split** (06 ▸ Rules ▸ Abnormal repo states): "it commits the whole - // tree as *Initial board state*, never a folded diff-from-empty: there is no last-committed - // snapshot to diff against". Splitting a repository's first commit three ways by the - // provenance of files that mostly predate the app knowing about them would be a fiction; the - // whole tree arriving at once is the event, and it is the user's own opt-in that caused it, - // so it is authored as the user. (Recorded as a judgment call: DESIGN fixes the subject and - // the shape, not the author.) - guard !reading.isUnborn else { - return [PlannedCommit( - paths: changed.map(\.path), - message: input.composer.narrative(for: request(changed, .user, isRootCommit: true)), - author: user, - committer: user, - kind: .root - )] - } - - let split = CommitAttribution.split(changed, under: input.boardRoot, receipts: input.receipts) - return split.ordered.map { group in - // One combined switch over `group.kind`, producing both `authorship` (what the message - // seam is allowed to know) and `author` (who the commit is actually by) — the foreign - // branch resolves the identity once and both derive from it. - let authorship: ChangeAuthorship - let author: GitIdentity - switch group.kind { - case .foreign: - let identity = CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot) - authorship = .foreign(identity.name) - author = identity - // **A heal is authored `Lanework Integrity `** (06 ▸ Commit - // messages ▸ Healing mutations commit separately, ruled 2026-07-31): "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". This authored heals as the - // *user* until that ruling, which left the separate commit filterable only by message - // shape — and the shape vocabulary deliberately never says "healed". - case .heal: - authorship = .heal - author = CommitAttribution.integrityIdentity - case .user: - authorship = .user - author = user - } - let kind: PlannedCommitKind - switch group.kind { - case .foreign: kind = .foreign - case .heal: kind = .heal - case .user: kind = .user - } - // The committer stays the user throughout — 06's recorded-by convention, which is why - // only the author varies above. - return PlannedCommit( - paths: group.paths.map(\.path), - message: input.composer.narrative(for: request(group.paths, authorship)), - author: author, - committer: user, - kind: kind - ) - } - } - - /// Whether a changed path lives inside a folder staged around. - private nonisolated static func isExcluded( - _ relativePath: String, - under boardRoot: URL, - by folders: [String] - ) -> Bool { - guard !folders.isEmpty else { return false } - let absolute = EchoLedger.key(boardRoot.appendingPathComponent(relativePath)) - return folders.contains { absolute == $0 || absolute.hasPrefix($0 + "/") } - } - - // MARK: - Outcomes - - private func apply(_ outcome: GitCommitOutcome, healPaths: Set = []) { - switch outcome { - case let .committed(landed): - let oids = landed.map(\.oid) - setPause(nil) - lastFailure = nil - commitCount += oids.count - lastCommitOIDs = oids - // **The stack hears about every commit this engine lands** (06 ▸ Rules ▸ The stack is - // HEAD's first-parent ancestry, live), *before* the receipts that describe them are - // cleared below — the heal class exists only for as long as they do. - reportLanded?(GitLandedWindow(commits: landed, healPaths: healPaths)) - // The window is over: its receipts have said everything they can say, and keeping them - // would let them vouch for the *next* window's changes to the same paths. - dropHarvestOutsideOpenSessions() - holdsForeignChanges = false - reportRecovery?() - Self.logger.debug("auto-commit landed \(oids.count, privacy: .public) commit(s)") - - case .nothingToCommit: - // **The happy path, not a malfunction** (06): an agent committed its own work, or the - // whole window was staged around. Silent, and the window closes either way. - setPause(nil) - lastFailure = nil - dropHarvestOutsideOpenSessions() - holdsForeignChanges = false - reportRecovery?() - - case .locked: - // "No banner, no log-worthy failure: a held lock is another writer doing its job." The - // changes are still pending and the harvest is still held, so the next quiet moment - // commits them with their provenance intact. - Self.logger.debug("index.lock held — re-debouncing") - arm() - - case let .held(reason): - setPause(reason) - Self.logger.notice("auto-commit held: \(reason.rawValue, privacy: .public)") - // **The standing pause's own re-read** (06 ▸ Rules ▸ Abnormal repo states, blessed - // 2026-07-31) — and the mid-session healing path for the unreadable repository too: the - // watcher never delivers `.git`, so a repository repaired in a terminal has no other way - // to be noticed before the next open. - arm(after: holdRecheckInterval) - - case let .failed(failure): - setPause(nil) - lastFailure = failure - Self.logger.error("auto-commit failed: \(failure.description, privacy: .public)") - reportFailure?(failure) - arm() - } - } - - // MARK: - Harvest - - /// Copies the ledger's current receipts into this window's own record. - /// - /// Whole-ledger rather than bracket-scoped, deliberately: the Writer's primitives drop receipts - /// without telling anyone which paths they were, and a diff of key sets would miss a - /// *supersession* (the same key, newer bytes) — which is exactly the case that must not be - /// missed, since the newest write is the one disk will be compared against. Copying is cheap: - /// the ledger holds tens of entries, and the harvest happens once per gesture. - private func harvest() { - for (path, entry) in ledger.outstandingEntries() { - harvested[path] = entry - } - } - - /// **Forgets the receipts a flush has spent — and keeps the ones it could not** (06 ▸ Interaction - /// with external writers: attribution "per file", off the ledger). - /// - /// A receipt is cleared because the commit it described has landed. Under the widened - /// stage-around (`beginCardSession`) a flush routinely lands *without* the session folder, so its - /// receipts have not been spent at all: they describe writes still sitting uncommitted on disk, - /// waiting for the close flush. Clearing them wholesale is what would make the two-commit split at - /// close wrong in exactly the case it exists for — the app's own body save and comment posts would - /// arrive at the close unvouched-for and commit as `Lanework External`, blaming the outside world - /// for the user's own session. - /// - /// So the drop is scoped to what the flush could see: everything outside every open session's - /// folder goes, everything inside one stays until that session's own commit spends it. - private func dropHarvestOutsideOpenSessions() { - let open = stagedAroundKeys - guard !open.isEmpty else { - harvested.removeAll() - return - } - harvested = harvested.filter { key, _ in - open.contains { key == $0 || key.hasPrefix($0 + "/") } - } - } - - /// The open sessions' folders as `EchoLedger` keys — what both the staging exclusion and the - /// harvest's scoped drop compare against, resolved in one place so they cannot disagree. - private var stagedAroundKeys: [String] { - cardSessions.values.compactMap { $0() }.map(EchoLedger.key) - } -} diff --git a/Kanban/Git/GitBranchOperation.swift b/Kanban/Git/GitBranchOperation.swift deleted file mode 100644 index 37cd7e1..0000000 --- a/Kanban/Git/GitBranchOperation.swift +++ /dev/null @@ -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? - 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?) -> 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())) - } -} diff --git a/Kanban/Git/GitBranchSwitcher.swift b/Kanban/Git/GitBranchSwitcher.swift deleted file mode 100644 index fdb7a94..0000000 --- a/Kanban/Git/GitBranchSwitcher.swift +++ /dev/null @@ -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 = [] - - /// **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) - } -} diff --git a/Kanban/Git/GitCommitOperation.swift b/Kanban/Git/GitCommitOperation.swift deleted file mode 100644 index 0ef9106..0000000 --- a/Kanban/Git/GitCommitOperation.swift +++ /dev/null @@ -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.. 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 { - _ = 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? { - var signature: UnsafeMutablePointer? - 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?) -> 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)) - } -} diff --git a/Kanban/Git/GitHeadSnapshot.swift b/Kanban/Git/GitHeadSnapshot.swift deleted file mode 100644 index 80350c1..0000000 --- a/Kanban/Git/GitHeadSnapshot.swift +++ /dev/null @@ -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.. 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 - } - } - } -} diff --git a/Kanban/Git/GitHistoryProvider.swift b/Kanban/Git/GitHistoryProvider.swift deleted file mode 100644 index 7f4eb0e..0000000 --- a/Kanban/Git/GitHistoryProvider.swift +++ /dev/null @@ -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) 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 = [] - - /// Paths committed as heal work in this session, which a restore never materializes. - private var healPaths: Set = [] - - /// 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? - - /// **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 = [] - 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 = [] - - /// **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.. 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 - } -} diff --git a/Kanban/Git/GitHistoryWalk.swift b/Kanban/Git/GitHistoryWalk.swift deleted file mode 100644 index 1e84428..0000000 --- a/Kanban/Git/GitHistoryWalk.swift +++ /dev/null @@ -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 - } -} diff --git a/Kanban/Git/GitHousekeeping.swift b/Kanban/Git/GitHousekeeping.swift deleted file mode 100644 index 460c825..0000000 --- a/Kanban/Git/GitHousekeeping.swift +++ /dev/null @@ -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-.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//<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? - 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? - - 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 - } -} diff --git a/Kanban/Git/GitIdentity.swift b/Kanban/Git/GitIdentity.swift deleted file mode 100644 index f5fc982..0000000 --- a/Kanban/Git/GitIdentity.swift +++ /dev/null @@ -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 ` and `modified-by`-stamped windows as -/// `@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 `"" ` 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.. 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, 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.. [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.. String { - if value.hasPrefix("\"") { - let body = value.dropFirst() - guard let closing = body.firstIndex(of: "\"") else { return String(body) } - return String(body[body.startIndex.. GitOperationRecovery { - guard let stamp else { return .nothingToDo } - guard pause != .unreadable else { return .nothingToDo } - guard pause != nil else { return .clearStamp } - return .abort(stamp) - } -} diff --git a/Kanban/Git/GitPathHistory.swift b/Kanban/Git/GitPathHistory.swift deleted file mode 100644 index d571964..0000000 --- a/Kanban/Git/GitPathHistory.swift +++ /dev/null @@ -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] - } - } -} diff --git a/Kanban/Git/GitRepository.swift b/Kanban/Git/GitRepository.swift deleted file mode 100644 index 08c594e..0000000 --- a/Kanban/Git/GitRepository.swift +++ /dev/null @@ -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 { - 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) - } -} diff --git a/Kanban/Git/GitRestoreOperation.swift b/Kanban/Git/GitRestoreOperation.swift deleted file mode 100644 index 23a2a76..0000000 --- a/Kanban/Git/GitRestoreOperation.swift +++ /dev/null @@ -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 = [], - reconciling folderNames: Set = [] - ) -> 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 - // `/`, and every live card has a lane above it, so treating the name as a path - // matched nothing at all and made the whole Discard branch silently inert. - let reconciling = folderPaths(named: folderNames, at: boardRoot) - - guard let targetTree = tree(of: target, in: repository) else { return nil } - defer { git_tree_free(targetTree) } - var wanted: [String: git_oid] = [:] - fileMap(of: targetTree, in: repository, prefix: "", depth: 0, into: &wanted) - - var current: [String: git_oid] = [:] - if let headTree = headTree(of: repository) { - defer { git_tree_free(headTree) } - fileMap(of: headTree, in: repository, prefix: "", depth: 0, into: ¤t) - } - - // The reconciled folders answer from disk instead: their committed state is beside the point, - // because what is being discarded is exactly what is *not* committed. - if !reconciling.isEmpty { - for folder in reconciling { - current = current.filter { !isInside($0.key, folder: folder) } - } - for path in workingTreeFiles(under: reconciling, at: boardRoot) { - // A sentinel oid nothing can equal: the comparison below only ever asks "same or - // different", and a working-tree file's bytes are not addressed by the object store. - current[path] = git_oid() - } - } - - var changes: [GitRestorePlan.Change] = [] - for (path, oid) in wanted.sorted(by: { $0.key < $1.key }) { - guard !excluding.contains(path) else { continue } - if let held = current[path], equal(held, oid), !isInside(path, folders: reconciling) { continue } - guard let data = blob(oid, in: repository) else { continue } - changes.append(GitRestorePlan.Change(path: path, contents: data)) - } - for path in current.keys.sorted() where wanted[path] == nil { - guard !excluding.contains(path) else { continue } - changes.append(GitRestorePlan.Change(path: path, contents: nil)) - } - return GitRestorePlan(changes: changes.sorted { $0.path < $1.path }) - } - - // MARK: - Applying - - /// **Writes the plan and commits it** — one new commit on the current branch, nothing rewound. - /// - /// The commit goes through `GitCommitOperation.perform` unchanged, so it takes the ordinary - /// signature path (06 ▸ Interaction with external writers) and is authored by the user: a restore - /// is the user acting through the app, whatever the origin of the commit it crosses. - /// - /// A plan that turns out to write nothing new commits nothing — `perform`'s own empty-tree skip — - /// and answers `.nothingToCommit`, which the caller reads as "the step was crossed and needed no - /// bytes", not as a failure. - nonisolated static func apply( - _ plan: GitRestorePlan, - at boardRoot: URL, - message: String - ) -> GitCommitOutcome { - _ = startUp - guard !plan.isEmpty else { return .nothingToCommit } - - if let failure = materialize(plan, at: boardRoot) { - return .failed(failure) - } - - let identity = GitCommitOperation.userIdentity(at: boardRoot) - return GitCommitOperation.perform( - at: boardRoot, - commits: [PlannedCommit( - paths: plan.paths, - message: message, - author: identity, - committer: identity, - kind: .user - )], - allowRootCommit: false - ) - } - - /// **The writes, without the commit** — the plan materialized onto disk. `nil` means every change - /// landed. - /// - /// Split out of `apply` for the branch switch's Discard branch (`revertToHead(folders:at:)`), - /// which needs the bytes moved and emphatically does *not* want a commit attempted over them. - nonisolated static func materialize(_ plan: GitRestorePlan, at boardRoot: URL) -> GitOperationFailure? { - let manager = FileManager.default - for change in plan.changes { - let url = boardRoot.appendingPathComponent(change.path) - guard let contents = change.contents else { - try? manager.removeItem(at: url) - pruneEmptyFolders(above: url, upTo: boardRoot) - continue - } - let folder = url.deletingLastPathComponent() - do { - try manager.createDirectory(at: folder, withIntermediateDirectories: true) - try contents.write(to: url, options: .atomic) - } catch { - logger.error("restore could not write \(change.path, privacy: .public)") - return GitOperationFailure( - operation: operationName, - message: (error as NSError).localizedDescription - ) - } - } - return nil - } - - /// **"Discard reverts buffers and uncommitted saves to HEAD"** (06-history-undo.md ▸ Branch - /// switching) — the *uncommitted saves* half, for the operation that has no restore plan to fold - /// it into. - /// - /// An undo restore reconciles a discarded card's folder inside its own plan, because it is - /// materializing a target state anyway and one pass that does both cannot disagree with itself. A - /// branch switch materializes nothing — libgit2's checkout does the moving — so the discard has to - /// be its own step, and it has to run **before** the pending auto-commit is flushed: `discard` - /// ends the Edit session, which un-stages-around the card's folder, so a flush over a folder still - /// holding those saves would commit exactly the text the user just asked to lose. - /// - /// It is expressed as a restore *to HEAD* with the folders reconciled against the working tree, - /// which is the same machinery under a different target: every path outside those folders compares - /// HEAD against HEAD and produces nothing, and inside them the working tree's own files are what - /// the plan replaces. Nothing is committed — by construction there is nothing new to commit, since - /// the tree afterwards is HEAD's. - /// - /// Answers whether the revert ran cleanly; `false` is a repository that could not be read, which - /// the caller reports as its operation's clean failure. - /// - /// - Parameter folderNames: card **folder names** — the ids `SettleableSession.cardFolderName` - /// carries, not paths. Resolved against the tree here for that property's own reason: "a card's - /// own folder component never changes, only the lane above it", so a session that began before a - /// lane move is still matched afterwards. - nonisolated static func revertToHead(folderNames: Set, 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, at boardRoot: URL) -> Set { - guard let walker = FileManager.default.enumerator( - at: boardRoot, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsPackageDescendants] - ) else { return [] } - - var found: Set = [] - 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.. 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, 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) -> 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 - } -} diff --git a/Kanban/Git/HistoryCommitSeam.swift b/Kanban/Git/HistoryCommitSeam.swift deleted file mode 100644 index 6e9cc59..0000000 --- a/Kanban/Git/HistoryCommitSeam.swift +++ /dev/null @@ -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) } - ) - } -} diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift deleted file mode 100644 index d50b6ff..0000000 --- a/Kanban/Git/HistoryStore.swift +++ /dev/null @@ -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 - } -} diff --git a/Kanban/LiveStore/EchoLedger.swift b/Kanban/LiveStore/EchoLedger.swift index 692ce20..bd4c83a 100644 --- a/Kanban/LiveStore/EchoLedger.swift +++ b/Kanban/LiveStore/EchoLedger.swift @@ -2,6 +2,46 @@ import CryptoKit import Foundation import Synchronization +// MARK: - Harvested receipts + +/// **One EchoLedger receipt, copied out for a provenance consumer** (02-architecture.md ▸ +/// Components ▸ EchoLedger; 06-history-undo.md ▸ Interaction with external writers). +/// +/// Relocated 2026-08-08 from `CommitAttribution.swift` with the git excision — the harvest surface +/// outlives its git consumer; it is foundation for the deferred foreign-change journal +/// (`strategy/01-git-excision.md`). +/// +/// ### Why a copy and not a read +/// +/// The ledger's receipts are **consumed** by the landing reload that classifies them — "one write, +/// one echo", which is what buys the announcer its silence. A consumer asking its question a +/// debounce later finds every receipt for the user's own card edit already gone; reading the live +/// ledger at that point would misattribute the user's own work to a foreign writer, which is the +/// one misattribution this whole mechanism exists to prevent. +/// +/// So a consumer harvests at the **close of each write bracket** — the moment a receipt describes +/// a completed write and nothing has had a chance to consume it — and keeps its own copy for the +/// life of the debounce window. Supersession still works: a later bracket's harvest overwrites the +/// same key with the newer hash, exactly as the ledger's own `recordWrite` does. +/// +/// The satisfaction check stays the ledger's rule, re-applied against disk at attribution time, so +/// the two races 02 settles land the same way here: byte-identical foreign bytes over a fresh app +/// write classify app-mediated, and a foreign edit that misses the hash classifies foreign. +public struct HarvestedReceipt: Sendable, Equatable { + + public let receipt: EchoLedger.Receipt + + /// **Whether the write that dropped it was a heal** — the flag 06-history-undo.md (ruled + /// 2026-07-29) keys the third commit class on: "a debounce window holding a scheduled heal's + /// changes alongside anyone else's splits the heal's paths into their own commit". + public let isHeal: Bool + + public init(receipt: EchoLedger.Receipt, isHeal: Bool) { + self.receipt = receipt + self.isHeal = isHeal + } +} + // MARK: - EchoLedger /// **What the app wrote, so a landing reload can tell its own echo from someone else's edit** — diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift deleted file mode 100644 index 7cf0233..0000000 --- a/KanbanTests/AutoCommitTests.swift +++ /dev/null @@ -1,1376 +0,0 @@ -import Foundation -import SwiftGitX -import Testing -@testable import Kanban - -/// **The auto-commit engine** (06-history-undo.md ▸ Rules ▸ Auto-commit; ▸ Interaction with external -/// writers) — the debounce, the stage-around, the two-commit split, the abnormal-state hold, and the -/// contention posture. -/// -/// Every repository here is a **real** one, built through the app's own add-git over bundled -/// libgit2, and every commit is read back through libgit2 rather than through the engine that made -/// it. Nothing shells out to `git`: there is no `/usr/bin/git` in the promise this feature makes, so -/// there is none in its tests either (`HistoryStoreTests`' rule, kept). - -// MARK: - Fixtures - -private func makeBoard() throws -> WriterFixture { - let fixture = try WriterFixture() - try fixture.item("", Item.board) - try fixture.item(Ident.lane1, plain(order: "1024", title: "Todo")) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First")) - return fixture -} - -/// A board with a repository and a root commit already in it — the state every board is in a -/// moment after add-git, and the state the engine actually runs in. -/// -/// The ledger is the test's own and is handed to `compose`, exactly as `AppModel.beginSession` hands -/// it the session store's: a test that wants to say "the app wrote this" drops a receipt into it the -/// way a `performWrite` bracket would, rather than reaching into private state. -@MainActor -private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) { - let fixture = try makeBoard() - let ledger = EchoLedger() - let git = HistoryStore.compose(boardRoot: fixture.root, ledger: ledger) - #expect(await git.addGit()) - return (fixture, git, ledger) -} - -/// The engine, dialled down to milliseconds — `CardBodyEditSession.debounceInterval`'s precedent: a -/// production default on the property, and the suite spending none of it. -@MainActor -private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter { - let committer = try #require(git.committer) - committer.debounceInterval = .milliseconds(20) - committer.lockRetryDelay = .milliseconds(5) - committer.holdRecheckInterval = .milliseconds(20) - return committer -} - -// MARK: Reading commits back - -private struct CommitRecord: Equatable { - let subject: String - let authorName: String - let authorEmail: String - let committerName: String -} - -/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the committer. -private func history(at boardRoot: URL, limit: Int = 32) throws -> [CommitRecord] { - let repository = try Repository.open(at: boardRoot) - guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] } - - var records: [CommitRecord] = [] - var current: Commit? = tip - while let commit = current, records.count < limit { - records.append(CommitRecord( - subject: commit.summary, - authorName: commit.author.name, - authorEmail: commit.author.email, - committerName: commit.committer.name - )) - current = (try? commit.parents)?.first - } - return records -} - -private func headSubject(at boardRoot: URL) throws -> String? { - try history(at: boardRoot).first?.subject -} - -/// Whether the working tree has anything uncommitted — the clean-tree claim, asked of git. -private func isClean(at boardRoot: URL) -> Bool { - GitCommitOperation.changedPaths(at: boardRoot).isEmpty -} - -// MARK: - The debounce - -@MainActor -@Suite("Auto-commit ▸ the debounce") -struct AutoCommitDebounceTests { - - @Test("A settled change commits, and the tree comes back clean") - func aSettledChangeCommits() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(committer.commitCount == 1) - #expect(isClean(at: fixture.root), "a flush leaves nothing dirty — branch switch depends on it") - // The message is the semantic composer's, end to end — no placeholder anywhere in the path. - #expect(try headSubject(at: fixture.root) == "Add card 'Second'") - } - - @Test("A burst of changes inside one window is one commit, not one per change") - func aBurstIsOneCommit() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // The cadence constraint (06 ▸ Rules): "one commit per drag is fine for a local undo trail - // but noisy as a shared log". Five signals, one quiet moment, one commit. - for index in 2...6 { - try fixture.item("\(Ident.lane1)/card-\(index)", plain(order: "\(index * 1024)", title: "Card \(index)")) - committer.noteReloadLanded(sawForeignChange: false) - } - await committer.flushNow() - - #expect(committer.commitCount == 1) - #expect(try history(at: fixture.root).count == 2, "the root commit and one more") - } - - @Test("The debounce fires on its own, without anyone asking for a flush") - func theDebounceFiresOnItsOwn() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - - try await waitUntil { committer.commitCount == 1 } - #expect(isClean(at: fixture.root)) - } - - @Test("A clean tree is the happy path — the debounce fires and silently does nothing") - func aCleanTreeNoOps() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // "When the debounce fires and the tree has nothing to commit — the agent already committed - // its own work — the auto-committer no-ops silently" (06 ▸ Interaction with external - // writers). No commit, no failure, no banner. - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(committer.commitCount == 0) - #expect(committer.lastFailure == nil) - #expect(try history(at: fixture.root).count == 1) - } - - @Test("A stray-only window commits — the condition is the tree, not the snapshot") - func aStrayOnlyWindowCommits() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // 06 ▸ Commit messages ▸ Non-snapshot files commit too: "a permanently dirty stray would - // break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for - // every file the model can't see". - try fixture.file("NOTES.txt", Data("scratch\n".utf8)) - try fixture.file("CLAUDE.md", Data("# Agent guide\n".utf8)) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(committer.commitCount == 1) - #expect(isClean(at: fixture.root)) - #expect(GitRepository.trackedPaths(at: fixture.root).contains("NOTES.txt")) - } - - @Test("A launch catch-up commits what was found pending at open") - func launchCatchUpCommits() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - - // The blind window: changes made while the app was not running, so nothing vouches for them. - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Landed while away")) - - let committer = try quickCommitter(git) - committer.start() - try await waitUntil { committer.commitCount == 1 } - - #expect(isClean(at: fixture.root)) - // "the app never vouches for changes it didn't witness" — the launch-catch-up doctrine. - #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail) - } -} - -// MARK: - Attribution - -@MainActor -@Suite("Auto-commit ▸ attribution and the split") -struct AutoCommitAttributionTests { - - @Test("An app-mediated change is authored by the user") - func appMediatedIsTheUser() async throws { - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - let card = fixture.url("\(Ident.lane1)/\(Ident.card1)") - let text = plain(order: "1024", title: "Renamed by the user") - try fixture.item("\(Ident.lane1)/\(Ident.card1)", text) - ledger.recordWrite(at: card.appendingPathComponent(BoardLoader.indexFileName), text: text) - - committer.noteWriteBracketClosed() - await committer.flushNow() - - let identity = GitCommitOperation.userIdentity(at: fixture.root) - let head = try #require(try history(at: fixture.root).first) - #expect(head.authorEmail == identity.email) - #expect(head.authorEmail != CommitAttribution.externalAuthorEmail) - } - - @Test("A foreign change is authored by the pinned synthetic identity") - func foreignIsLaneworkExternal() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // Nobody vouched for this: no receipt, so the ledger cannot speak for it. - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Edited by an agent")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - let head = try #require(try history(at: fixture.root).first) - // The strings are API (06): they change with the deliberateness of a schema change. - #expect(head.authorName == "Lanework External") - #expect(head.authorEmail == "external@lanework.invalid") - } - - @Test("The committer is always this machine's user, even on a foreign commit") - func theCommitterIsAlwaysTheUser() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Foreign")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - let head = try #require(try history(at: fixture.root).first) - #expect(head.committerName == GitCommitOperation.userIdentity(at: fixture.root).name) - } - - @Test("A window where every changed file carries one modified-by authors as that agent") - func modifiedByRefinesAttribution() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card1)", stamped("First", by: "claude")) - try fixture.item("\(Ident.lane1)/\(Ident.card2)", stamped("Second", by: "claude")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - let head = try #require(try history(at: fixture.root).first) - // "display name verbatim, local part slugified; the domain marks self-reported identity" (06). - #expect(head.authorName == "claude") - #expect(head.authorEmail == "claude@agents.lanework.invalid") - } - - @Test("Disagreeing stamps fall back to Lanework External") - func disagreementFallsBack() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card1)", stamped("First", by: "claude")) - try fixture.item("\(Ident.lane1)/\(Ident.card2)", stamped("Second", by: "codex")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail) - } - - @Test("One unstamped changed file demotes the whole window") - func anUnstampedFileDemotes() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card1)", stamped("First", by: "claude")) - // A stray has no frontmatter to stamp, so it is an unstamped changed file. - try fixture.file("scratch.txt", Data("notes\n".utf8)) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail) - } - - @Test("A true deletion demotes the window — a deletion leaves no file to stamp") - func aTrueDeletionDemotes() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", stamped("Second", by: "claude")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) - try fixture.item("\(Ident.lane1)/\(Ident.card1)", stamped("First", by: "claude")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail) - } - - @Test("A stamped agent move attributes by its stamp, not by its departure") - func aStampedMoveKeepsItsAttribution() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item(Ident.lane2, stamped("Doing", by: "claude")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - // What a well-behaved agent does: move the folder *and* re-stamp it (08-agent-integration.md - // teaches exactly this, because "a bare `mv` rewrites nothing … and demotes the window under - // the unstamped-file rule"). - try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)") - try fixture.item("\(Ident.lane2)/\(Ident.card1)", stamped("First", by: "claude")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - // "**A folder move is not a deletion**" — the departure must not demote the window, which it - // only cannot do if libgit2's rename detection actually pairs the two ends. - let head = try #require(try history(at: fixture.root).first) - #expect(head.authorEmail == "claude@agents.lanework.invalid") - #expect(isClean(at: fixture.root)) - } - - @Test("A bare mv with no re-stamp demotes, exactly as the guide warns") - func aBareMoveDemotes() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item(Ident.lane2, stamped("Doing", by: "claude")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - // The card's `index.md` still carries whatever the app last wrote — no stamp. - try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)") - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail) - } - - @Test("A window holding both kinds splits into two commits, foreign first") - func aMixedWindowSplits() async throws { - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // The app's own write, vouched for by a receipt. - let mine = "\(Ident.lane1)/\(Ident.card1)" - let text = plain(order: "1024", title: "Mine") - try fixture.item(mine, text) - ledger.recordWrite(at: fixture.url(mine).appendingPathComponent(BoardLoader.indexFileName), text: text) - committer.noteWriteBracketClosed() - - // Somebody else's, in the same window. - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Theirs")) - committer.noteReloadLanded(sawForeignChange: true) - - await committer.flushNow() - - #expect(committer.commitCount == 2, "never mixed — a window containing both kinds is two commits") - let log = try history(at: fixture.root) - // Newest first, so the user's commit is on top and the foreign one is its parent: "foreign - // first, then the user's overwrite" (06). - #expect(log[0].authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email) - #expect(log[1].authorEmail == CommitAttribution.externalAuthorEmail) - #expect(isClean(at: fixture.root)) - } - - @Test("Heal-marked paths commit separately from everyone else's") - func healPathsSplitOut() async throws { - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // The integrity service's own write, heal-marked (ruled 2026-07-29): "a window holding a - // scheduled heal's changes alongside anyone else's splits the heal-receipted paths into - // their own commit". - let healed = "\(Ident.lane1)/\(Ident.card1)" - let healedText = plain(order: "1024", title: "Repaired") - try fixture.item(healed, healedText) - let healedFile = fixture.url(healed).appendingPathComponent(BoardLoader.indexFileName) - ledger.recordWrite(at: healedFile, text: healedText) - ledger.markHeal(at: healedFile) - - // An ordinary app write beside it. - let ordinary = "\(Ident.lane1)/\(Ident.card2)" - let ordinaryText = plain(order: "2048", title: "Ordinary") - try fixture.item(ordinary, ordinaryText) - ledger.recordWrite( - at: fixture.url(ordinary).appendingPathComponent(BoardLoader.indexFileName), - text: ordinaryText - ) - committer.noteWriteBracketClosed() - - await committer.flushNow() - - #expect(committer.commitCount == 2, "the heal's paths commit separately — the split's third class") - #expect(isClean(at: fixture.root)) - - // **And it is authored by the third pinned synthetic** (06 ▸ Commit messages ▸ Healing - // mutations commit separately, ruled 2026-07-31): "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." - let log = try history(at: fixture.root) - let user = GitCommitOperation.userIdentity(at: fixture.root) - #expect(log[0].authorEmail == user.email, "the user's own write stays the user's") - #expect(log[1].authorName == CommitAttribution.integrityAuthorName) - #expect(log[1].authorEmail == CommitAttribution.integrityAuthorEmail) - #expect(log[1].committerName == user.name, "the committer is always the user") - } - - @Test("The app's own delete is the user's, not an agent's") - func anAppMediatedDeleteIsTheUsers() async throws { - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // The receipt for a delete sits on the *folder*, and git reports the `index.md` inside it — - // so only a walk up the folders can tell the user's own delete from an agent's `rm`. - let folder = fixture.url("\(Ident.lane1)/\(Ident.card1)") - try FileManager.default.removeItem(at: folder) - ledger.recordDeletion(at: folder) - committer.noteWriteBracketClosed() - - await committer.flushNow() - - #expect(try history(at: fixture.root).first?.authorEmail - == GitCommitOperation.userIdentity(at: fixture.root).email) - } -} - -// MARK: - The stage-around - -@MainActor -@Suite("Auto-commit ▸ staging around open card windows") -struct AutoCommitStageAroundTests { - - @Test("A lane move mid-session commits the move without touching the session card's folder") - func aLaneMoveSkipsTheSessionFolder() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") - committer.beginCardSession(UUID()) { sessionFolder } - - // The editor's ~700 ms save lands on disk, uncommitted… - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First", body: "half-typed")) - // …and a board change lands beside it. - try fixture.item("\(Ident.lane2)", plain(order: "2048", title: "Doing")) - committer.noteReloadLanded(sawForeignChange: false) - await committer.flushNow() - - #expect(committer.commitCount == 1) - // The move is in history… - #expect(GitRepository.trackedPaths(at: fixture.root) - .contains("\(Ident.lane2)/\(BoardLoader.indexFileName)")) - // …and the half-typed body is not: the tree is still dirty, by exactly one folder. - let stillPending = GitCommitOperation.changedPaths(at: fixture.root).map(\.path) - #expect(stillPending == ["\(Ident.lane1)/\(Ident.card1)/\(BoardLoader.indexFileName)"]) - } - - @Test("Whole-root staging widens what commits — it never overrides the exclusion") - func straysInsideTheSessionFolderWait() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") - committer.beginCardSession(UUID()) { sessionFolder } - - try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/diagram.txt", Data("x\n".utf8)) - try fixture.file("elsewhere.txt", Data("y\n".utf8)) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(GitRepository.trackedPaths(at: fixture.root).contains("elsewhere.txt")) - #expect(!GitRepository.trackedPaths(at: fixture.root) - .contains("\(Ident.lane1)/\(Ident.card1)/attachments/diagram.txt")) - } - - @Test("Ending the session produces exactly one commit for it") - func endingTheSessionCommitsOnce() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - let token = UUID() - let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") - committer.beginCardSession(token) { sessionFolder } - - // Three debounced saves inside one session — each a real write, none of them a commit - // ("the body editor's ~700 ms disk saves … stay uncommitted"). - for tick in 1...3 { - try fixture.item("\(Ident.lane1)/\(Ident.card1)", - plain(order: "1024", title: "First", body: "draft \(tick)")) - committer.noteWriteBracketClosed() - await committer.flushNow() - } - #expect(committer.commitCount == 0, "no save tick may become a commit") - - // The window close — "window close flushes the session as one commit" (06 ▸ Rules - // ▸ Auto-commit, widened 2026-07-31: the unit is the window, not the Edit→Preview flip). - committer.endCardSession(token) - try await waitUntil { committer.commitCount == 1 } - - #expect(committer.commitCount == 1, "exactly one commit per card-window session") - #expect(isClean(at: fixture.root)) - } - - @Test("A card that moved mid-session is staged around at wherever it now is") - func theExclusionFollowsTheCard() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // The registry holds a resolver, not a URL, so a lane move under an open session keeps the - // right folder excluded rather than the one Edit was entered in. - var lane = Ident.lane1 - committer.beginCardSession(UUID()) { fixture.url("\(lane)/\(Ident.card1)") } - - try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing")) - try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)") - lane = Ident.lane2 - - try fixture.item("\(Ident.lane2)/\(Ident.card1)", - plain(order: "1024", title: "First", body: "still typing")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(!GitRepository.trackedPaths(at: fixture.root) - .contains("\(Ident.lane2)/\(Ident.card1)/\(BoardLoader.indexFileName)")) - } - - @Test("A window whose whole change set is staged around commits nothing at all") - func anAllExcludedWindowIsANoOp() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - committer.beginCardSession(UUID()) { fixture.url("\(Ident.lane1)/\(Ident.card1)") } - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First", body: "typing")) - committer.noteWriteBracketClosed() - await committer.flushNow() - - #expect(committer.commitCount == 0) - #expect(committer.lastFailure == nil, "an empty window is a no-op, never a failure") - } - - /// **A close flush queues behind an in-flight flush rather than skipping it** (06 ▸ Rules - /// ▸ Auto-commit: "nothing settled is ever left unsaved or uncommitted by closing"). - /// - /// The interleaving is the close sequence's own, forced rather than waited for. `endCardSession` - /// releases the stage-around **and arms a fresh debounce**, and `CloseFlushCoordinator` then - /// spends its card-drain deadline before reaching `committerFlush` — two intervals that are both - /// two seconds, so in practice the debounce fired into the drain's last moments about half the - /// time. What made that a defect rather than a coin toss is what the debounced flush had already - /// planned: a commit whose exclusion list still held the session's folder. Skipping behind it left - /// the session uncommitted *permanently* — teardown stops the committer, and there is no later - /// flush anywhere. - /// - /// So the flush in flight here is deliberately one that planned **with** the exclusion, and the - /// release happens while it is still running. Before the fix this test's `flushNow()` returned - /// having done nothing and the card's body stayed dirty forever. - @Test("A flush asked for while one is in flight waits for it, and commits what it was asked to") - func anExplicitFlushIsNeverDroppedBehindAnInFlightOne() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - // The one point inside a flush that is both off the main actor and injectable: composing. - // It holds the flush open long enough for the close to arrive underneath it. - committer.composer = SlowComposer(delay: 0.4) - - let token = UUID() - committer.beginCardSession(token) { fixture.url("\(Ident.lane1)/\(Ident.card1)") } - - // The session's uncommitted work, held by the stage-around… - try fixture.item("\(Ident.lane1)/\(Ident.card1)", - plain(order: "1024", title: "First", body: "typed and never committed")) - // …and a board change beside it, so the debounced flush has something to compose slowly about - // rather than answering `nothingToCommit` before it ever reaches the composer. - try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing")) - - // Arm the debounce and let it fire: from here until the composer returns, a flush is in - // flight, and it planned its commit while the session folder was still excluded. - committer.noteReloadLanded(sawForeignChange: true) - try await waitUntil { committer.isCommitInFlight } - - // The close sequence, arriving underneath it: the session ends, its folder is released, and - // the coordinator asks for the flush that must not be lost. - committer.endCardSession(token) - await committer.flushNow() - - #expect(isClean(at: fixture.root), - "the close flush waited its turn and committed the session it was asked to") - #expect(GitRepository.trackedPaths(at: fixture.root) - .contains("\(Ident.lane1)/\(Ident.card1)/\(BoardLoader.indexFileName)")) - } -} - -/// A composer that takes its time, so a test can hold a flush open and drive the close sequence into -/// the gap. Everything else about it is the real one — this suite asserts *when* a commit exists, and -/// a fake message would make the commits it reads back unrecognisable. -private struct SlowComposer: ChangeNarrating { - let delay: TimeInterval - - func narrative(for request: ChangeNarrationRequest) -> String { - // Blocking, deliberately: this runs on the flush's own detached task, and what the test needs - // held open is that task rather than the actor the close sequence is running on. - Thread.sleep(forTimeInterval: delay) - return ChangeNarrator.narrative(for: request) - } -} - -// MARK: - Contention, holds, and failure - -@MainActor -@Suite("Auto-commit ▸ contention and abnormal states") -struct AutoCommitContentionTests { - - @Test("A held index.lock never surfaces as a failure, and the change lands on the next debounce") - func aHeldLockIsNeverAFailure() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - var reportedFailures = 0 - committer.reportFailure = { _ in reportedFailures += 1 } - - // An agent's commit in flight. - let lock = fixture.root.appendingPathComponent(".git/index.lock") - try Data().write(to: lock) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(committer.commitCount == 0) - #expect(reportedFailures == 0, "a held lock is another writer doing its job — no banner") - #expect(committer.lastFailure == nil) - - // The other writer finishes; the pending changes are still pending. - try FileManager.default.removeItem(at: lock) - try await waitUntil { committer.commitCount == 1 } - #expect(isClean(at: fixture.root)) - } - - @Test("The lock never gets deleted, however long it is held") - func theLockIsNeverRemoved() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // "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's stale-lock deletion is deliberately gone. - let lock = fixture.root.appendingPathComponent(".git/index.lock") - try Data().write(to: lock) - try FileManager.default.setAttributes( - [.modificationDate: Date(timeIntervalSinceNow: -60 * 60 * 24)], - ofItemAtPath: lock.path - ) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - await committer.flushNow() - - #expect(FileManager.default.fileExists(atPath: lock.path)) - try FileManager.default.removeItem(at: lock) - } - - @Test("An in-progress merge holds the engine — and it resumes when the state clears") - func anInProgressMergeHolds() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // The marker outside-the-app git leaves. `git_repository_state` reads exactly this file, so - // the hold is the real one rather than a mocked one. - let mergeHead = fixture.root.appendingPathComponent(".git/MERGE_HEAD") - try Data("\(String(repeating: "0", count: 40))\n".utf8).write(to: mergeHead) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(committer.pause == .merge) - #expect(committer.commitCount == 0) - #expect(committer.lastFailure == nil, "a pause is not a failure") - - // "Edits keep landing on disk — files are the board — and commit as one settled batch when - // the state clears." - try fixture.item("\(Ident.lane1)/card-3", plain(order: "3072", title: "Third")) - try FileManager.default.removeItem(at: mergeHead) - await committer.flushNow() - - #expect(committer.pause == nil) - #expect(committer.commitCount == 1, "one settled batch, not one commit per edit made while held") - #expect(isClean(at: fixture.root)) - } - - @Test("A detached HEAD holds too, and says which state it is in") - func aDetachedHeadHolds() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - let head = try #require(GitRepository.headCommit(at: fixture.root)) - try Data("\(head.oid)\n".utf8).write(to: fixture.root.appendingPathComponent(".git/HEAD")) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - await committer.flushNow() - - #expect(committer.pause == .detachedHead) - #expect(committer.pause?.explanation.contains("detached") == true) - #expect(committer.commitCount == 0) - } - - /// **The corrupt-`.git` loud failure's engine half** (06-history-undo.md ▸ Rules, ruled - /// 2026-07-31): "the board itself loads and edits normally — files are the board — but the - /// failure is loud … with the whole git surface paused", and "the banner clears when a later - /// open or reload finds the repo readable". - @Test("An unreadable repository holds the engine, and heals when it opens again") - func anUnreadableRepositoryHolds() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - var transitions: [Bool] = [] - committer.reportRepositoryUnreadable = { transitions.append($0) } - - // What a half-copied or half-deleted `.git` looks like to libgit2: the layout no longer - // validates, so the repository will not open at all. Reversible, which is what makes the - // heal half of this test the real thing rather than a second fixture. - let head = fixture.root.appendingPathComponent(".git/HEAD") - let savedHead = try Data(contentsOf: head) - try FileManager.default.removeItem(at: head) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(committer.pause == .unreadable) - #expect(committer.commitCount == 0, "nothing is attempted against a repository the app can't open") - #expect(committer.lastFailure == nil, "a pause is not a failure") - #expect(transitions == [true], "the banner is raised once, on the transition") - - // "Edits keep landing on disk — files are the board — and commit as one settled batch when - // the state clears." Mid-session, the re-read that notices is the paused engine's own. - try fixture.item("\(Ident.lane1)/card-3", plain(order: "3072", title: "Third")) - try savedHead.write(to: head) - await committer.flushNow() - - #expect(committer.pause == nil) - #expect(committer.commitCount == 1, "one settled batch, exactly as any other pause") - #expect(transitions == [true, false], "and the banner heals — it is a condition, not an event") - #expect(isClean(at: fixture.root)) - } - - @Test("The popover's own re-read learns the state without attempting anything") - func refreshPauseLearnsTheUnreadableRepository() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - let head = fixture.root.appendingPathComponent(".git/HEAD") - let savedHead = try Data(contentsOf: head) - try FileManager.default.removeItem(at: head) - - await committer.refreshPause() - #expect(committer.pause == .unreadable) - #expect(committer.commitCount == 0) - - try savedHead.write(to: head) - await committer.refreshPause() - #expect(committer.pause == nil) - } - - @Test("An unborn HEAD is normal — the first settled change commits the whole tree") - func anUnbornHeadIsNormal() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - - // An adopted repository: someone ran `git init` in a terminal and never committed. - _ = try Repository.create(at: fixture.root) - try "ref: refs/heads/main\n".write( - to: fixture.root.appendingPathComponent(".git/HEAD"), - atomically: true, - encoding: .utf8 - ) - - let git = HistoryStore.compose(boardRoot: fixture.root) - #expect(git.mode == .git) - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - await committer.flushNow() - - #expect(committer.pause == nil, "unborn is normal git mode, never a pause") - #expect(committer.commitCount == 1) - // "It commits the whole tree as *Initial board state*, never a folded diff-from-empty." - let log = try history(at: fixture.root) - #expect(log.count == 1) - #expect(log[0].subject == GitRepository.initialCommitSubject) - #expect(isClean(at: fixture.root)) - } - - @Test("A genuine failure suspends history, and a later success clears it") - func aGenuineFailureSuspendsHistory() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - var suspensions: [String] = [] - var recoveries = 0 - committer.reportFailure = { suspensions.append($0.message) } - committer.reportRecovery = { recoveries += 1 } - - // A repository whose object store cannot be written to: the files are safe on disk, history - // stops advancing, and 02's write-failure posture is what says so. - let objects = fixture.root.appendingPathComponent(".git/objects") - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: objects.path) - await committer.flushNow() - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: objects.path) - - #expect(!suspensions.isEmpty, "a genuine failure is surfaced, unlike contention") - #expect(committer.lastFailure != nil) - - // "retried on the next debounce" — and the suspension clears on the first commit that lands. - await committer.flushNow() - #expect(committer.commitCount == 1) - #expect(committer.lastFailure == nil) - #expect(recoveries > 0) - } -} - -// MARK: - Flush before overwrite - -@MainActor -@Suite("Auto-commit ▸ flush before overwrite") -struct FlushBeforeOverwriteTests { - - @Test("An app write over a pending foreign change commits the external version first") - func theExternalVersionEntersHistoryFirst() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // An agent rewrote a body; the reload landed and classified it foreign. - try fixture.item("\(Ident.lane1)/\(Ident.card1)", - plain(order: "1024", title: "First", body: "the agent's version")) - committer.noteReloadLanded(sawForeignChange: true) - - // Now the app is about to overwrite it. - committer.noteWillWrite() - - #expect(committer.commitCount == 1, "the external version is in history before it is overwritten") - #expect(try history(at: fixture.root).first?.authorEmail == CommitAttribution.externalAuthorEmail) - - // …and the app's own write then commits on its own debounce: both versions exist as commits. - try fixture.item("\(Ident.lane1)/\(Ident.card1)", - plain(order: "1024", title: "First", body: "the user's version")) - committer.noteWriteBracketClosed() - await committer.flushNow() - #expect(committer.commitCount == 2) - } - - @Test("A window of nothing but the app's own writes does not flush per gesture") - func appOnlyWindowsDoNotFlushEarly() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // The cadence constraint: a five-gesture burst must not become five commits just because - // each gesture passes through the write gate. - for index in 2...6 { - committer.noteWillWrite() - try fixture.item("\(Ident.lane1)/card-\(index)", plain(order: "\(index * 1024)", title: "C\(index)")) - committer.noteWriteBracketClosed() - } - #expect(committer.commitCount == 0) - - await committer.flushNow() - #expect(committer.commitCount == 1) - } -} - -// MARK: - Composition - -@MainActor -@Suite("Auto-commit ▸ composition") -struct AutoCommitCompositionTests { - - @Test("A board carrying a `.git` composes a committer — there is no tier to compose under") - func aGitBearingBoardComposesACommitter() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) - - // **PIVOT 2026-08-07** (12-editions.md — git left the paywall). This test used to assert the - // gate one level up: `compose(tier: .free)` was `nil`, so there was no committer, nothing to - // disable and no path by which a free session could touch `.git`. What decides now is the - // board's own mode, and this board has a `.git` at its root. - let git = HistoryStore.compose(boardRoot: fixture.root) - #expect(git.mode == .git) - #expect(git.committer != nil, "the committer's existence is exactly mode == .git") - } - - @Test("A board without a repository has no committer") - func modeNoneHasNoCommitter() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - - let git = HistoryStore.compose(boardRoot: fixture.root) - #expect(git.mode == .none) - #expect(git.committer == nil, "the committer's existence is exactly mode == .git") - } - - @Test("Add-git builds a committer for the board it just flipped") - func addGitBuildsACommitter() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - - let git = HistoryStore.compose(boardRoot: fixture.root) - var wired = 0 - git.activateAutoCommit { _ in wired += 1 } - #expect(git.committer == nil) - - #expect(await git.addGit()) - #expect(git.committer != nil, "the first auto-commit follows the flip") - #expect(wired == 1, "the committer a mid-session add-git builds is wired like any other") - } - - @Test("A board that opens in git mode composes a committer, inert until it is started") - func adoptionComposesAnInertCommitter() async throws { - let (fixture, _, _) = try await makeGitBoard() - defer { fixture.tearDown() } - - let reopened = HistoryStore.compose(boardRoot: fixture.root) - let committer = try #require(reopened.committer) - // Composition happens on the board-open path, where arming a debounce would be a side effect - // of *detection*. `activateAutoCommit` is what starts it. - #expect(committer.commitCount == 0) - #expect(committer.stagedAroundFolders.isEmpty) - } -} - -// MARK: - The Edit-session flag - -/// What the card body still owes the commit model after the stage-around widened to the whole window -/// (06 ▸ Rules ▸ Auto-commit, 2026-07-31): the **flag**, not an announcement. -/// -/// `CardBodyEditSession.editSessionDidChange` was the boundary's announcement, and it went with the -/// widening — the exclusion now opens with the window and releases when the window's session ends, -/// so nothing in production ever wired it (`CardWindowHost.configureSession`). What survives is -/// `isEditing`, which the close path reads as part of "does this window hold unsaved content". -@MainActor -@Suite("Auto-commit ▸ the Edit-session flag") -struct EditSessionBoundaryTests { - - @Test("Entering and leaving Edit moves the flag, and a re-assertion of the mode does not") - func theBoundaryMovesTheFlagOnce() { - let session = CardBodyEditSession() - let presentation = CardBodyPresentation() - presentation.beginEdits = { session.beginEditSession() } - presentation.flushEdits = { session.endEditSession() } - - presentation.setMode(.edit) - presentation.setMode(.edit) // a re-published focus value, a menu validation pass - session.beginEditSession() // idempotent - #expect(session.isEditing) - - presentation.setMode(.preview) - presentation.setMode(.preview) - #expect(!session.isEditing) - } - - @Test("A window that opens straight into Edit is in a session from the start") - func anEmptyBodyOpensASession() { - let session = CardBodyEditSession() - let presentation = CardBodyPresentation() - presentation.beginEdits = { session.beginEditSession() } - - // "a card opens in Preview — unless its body is empty, which opens straight into Edit". - #expect(presentation.openIfNeeded(body: "") == .edit) - #expect(session.isEditing) - } -} - -// MARK: - Semantic messages, through the whole engine - -/// The composer's own vocabulary is proved without a repository in `CommitMessageTests`. What is -/// proved here is the wiring: that a **real commit**, made by the real engine over real libgit2, -/// carries the composed message — HEAD's tree read for the last-committed half, the working tree for -/// the current one, the split's own paths narrowing each message to its own commit. -@MainActor -@Suite("Auto-commit ▸ semantic messages") -struct AutoCommitMessageTests { - - @Test("A foreign change composes identically to an app-mediated one — only the author differs") - func originIsNotInTheProse() async throws { - // 06 ▸ The external gap, closed: "Origin lives in the author field (structural attribution), - // not in message prose." Two boards, the same rename, one vouched for and one not. - func rename(vouchedFor: Bool) async throws -> CommitRecord { - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - let text = plain(order: "1024", title: "Renamed") - try fixture.item("\(Ident.lane1)/\(Ident.card1)", text) - if vouchedFor { - ledger.recordWrite( - at: fixture.url("\(Ident.lane1)/\(Ident.card1)") - .appendingPathComponent(BoardLoader.indexFileName), - text: text - ) - committer.noteWriteBracketClosed() - } else { - committer.noteReloadLanded(sawForeignChange: true) - } - await committer.flushNow() - return try #require(try history(at: fixture.root).first) - } - - let app = try await rename(vouchedFor: true) - let foreign = try await rename(vouchedFor: false) - #expect(app.subject == "Rename card 'First' → 'Renamed'") - #expect(foreign.subject == app.subject, "the message engine is origin-agnostic by design") - // …and the author is the only thing that differs. - #expect(app.authorEmail != CommitAttribution.externalAuthorEmail) - #expect(foreign.authorEmail == CommitAttribution.externalAuthorEmail) - } - - @Test("Board-open catch-up carries a real composed message, not a placeholder") - func launchCatchUpComposes() async throws { - // "Changes found pending at board open diff HEAD's tree against the working tree through the - // same composer, instead of committing blind" (06). Nothing signals this window: no reload - // landed, no bracket closed, and no snapshot was ever handed to the committer — the previous - // board can only have come from HEAD. - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Written while closed")) - committer.start() - - try await waitUntil { committer.commitCount == 1 } - #expect(try headSubject(at: fixture.root) == "Add card 'Written while closed'") - #expect(isClean(at: fixture.root)) - } - - @Test("The guide write auto-commits as 'Update agent guide (vN)'") - func theGuideComposesItsVersion() async throws { - // The m10 agent-guide card's deferred git bullet, landing here: N is read from the marker - // line of the bytes on disk (`AgentGuide.installedVersion`), never tagged at the write site. - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - // An older guide, committed — so the window is a genuine guide *upgrade*: HEAD's bytes carry - // v1 and the working tree's carry the version this build ships. - try fixture.file(AgentGuide.filename, Data("\nOld.\n".utf8)) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - #expect(try headSubject(at: fixture.root) == "Update agent guide (v1)") - - _ = try AgentGuide.install(atBoardRoot: fixture.root) - committer.noteWriteBracketClosed() - await committer.flushNow() - - #expect(try headSubject(at: fixture.root) == "Update agent guide (v\(AgentGuide.version))") - } - - @Test("A split window's two commits each describe only their own paths") - func eachCommitDescribesItsOwnPaths() async throws { - // Both messages compose against the same HEAD, so the only thing that can keep them apart is - // the changed-path list each commit stages — the filter, proved end to end. - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Touched by an agent")) - - let text = plain(order: "2048", title: "Added by the user") - let card = try fixture.item("\(Ident.lane1)/\(Ident.card2)", text) - ledger.recordWrite(at: card.appendingPathComponent(BoardLoader.indexFileName), text: text) - - committer.noteWriteBracketClosed() - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - let log = try history(at: fixture.root) - #expect(committer.commitCount == 2) - // Newest first: the user's overwrite lands after the foreign version it might have buried. - #expect(log.first?.subject == "Add card 'Added by the user'") - #expect(log.dropFirst().first?.subject == "Rename card 'First' → 'Touched by an agent'") - #expect(log.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail) - } - - @Test("A comment lands in the trail by its own verb, through real libgit2") - func commentsComposeTheirFamily() async throws { - // The one part of the comment family that cannot be proved without a repository: "is this - // path new" is `GIT_DELTA_ADDED`, read off the real diff — the fact that tells a post from an - // edit where the snapshot has nothing to say (01-storage-format.md § Enhanced schema). - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - let comment = "\(Ident.lane1)/\(Ident.card1)/comments/cccccccc-0000-4000-8000-000000000001" - - try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nLooks good.\n".utf8)) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - #expect(try headSubject(at: fixture.root) == "Comment on 'First'") - - try fixture.file("\(comment)/index.md", Data("---\nschema: 1\nkind: comment\n---\nOn reflection.\n".utf8)) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - #expect(try headSubject(at: fixture.root) == "Edit comment on 'First'") - } - - @Test("A stray-only window names the stray rather than shrugging") - func straysAreNamed() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.file("notes.txt", Data("scratch\n".utf8)) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - #expect(try headSubject(at: fixture.root) == "Update 'notes.txt'") - } - - // MARK: The covering snapshot - - /// One card-window session's worth of state, as the close flush meets it: a change on disk that - /// the app vouched for, and a `store.snapshot` that has not caught up yet. - /// - /// The board's two store reads are faked rather than driven through a real `BoardStore`, and - /// deliberately: what is being pinned is *the order the flush reads them in*, which a real - /// watcher would settle by racing rather than by rule. `landsAfterReads` is the reload landing — - /// the generation asked for the nth time is the walk that finally covers the write. - private func flushRacingItsReload( - awaitsCoverage: Bool, - landsAfterReads: Int = 3 - ) async throws -> String? { - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - // Only the explicit flush runs: a debounce firing mid-wait would be a second flush answering - // the question this test is asking of the first. - committer.debounceInterval = .seconds(30) - committer.coveringSnapshotPollInterval = .milliseconds(1) - committer.coveringSnapshotDeadline = .milliseconds(500) - - // The board as the app last read it — one card, which is what HEAD's tree also says. - var current = try fixture.snapshot() - committer.currentSnapshot = { current } - - // The session's write lands on disk, vouched for, with no reload behind it yet. - let text = plain(order: "2048", title: "Second") - try fixture.item("\(Ident.lane1)/\(Ident.card2)", text) - ledger.recordWrite( - at: fixture.url("\(Ident.lane1)/\(Ident.card2)").appendingPathComponent(BoardLoader.indexFileName), - text: text - ) - committer.noteWriteBracketClosed() - - if awaitsCoverage { - var generation = 0 - var reads = 0 - committer.awaitReloadQuiescence = {} - committer.landedReloads = { - reads += 1 - if reads == landsAfterReads { - current = (try? fixture.snapshot()) ?? current - generation += 1 - committer.noteReloadLanded(sawForeignChange: false) - } - return generation - } - } - - await committer.flushNow() - return try headSubject(at: fixture.root) - } - - /// **"The flush awaits the snapshot that covers it"** (06 ▸ Rules ▸ Auto-commit, ruled - /// 2026-07-31): "the commit's subject can never be outrun by its own reload". - @Test("A close flush racing a stale snapshot composes from the covering one") - func theFlushAwaitsItsCoveringSnapshot() async throws { - #expect(try await flushRacingItsReload(awaitsCoverage: true) == "Add card 'Second'") - } - - /// The same race with the store's two reads unwired — the storeless configuration, and what the - /// close flush did before the ruling. The commit still lands (the condition is the *tree*), but - /// its subject describes a board that has not heard about the card it is committing. - @Test("Without the await the subject is the one the stale snapshot could compose — the defect, pinned") - func aStaleSnapshotComposesTheShrug() async throws { - #expect(try await flushRacingItsReload(awaitsCoverage: false) == ChangeNarrator.unnamedSubject) - } - - /// The bound is a bound: a board whose watcher stream never came up has no reload to wait for, and - /// the close path may not hang on one. The commit lands from the snapshot in hand. - @Test("A covering reload that never lands ends the wait rather than the app") - func theWaitIsBounded() async throws { - // The generation never moves, so the wait runs to its (millisecond) deadline and composes. - #expect(try await flushRacingItsReload(awaitsCoverage: true, landsAfterReads: .max) - == ChangeNarrator.unnamedSubject) - } -} - -// MARK: - Attribution, as a pure function - -@Suite("Auto-commit ▸ attribution rules") -struct CommitAttributionRuleTests { - - @Test("An agent identity is the name verbatim and a slugified local part") - func agentIdentityShape() { - #expect(CommitAttribution.agentIdentity(named: "claude") - == GitIdentity(name: "claude", email: "claude@agents.lanework.invalid")) - // Display name verbatim; the address is what gets sanitized. - #expect(CommitAttribution.agentIdentity(named: "Claude Code") - == GitIdentity(name: "Claude Code", email: "claude-code@agents.lanework.invalid")) - // libgit2 refuses a signature with an angle bracket in it, so the slug has to be total — - // every disallowed character becomes `-`, and the leading/trailing ones are then trimmed. - #expect(CommitAttribution.agentIdentity(named: "bot ").email - == "bot--x@agents.lanework.invalid") - #expect(CommitAttribution.agentIdentity(named: "bot ").name == "bot ") - } - - @Test("An empty stamp falls back rather than producing a nameless author") - func anEmptyStampFallsBack() { - #expect(CommitAttribution.agentIdentity(named: " ").name == CommitAttribution.externalAuthorName) - } - - @Test("A rename's departure is not a true deletion") - func aRenameDepartureDoesNotDemote() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - try fixture.item("lane/card", stamped("Moved", by: "claude")) - - let paths = [ - ChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true), - ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: true) - ] - // "**A folder move is not a deletion**: items match by id across the whole board." - #expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root).name == "claude") - } - - @Test("A window of nothing but rename departures has no stamp to agree on") - func departuresAloneFallBack() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - let paths = [ChangedPath(path: "old/card/index.md", isDeletion: true, isRename: true)] - #expect(CommitAttribution.foreignIdentity(for: paths, under: fixture.root) - == CommitAttribution.externalIdentity) - } - - @Test("Only index.md carries a stamp — every other path is unstamped by construction") - func onlyIndexFilesCarryStamps() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - try fixture.item("lane/card", stamped("Stamped", by: "claude")) - try fixture.file("lane/card/attachments/note.txt", Data("x\n".utf8)) - - #expect(CommitAttribution.modifiedBy(atRelativePath: "lane/card/index.md", under: fixture.root) == "claude") - #expect(CommitAttribution.modifiedBy(atRelativePath: "lane/card/attachments/note.txt", under: fixture.root) == nil) - } - - @Test("A satisfied receipt vouches; a receipt disk no longer matches does not") - func satisfactionDecidesProvenance() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - let text = plain(order: "1024", title: "Mine") - try fixture.item("lane/card", text) - - let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md")) - let matching = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: false)] - let stale = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: "other")), isHeal: false)] - let changed = [ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)] - - #expect(CommitAttribution.split(changed, under: fixture.root, receipts: matching).user == changed) - // "a foreign edit landing on an app-written path inside the same window misses the hash and - // classifies foreign (last writer wins the file)". - #expect(CommitAttribution.split(changed, under: fixture.root, receipts: stale).foreign == changed) - // And with nothing held at all, the app never vouches. - #expect(CommitAttribution.split(changed, under: fixture.root, receipts: [:]).foreign == changed) - } - - @Test("A heal-marked receipt lands its path in the heal class") - func healMarksSplitOut() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - let text = plain(order: "1024", title: "Repaired") - try fixture.item("lane/card", text) - - let file = EchoLedger.key(fixture.url("lane/card").appendingPathComponent("index.md")) - let receipts = [file: HarvestedReceipt(receipt: .content(hash: EchoLedger.hash(of: text)), isHeal: true)] - let changed = [ChangedPath(path: "lane/card/index.md", isDeletion: false, isRename: false)] - - let split = CommitAttribution.split(changed, under: fixture.root, receipts: receipts) - #expect(split.heal == changed) - #expect(split.user.isEmpty) - // Foreign first, then heal, then the user's — the commit order, made assertable. - #expect(split.ordered.map(\.kind) == [.heal]) - } -} - -// MARK: - Helpers - -/// An `index.md` with **no `modified-by`** — what the app itself writes ("Absence of `modified-by` -/// means the board's user, via the app", `BoardWriter`), and what every test that is not about the -/// stamp needs: the shared `Item.rich` fixture carries `modified-by: claude`, which would quietly -/// author half of this file's commits as an agent. -private func plain(order: String, title: String, body: String = "Body.") -> String { - """ - --- - schema: 1 - title: \(title) - order: \(order) - --- - \(body) - - """ -} - -/// A card `index.md` carrying a `modified-by` stamp — what a well-behaved agent writes -/// (08-agent-integration.md; 01-storage-format.md). -private func stamped(_ title: String, by writer: String) -> String { - """ - --- - schema: 1 - order: 1024 - title: \(title) - modified-by: \(writer) - --- - Body. - - """ -} - -/// Spins the run loop until `condition` holds or the deadline expires — the debounce's own testimony -/// without a fixed sleep. -@MainActor -private func waitUntil( - _ condition: @MainActor () -> Bool, - within deadline: Duration = .seconds(5), - sourceLocation: SourceLocation = #_sourceLocation -) async throws { - let start = ContinuousClock.now - while !condition() { - guard ContinuousClock.now - start < deadline else { - Issue.record("condition never held", sourceLocation: sourceLocation) - return - } - try await Task.sleep(for: .milliseconds(5)) - } -} diff --git a/KanbanTests/BoardGitModeTests.swift b/KanbanTests/BoardGitModeTests.swift deleted file mode 100644 index cd59148..0000000 --- a/KanbanTests/BoardGitModeTests.swift +++ /dev/null @@ -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) - } -} diff --git a/KanbanTests/CardSessionUndoTests.swift b/KanbanTests/CardSessionUndoTests.swift index de48fa8..c45e882 100644 --- a/KanbanTests/CardSessionUndoTests.swift +++ b/KanbanTests/CardSessionUndoTests.swift @@ -16,6 +16,25 @@ import Testing /// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider /// 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 @MainActor @@ -735,7 +754,7 @@ struct CardSessionPurgeTests { defer { fixture.tearDown() } 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() #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) } @@ -823,10 +842,11 @@ struct CardSessionPurgeTests { try fixture.item(path, commentText()) let window = try makeWindow(fixture) window.comments.reload() - // The git provider drops every registration (its substrate is the commit trail) and retires - // it on the way past — which is what makes "purge rides the close flush" true on Pro with no - // tier check at any call site. Bound directly here: `register` reads no repository. - window.store.history = GitHistoryProvider(boardRoot: fixture.root) + // A substrate that keeps no steps drops every registration and retires it on the way past — + // which is what makes "purge rides the close flush" true structurally, with no tier check at + // any call site. `history` is weak, so the fake is held locally for the assertion's duration. + let noBackingProvider = NoBackingHistoryProvider() + window.store.history = noBackingProvider window.comments.delete(ItemID(rawValue: CommentIdent.one)) await window.session.endSession() diff --git a/KanbanTests/ChangeNarratorTests.swift b/KanbanTests/ChangeNarratorTests.swift index 1438c43..c30f297 100644 --- a/KanbanTests/ChangeNarratorTests.swift +++ b/KanbanTests/ChangeNarratorTests.swift @@ -46,7 +46,7 @@ private func files(under root: URL) -> [String: Data] { 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. private func changedPaths(from before: URL, to after: URL) -> [ChangedPath] { 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 // 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. - 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(), previousSnapshot: nil )) - #expect(message == GitRepository.initialCommitSubject) + #expect(message == ChangeNarrator.rootSubject) } } diff --git a/KanbanTests/DuplicateIdentityTests.swift b/KanbanTests/DuplicateIdentityTests.swift index 6d70e73..46e908c 100644 --- a/KanbanTests/DuplicateIdentityTests.swift +++ b/KanbanTests/DuplicateIdentityTests.swift @@ -549,26 +549,6 @@ struct DuplicateIdentityDetectionTests { #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 /// 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") @@ -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. @Test("Three occurrences leave one standing") func threeCopiesLeaveOne() throws { diff --git a/KanbanTests/GitIdentityTests.swift b/KanbanTests/GitIdentityTests.swift deleted file mode 100644 index bfc5404..0000000 --- a/KanbanTests/GitIdentityTests.swift +++ /dev/null @@ -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 ` 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 == "ada@analytical.local") - } - - @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 == "ada@analytical.local") - } - - @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 .local" - ) - - #expect(!identity.email.contains(" ")) - #expect(!identity.email.contains("<")) - #expect(!identity.email.contains(">")) - #expect(identity.email == "ada-lovelace@Ada-s--Mac-.local") - } - - @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 == "ada@host.example.com") - } - - @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 == "ada@example.com") - } - - @Test("Config wins over the derived default, key by key") - func resolutionPrefersConfigPerKey() { - let derived = GitIdentity(name: "Machine Owner", email: "owner@mac.local") - - let both = GitIdentity.resolve(repoLocal: (name: "Ada", email: "ada@example.com"), derived: derived) - #expect(both == GitIdentity(name: "Ada", email: "ada@example.com")) - - // 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: "owner@mac.local")) - - 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 == "ada@example.com", "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 == "new@example.com") - - // 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 == "ada@example.com") - - // 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 = ada@work.example\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 = ada@example.com\n".utf8)) - - let identity = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git")) - #expect(identity.name == "Ada") - #expect(identity.email == "ada@example.com") - } -} - -/// **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: "new@example.com", 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 = new@example.com")) - #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 == "new@example.com") - } - - @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 = ada@example.com\n" - - let written = GitConfigFile.applying(name: "Ada Lovelace", email: "ada@example.com", 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: " ada@example.com ", 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 = ada@work.example")) - #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: "new@home.example", 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 == "new@home.example") - } - - @Test("Writing into empty text creates just the new `[user]` section") - func writesIntoAnEmptyConfig() { - let written = GitConfigFile.applying(name: "Ada Lovelace", email: "ada@example.com", to: "") - - #expect(written == "[user]\n\tname = Ada Lovelace\n\temail = ada@example.com\n") - let read = GitConfigFile.identity(inConfigText: written) - #expect(read.name == "Ada Lovelace") - #expect(read.email == "ada@example.com") - } - - @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 = ada@example.com" - 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 = ada@example.com\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: "ada@example.com", to: "[core]\n\tbare = false") - let appendedWithTrailingNewline = GitConfigFile.applying(name: "Ada", email: "ada@example.com", to: "[core]\n\tbare = false\n") - #expect(appendedNoTrailingNewline == "[core]\n\tbare = false\n\n[user]\n\tname = Ada\n\temail = ada@example.com\n") - #expect(appendedWithTrailingNewline == appendedNoTrailingNewline, "the trailing-newline state of the input doesn't change the appended shape") - } -} diff --git a/KanbanTests/HistoryStoreTests.swift b/KanbanTests/HistoryStoreTests.swift deleted file mode 100644 index d83dec4..0000000 --- a/KanbanTests/HistoryStoreTests.swift +++ /dev/null @@ -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: "ada@example.com") - - #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") - } -} diff --git a/KanbanTests/RepositoryHygieneTests.swift b/KanbanTests/RepositoryHygieneTests.swift deleted file mode 100644 index beb531d..0000000 --- a/KanbanTests/RepositoryHygieneTests.swift +++ /dev/null @@ -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 = [] -) 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 { - 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() - 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.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") - } -}