Implement undo and redo as forward commits

GitHistoryProvider is the second HistoryProviding implementation:
its stack IS HEAD's first-parent ancestry, reseeded on load (redo
empty), re-synced to HEAD before every crossing so agents'
self-commits become the top and ⌘Z steps back exactly one commit;
any arriving commit clears redo (a heal-only window deliberately
does not). Restores are forward commits through the ordinary
signature path — GitRestoreOperation materializes only the
current-vs-target diff as working-tree writes and resolves no
reset/checkout symbol at all; heal commits are transparent
in-session (pointer passes over, restores exclude heal-owned paths,
identity carried on landed windows via PlannedCommit.kind →
GitLandedCommit). Subjects "Undo:/Redo: <crossed subject>"; menu
labels never nest in-session; the root commit is not a step
(crossing it would restore the empty tree).

Provider binding flips: makeHistoryProvider(store, tier, git) —
free binds native everywhere, Pro binds the git provider on git
boards and NOTHING on mode-none/repo-nested (the pair disables
through existing validation); add-git mid-session live-binds via
HistoryStore.didAddGit → bindHistoryProvider (the flip only ever
adds).

SessionSettleGate is the reusable Save All / Discard / Cancel step:
restores whose diff touches an open Edit session or raw-source
buffer gate on it (Save All applies with validation — a refused
buffer cancels the whole restore focused on the offender; Discard
reverts via CardBodyEditSession.discardBuffer and reconciles against
the working tree, deliberately skipping the second flush); untouched
sessions ride through undisturbed. Built for the branch-switch card
to reuse. BoardStore gains the async performWholesale sibling.

CardHistorySection fills the m6 EmptyView slot: read-only, newest
first, follows the card across lane moves by folder-component match
(the UUID is the identity — no rename detection), absent off git
mode and off Pro.

