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:
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: ¤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 }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user