2332 tests / 403 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-31 15:54:22 -04:00
parent 563999655f
commit 142c6e75fe
19 changed files with 3126 additions and 82 deletions
+100 -22
View File
@@ -1,6 +1,44 @@
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<String>
public init(commits: [GitLandedCommit], healPaths: Set<String>) {
self.commits = commits
self.healPaths = healPaths
}
/// The oids of the heal-class commits the ones the undo pointer passes over.
public var healOIDs: Set<String> {
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
@@ -110,6 +148,22 @@ public final class GitAutoCommitter {
@ObservationIgnored
public var reportRecovery: (@MainActor () -> 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` on every board with no undo provider bound (the free tier's committer does not exist;
/// a Pro board's does, and a provider is bound beside it).
@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
@@ -247,7 +301,8 @@ public final class GitAutoCommitter {
// 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.
apply(Self.execute(input))
let result = Self.execute(input)
apply(result.outcome, healPaths: result.healPaths)
}
// MARK: - Edit sessions
@@ -324,12 +379,12 @@ public final class GitAutoCommitter {
// 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 outcome = await Task.detached(priority: .utility) { Self.execute(input) }.value
if case .locked = outcome, attempt < 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(outcome)
apply(result.outcome, healPaths: result.healPaths)
return
}
}
@@ -355,35 +410,45 @@ public final class GitAutoCommitter {
)
}
/// 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<String> = []
}
/// **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) -> GitCommitOutcome {
private nonisolated static func execute(_ input: FlushInput) -> FlushOutput {
let reading = GitCommitOperation.reading(at: input.boardRoot)
if let pause = reading.pause { return .held(pause) }
if reading.isIndexLocked { return .locked }
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 .failed(GitOperationFailure(
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 .nothingToCommit }
guard !changed.isEmpty else { return FlushOutput(outcome: .nothingToCommit) }
return GitCommitOperation.perform(
at: input.boardRoot,
commits: plan(
changed,
reading: reading,
input: input,
composition: composition(for: changed, input: input)
)
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))
)
}
@@ -475,7 +540,8 @@ public final class GitAutoCommitter {
paths: changed.map(\.path),
message: input.composer.message(for: request(changed, .user, isRootCommit: true)),
author: user,
committer: user
committer: user,
kind: .root
)]
}
@@ -497,11 +563,18 @@ public final class GitAutoCommitter {
}
let author: GitIdentity
if case let .foreign(identity) = authorship { author = identity } else { author = user }
let kind: PlannedCommitKind
switch group.kind {
case .foreign: kind = .foreign
case .heal: kind = .heal
case .user: kind = .user
}
return PlannedCommit(
paths: group.paths.map(\.path),
message: input.composer.message(for: request(group.paths, authorship)),
author: author,
committer: user
committer: user,
kind: kind
)
}
}
@@ -519,13 +592,18 @@ public final class GitAutoCommitter {
// MARK: - Outcomes
private func apply(_ outcome: GitCommitOutcome) {
private func apply(_ outcome: GitCommitOutcome, healPaths: Set<String> = []) {
switch outcome {
case let .committed(oids):
case let .committed(landed):
let oids = landed.map(\.oid)
pause = 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.
harvested.removeAll()
+51 -5
View File
@@ -110,6 +110,38 @@ public struct GitChangedPath: Sendable, Equatable, Hashable {
// 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
@@ -138,19 +170,33 @@ public struct PlannedCommit: Sendable, Equatable {
/// itself.
public let committer: GitIdentity
public init(paths: [String], message: String, author: GitIdentity, 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.
case committed([String])
/// 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
@@ -452,11 +498,11 @@ enum GitCommitOperation {
// own terms.
resetIndexToHead(index, in: repository)
var landed: [String] = []
var landed: [GitLandedCommit] = []
for plan in commits {
switch commit(plan, in: repository, index: index) {
case let .landed(oid):
landed.append(oid)
landed.append(GitLandedCommit(oid: oid, kind: plan.kind))
case .skipped:
continue
case let .stopped(outcome):
+477
View File
@@ -0,0 +1,477 @@
import Foundation
import os
// MARK: - GitHistoryProvider
/// **Pro's undo substrate: the commit trail itself** (06-history-undo.md; 12-editions.md The
/// provider seam) the second implementation of `HistoryProviding`, and the one the seam was
/// designed around.
///
/// ### The stack is not a stack
///
/// "The stack **is** HEAD's first-parent ancestry, live" (06 Rules). Nothing here records a step
/// when the board is written to; `register(_:)` is a deliberate no-op, because on a git board an undo
/// step is a *commit* and commits are made by the auto-committer, by an agent, or by a terminal. What
/// this object holds is a **pointer into that ancestry** which commit Z would cross next plus a
/// redo list of the commits already crossed in this session. Both are caches over a repository that
/// remains the only truth, which is what makes "no sidecar state, nothing ever lost" (14 C8) a
/// property of the shape rather than a discipline.
///
/// ### Four rules, and where each one lives
///
/// - **Forward only.** A crossing writes an older state as a *new commit* `GitRestoreOperation`,
/// which cannot reset because it never resolves a reset symbol. Old commits stay reachable; refs
/// only move forward.
/// - **Exactly one commit per Z.** The pre-flight sync (`syncToHEAD`) re-reads HEAD before every
/// crossing, so agents' self-commits landed since the last operation become the new top and Z
/// steps back over *them* rather than silently reverting twenty minutes of their work.
/// - **Any arrival clears redo.** From the pre-flight sync for commits made outside the app, and from
/// `noteLanded(_:)` for the ones this app's committer made with the one exception the heal rule
/// requires (below).
/// - **In-session and post-relaunch are one rule.** `reseed()` is the same ancestry walk from
/// scratch, so a relaunch, a branch switch and a foreign arrival all take the same path.
///
/// ### Heal transparency, and its honest limit
///
/// Heal-class commits never become steps: the pointer passes over them, and a restore excludes the
/// paths whose divergence is heal work so a Z run never reverts a repair and never re-arms the
/// healer (06 Rules Heal commits are transparent to undo, in-session). Both halves are learned
/// from `GitAutoCommitter.reportLanded`, which fires while the Writer's heal-marked receipts still
/// exist. **In-session is the whole of it, deliberately**: the reseed is sidecar-free, so after a
/// relaunch old heal commits reappear as ordinary steps the accepted one-bounce residual, named in
/// 06 and not worked around here.
///
/// ### Asynchrony
///
/// `HistoryProviding.undo()` is synchronous because a menu item is; a restore is a settle step, a
/// libgit2 diff, a set of writes and a commit. So the protocol methods start a `Task` and return, and
/// `cross(_:)` is the awaitable one a test drives. Enablement never waits on any of it: `canUndo`,
/// `canRedo` and both action names answer from the cached ancestry, so menu validation costs nothing.
@MainActor
@Observable
public final class GitHistoryProvider: HistoryProviding {
// MARK: - Identity
/// The board this is the history of in git mode, the repository's working-tree root.
public let boardRoot: URL
// MARK: - Seams
/// **The pending auto-commit, flushed before a restore commits** (06 Rules Flush-before-
/// overwrite, applied here by the card's own rule: settled tree first, then one more commit).
///
/// Without it a Z would commit an older state on top of edits that never got a commit of their
/// own the forward trail would be missing the very version the undo is stepping back from.
@ObservationIgnored
public var flushPendingCommit: (@MainActor () async -> Void)?
/// Whether the git surface is **held** a detached HEAD or an in-progress merge/rebase
/// (06 Rules Abnormal repo states: "Undo/Redo and the branch controls disable"). Reads
/// `GitAutoCommitter.pause`, which is in-memory state, so enablement stays free.
@ObservationIgnored
public var isHeld: (@MainActor () -> Bool)?
/// Stops and restarts the auto-commit debounce around a restore, so its own writes cannot be
/// half-committed by a timer that fires mid-materialization.
@ObservationIgnored
public var suspendCommitting: (@MainActor () -> Void)?
@ObservationIgnored
public var resumeCommitting: (@MainActor () -> Void)?
/// **The save-or-discard step** (06 Rules Undo restore vs open Edit sessions). `nil` is a
/// board with no card windows to settle every storeless test, and a session composed before any
/// window opened.
@ObservationIgnored
public var settleSessions: (@MainActor (Set<String>) async -> SessionSettleOutcome)?
/// Runs the restore inside the store's wholesale bracket watcher suspended, one full reload at
/// the end, the board locked if that reload fails (02-architecture.md; `BoardStore.performWholesale`).
/// `nil` runs the work bare, which is what a repository-level test wants.
@ObservationIgnored
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
/// A genuine restore failure surfaced as 02's one-shot banner by whoever wires it.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
// MARK: - The cached stack
/// HEAD's first-parent ancestry as of the last sync, newest first. The *stack*, cached.
public private(set) var ancestry: [GitCommitRecord] = []
/// The oid of the commit Z would cross next, or `nil` before the first seed. Not always
/// `ancestry.first`: after an undo the pointer sits below the restore commit the undo just made,
/// which is the whole mechanism behind "the undo-menu labels are the *crossed* commit's subject,
/// so labels never nest" (06 Commit messages).
public private(set) var pointerOID: String?
/// Commits crossed by Z in this session, oldest crossed first Z restores the state *at* the
/// last of them. Empty on every seed: "redo starts empty" (06 Rules Undo survives relaunch).
public private(set) var redoCommits: [GitCommitRecord] = []
/// The HEAD this cache was built against the pre-flight sync's comparison.
private var knownHead: String?
/// Heal-class commits landed **in this session**, which the pointer passes over.
private var healOIDs: Set<String> = []
/// Paths committed as heal work in this session, which a restore never materializes.
private var healPaths: Set<String> = []
/// Whether a crossing is in flight a second Z during a restore must not start a second one.
public private(set) var isCrossing = false
/// How many restores this provider has landed the trail's own testimony, so a test need not
/// infer a crossing from a commit walk.
public private(set) var restoreCount = 0
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
public init(boardRoot: URL) {
self.boardRoot = boardRoot
}
// MARK: - Seeding
/// **Reseeds the stack from HEAD's first-parent ancestry, with an empty redo** the API a board
/// open, a relaunch, and a **branch switch** all enter through (06 Rules Undo survives
/// relaunch; Branch switching: "The undo/redo stack does not survive a switch. It is discarded
/// and reseeded from the new HEAD's first-parent ancestry redo starts empty").
///
/// Synchronous and off the main actor is not an option the walk is libgit2 so this is the
/// awaitable seed and `seed()` is the fire-and-forget one an open path can call.
public func reseed() async {
let root = boardRoot
let records = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.ancestry(at: root)
}.value
adopt(records)
}
/// `reseed()` without waiting what a board open and an add-git flip call.
public func seed() {
Task { await reseed() }
}
/// The reseed's main-actor half, split out so the sync path can reuse it.
private func adopt(_ records: [GitCommitRecord]) {
ancestry = records
knownHead = records.first?.oid
pointerOID = records.first?.oid
redoCommits = []
}
/// **The pre-flight sync** (06 Rules The stack is HEAD's first-parent ancestry, live):
/// "The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the
/// app's committer; the pre-flight sync is how the stack learns), so Z always steps back exactly
/// **one** commit."
///
/// One reference read when nothing moved, a full reseed when something did. A reseed here is the
/// same reseed a relaunch does, which is the point: "In-session and post-relaunch behavior are
/// thereby one rule."
private func syncToHEAD() async {
let root = boardRoot
let head = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.headOID(at: root)
}.value
guard head != knownHead else { return }
Self.logger.debug("undo stack re-syncing: HEAD moved outside the stack's knowledge")
await reseed()
}
// MARK: - What the committer tells it
/// **A flush landed** `GitAutoCommitter.reportLanded`.
///
/// Two behaviours, and the split between them is the heal rule:
///
/// - A window of **nothing but heal commits** leaves the pointer and the redo list exactly where
/// they were, and only records what was healed. That is what keeps an undo run from being
/// trapped on an ever-renewing top: "the fresh heal commit is in-session, transparent, and the
/// undo run continues past it" (06).
/// - **Anything else is an arrival**, and "any commit arriving from anywhere clears the redo
/// stack (classic behavior)" with the new commit becoming the top of the stack, so the next
/// Z crosses what just happened.
public func noteLanded(_ window: GitLandedWindow) {
healOIDs.formUnion(window.healOIDs)
healPaths.formUnion(window.healPaths)
guard !window.commits.isEmpty else { return }
// The walk is libgit2 work and the committer reports from a synchronous outcome handler, so
// the cache catches up on its own turn. `settled()` is how anything that must not race it
// waits `cross(_:)` first of all.
refresh = Task { [weak self] in
guard let self else { return }
if window.isEntirelyHeal {
// The ancestry gained a commit the pointer must be able to walk past; the pointer and
// the redo list are untouched.
await self.refreshAncestryKeepingPointer()
} else {
await self.reseed()
}
}
}
/// The cache catch-up started by the last `noteLanded(_:)`, if it is still running.
@ObservationIgnored
private var refresh: Task<Void, Never>?
/// **Waits for the cache to have heard about the last commit** so "Z now crosses what just
/// landed" is a fact to await rather than a race.
///
/// Every crossing awaits it, which is the production caller; a test awaits it to assert on
/// enablement the instant a flush returns, where a menu would simply be validated a turn later.
public func settled() async {
await refresh?.value
refresh = nil
}
/// Re-reads the ancestry without disturbing the pointer or the redo list the heal window's
/// path, and the one every successful restore takes.
private func refreshAncestryKeepingPointer() async {
let root = boardRoot
let records = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.ancestry(at: root)
}.value
ancestry = records
knownHead = records.first?.oid
if let pointerOID, !records.contains(where: { $0.oid == pointerOID }) {
// The pointer's commit is no longer in HEAD's first-parent ancestry a rebase remapped
// it (07-sync-collab.md's pull). The honest answer is the seed's: start again from the
// top, redo empty.
adopt(records)
}
}
// MARK: - HistoryProviding
/// **Deliberately nothing.** On a git board an undo step is a commit, and the Writer boundary's
/// inverse operations are the *free* tier's substrate (13-native-undo.md). `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.
public func register(_ step: HistoryStep) {}
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(to: target, message: "Undo: \(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(to: target.oid, message: "Redo: \(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.
private func restore(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("this board's repository could not be read")
return false
}
var reconciling: Set<String> = []
if !preliminary.isEmpty, let settleSessions {
switch await settleSessions(Set(preliminary.paths)) {
case .cancelled, .failed:
// "Cancel keeps everything" and a raw buffer that would not validate cancels the
// whole restore, focused on the offender (06 Branch switching).
return false
case .proceed:
reconciling = discardedFolders
discardedFolders = []
}
if reconciling.isEmpty {
// **Save All ended sessions, which commits them**: the tree moved, so the plan is
// recomputed below against the HEAD that now exists rather than the one it was
// drafted against.
//
// **Discard deliberately does not flush.** Ending a session un-stages-around its
// folder, so a flush here would commit exactly the uncommitted saves the user just
// asked to lose a Discard that wrote them into history forever. Nothing is left
// behind by skipping it: everything else pending was already flushed at the top of
// the crossing, and the discarded folder is reconciled against the working tree by
// the plan itself.
await flushPendingCommit?()
await settled()
}
}
// Bound before the closure that crosses actors reads it the settle step is over, and what
// it decided is a value from here on.
let folders = reconciling
var landed = false
let work: @MainActor () async -> Void = { [weak self] in
guard let self else { return }
self.suspendCommitting?()
defer { self.resumeCommitting?() }
let outcome = await Task.detached(priority: .userInitiated, operation: {
guard let plan = GitRestoreOperation.plan(
at: root,
target: target,
excluding: excluded,
reconciling: folders
) else {
return GitCommitOutcome.failed(GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: "this board's repository could not be read"
))
}
return GitRestoreOperation.apply(plan, at: root, message: message)
}).value
switch outcome {
case .committed:
self.restoreCount += 1
landed = true
case .nothingToCommit:
// The step was crossed and needed no bytes every path its diff would have written
// was heal work, or the two states are byte-identical. The pointer still advances:
// a step that changed nothing is still a step the user asked to walk past.
landed = true
case .locked:
// "Contention outlasting the brief retry surfaces as a *waiting* state" (06); the
// in-progress banner row is the branch card's surface. Here the honest answer is to
// leave the stack where it is so Z can simply be pressed again.
Self.logger.debug("restore found index.lock held — the stack is unchanged")
case let .held(pause):
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
case let .failed(failure):
self.reportFailure?(failure)
}
}
if let runBracketed {
await runBracketed(message, work)
} else {
await work()
}
return landed
}
/// Folders the settle step's Discard branch left for the plan to reconcile against the working
/// tree. Filled by the gate's wiring through `noteDiscarded(_:)`.
private var discardedFolders: Set<String> = []
/// **A settle step discarded this card's session** its folder is compared against the working
/// tree rather than against HEAD, so the uncommitted saves the user just chose to lose are
/// reverted by the restore itself rather than by a second pass that could disagree with it
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
public func noteDiscarded(cardFolderPath: String) {
discardedFolders.insert(cardFolderPath)
}
// MARK: - The pointer
/// The index in `ancestry` of the commit Z would cross, or `nil` when there is none.
///
/// Two commits are never steps:
///
/// - **Heal commits**, which the pointer passes over (06 Rules Heal commits are transparent).
/// - **The root commit** a judgment call, recorded. It has no parent, so "the state before it"
/// is the empty tree: crossing it would delete every file the board has ever had, in one
/// keystroke, on a board whose entire history is that one commit. 06 says an unborn repository's
/// "undo trail simply starts empty"; a repository with exactly one commit is that repository one
/// commit later, and the honest reading is that the board's existence is not a step. (Nothing is
/// lost either way: the commit stays reachable in any git client.)
private func crossableIndex() -> Int? {
guard !ancestry.isEmpty else { return nil }
let start = pointerOID.flatMap { oid in ancestry.firstIndex { $0.oid == oid } } ?? 0
for index in start..<ancestry.count {
let commit = ancestry[index]
guard !healOIDs.contains(commit.oid) else { continue }
guard commit.parentOID != nil else { return nil }
return index
}
return nil
}
private func report(_ message: String) {
reportFailure?(GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: message
))
}
}
+213
View File
@@ -0,0 +1,213 @@
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
}
}
+337
View File
@@ -0,0 +1,337 @@
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 folders of 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.
///
/// ### 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: board-root-relative folders compared against the working tree rather than
/// against HEAD (the Discard branch of the save-or-discard step).
nonisolated static func plan(
at boardRoot: URL,
target: String,
excluding: Set<String> = [],
reconciling: Set<String> = []
) -> GitRestorePlan? {
_ = startUp
guard let repository = open(boardRoot) else { return nil }
defer { git_repository_free(repository) }
guard let targetTree = tree(of: target, in: repository) else { return nil }
defer { git_tree_free(targetTree) }
var wanted: [String: git_oid] = [:]
fileMap(of: targetTree, in: repository, prefix: "", depth: 0, into: &wanted)
var current: [String: git_oid] = [:]
if let headTree = headTree(of: repository) {
defer { git_tree_free(headTree) }
fileMap(of: headTree, in: repository, prefix: "", depth: 0, into: &current)
}
// The reconciled folders answer from disk instead: their committed state is beside the point,
// because what is being discarded is exactly what is *not* committed.
if !reconciling.isEmpty {
for folder in reconciling {
current = current.filter { !isInside($0.key, folder: folder) }
}
for path in workingTreeFiles(under: reconciling, at: boardRoot) {
// A sentinel oid nothing can equal: the comparison below only ever asks "same or
// different", and a working-tree file's bytes are not addressed by the object store.
current[path] = git_oid()
}
}
var changes: [GitRestorePlan.Change] = []
for (path, oid) in wanted.sorted(by: { $0.key < $1.key }) {
guard !excluding.contains(path) else { continue }
if let held = current[path], equal(held, oid), !isInside(path, folders: reconciling) { continue }
guard let data = blob(oid, in: repository) else { continue }
changes.append(GitRestorePlan.Change(path: path, contents: data))
}
for path in current.keys.sorted() where wanted[path] == nil {
guard !excluding.contains(path) else { continue }
changes.append(GitRestorePlan.Change(path: path, contents: nil))
}
return GitRestorePlan(changes: changes.sorted { $0.path < $1.path })
}
// MARK: - Applying
/// **Writes the plan and commits it** one new commit on the current branch, nothing rewound.
///
/// The commit goes through `GitCommitOperation.perform` unchanged, so it takes the ordinary
/// signature path (06 Interaction with external writers) and is authored by the user: a restore
/// is the user acting through the app, whatever the origin of the commit it crosses.
///
/// A plan that turns out to write nothing new commits nothing `perform`'s own empty-tree skip
/// and answers `.nothingToCommit`, which the caller reads as "the step was crossed and needed no
/// bytes", not as a failure.
nonisolated static func apply(
_ plan: GitRestorePlan,
at boardRoot: URL,
message: String
) -> GitCommitOutcome {
_ = startUp
guard !plan.isEmpty else { return .nothingToCommit }
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 .failed(GitOperationFailure(
operation: operationName,
message: (error as NSError).localizedDescription
))
}
}
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
)
}
// MARK: - Private plumbing
private static func open(_ boardRoot: URL) -> OpaquePointer? {
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
return repository
}
private static func tree(of oid: String, in repository: OpaquePointer) -> OpaquePointer? {
var id = git_oid()
guard git_oid_fromstr(&id, oid) == 0 else { return nil }
var commit: OpaquePointer?
guard git_commit_lookup(&commit, repository, &id) == 0, let commit else { return nil }
defer { git_commit_free(commit) }
var tree: OpaquePointer?
guard git_commit_tree(&tree, commit) == 0 else { return nil }
return tree
}
private static func headTree(of repository: OpaquePointer) -> OpaquePointer? {
guard git_repository_head_unborn(repository) != 1 else { return nil }
var reference: OpaquePointer?
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
defer { git_reference_free(reference) }
var object: OpaquePointer?
guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0 else { return nil }
return object
}
/// Every blob under a tree, board-root-relative, with its object id.
///
/// The depth cap is `GitHeadSnapshot.materialize`'s, for its reason: a guard against a
/// pathological repository, not a statement about boards.
private static func fileMap(
of tree: OpaquePointer,
in repository: OpaquePointer,
prefix: String,
depth: Int,
into map: inout [String: git_oid]
) {
guard depth < 8 else { return }
for position in 0..<git_tree_entrycount(tree) {
guard let entry = git_tree_entry_byindex(tree, position),
let rawName = git_tree_entry_name(entry),
let id = git_tree_entry_id(entry) else { continue }
let name = String(cString: rawName)
// A `/` in a tree entry name is impossible in a well-formed tree and would be a path
// escape if it were not: refuse rather than interpret (`GitHeadSnapshot`'s rule).
guard !name.isEmpty, name != ".", name != "..", !name.contains("/") else { continue }
let path = prefix.isEmpty ? name : prefix + "/" + name
switch git_tree_entry_type(entry) {
case GIT_OBJECT_TREE:
var child: OpaquePointer?
guard git_tree_lookup(&child, repository, id) == 0, let child else { continue }
defer { git_tree_free(child) }
fileMap(of: child, in: repository, prefix: path, depth: depth + 1, into: &map)
case GIT_OBJECT_BLOB:
map[path] = id.pointee
default:
// Submodules and symlinks: neither is a board, and neither is followed anywhere else
// in this app either.
continue
}
}
}
private static func blob(_ oid: git_oid, in repository: OpaquePointer) -> Data? {
var id = oid
var blob: OpaquePointer?
guard git_blob_lookup(&blob, repository, &id) == 0, let blob else { return nil }
defer { git_blob_free(blob) }
let size = Int(git_blob_rawsize(blob))
guard size > 0, let bytes = git_blob_rawcontent(blob) else { return Data() }
return Data(bytes: bytes, count: size)
}
/// Every file on disk under one of `folders`, board-root-relative. `.git` is never walked it is
/// not part of any board's tree and nothing here may write into it.
private static func workingTreeFiles(under folders: Set<String>, at boardRoot: URL) -> [String] {
var found: [String] = []
for folder in folders {
let root = boardRoot.appendingPathComponent(folder)
guard let walker = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles, .skipsPackageDescendants]
) else { continue }
for case let url as URL in walker {
guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
else { continue }
guard let relative = relativePath(of: url, under: boardRoot) else { continue }
found.append(relative)
}
}
return found
}
private static func relativePath(of url: URL, under boardRoot: URL) -> String? {
let root = boardRoot.standardizedFileURL.path
let path = url.standardizedFileURL.path
guard path.hasPrefix(root + "/") else { return nil }
return String(path.dropFirst(root.count + 1))
}
private static func isInside(_ path: String, folder: String) -> Bool {
path == folder || path.hasPrefix(folder + "/")
}
private static func isInside(_ path: String, folders: Set<String>) -> Bool {
folders.contains { isInside(path, folder: $0) }
}
/// Removes folders emptied by a deletion, up to (never including) the board root the same
/// tidiness a card's own delete leaves behind, so a restore does not litter a board with empty
/// UUID folders that the loader would then have to ignore.
private static func pruneEmptyFolders(above file: URL, upTo boardRoot: URL) {
let manager = FileManager.default
let root = boardRoot.standardizedFileURL.path
var folder = file.deletingLastPathComponent().standardizedFileURL
while folder.path != root, folder.path.hasPrefix(root + "/") {
let contents = (try? manager.contentsOfDirectory(atPath: folder.path)) ?? []
guard contents.isEmpty || contents == [".DS_Store"] else { return }
try? manager.removeItem(at: folder)
folder = folder.deletingLastPathComponent().standardizedFileURL
}
}
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
var left = lhs
var right = rhs
return git_oid_cmp(&left, &right) == 0
}
}
+14
View File
@@ -88,6 +88,18 @@ public final class HistoryStore {
@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 no provider at all
/// (`AppModel.makeHistoryProvider`), and 06 Rules Detection's one sanctioned commanded flip
/// means the board now has a trail to be an undo stack over. What binding it 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)?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
@@ -189,6 +201,8 @@ public final class HistoryStore {
self.committer = committer
autoCommitWiring?(committer)
committer.start()
// 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):