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
+174 -17
View File
@@ -290,19 +290,30 @@ public final class AppModel {
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it.
///
/// ### The `Tier` argument, and the one consumer it is still short
/// ### The three answers, and the `nil` among them
///
/// **`pro-m1` is the consumer that will switch on it.** The default closure ignores the tier
/// today and binds the native stack for both not because the seam is decorative, but because
/// the git provider it would bind does not exist yet (12 Tier matrix: git init/adoption,
/// git-backed undo and the history surfaces are all Pro-tier work, designed in 06-history-undo.md
/// and unbuilt). The argument is here now so that arriving milestone is one closure body rather
/// than a change to the composition root, and so that the tier a board actually composed under is
/// a recorded fact from today (`BoardSession.tier`) instead of something pro-m1 has to introduce
/// alongside its provider.
/// The closure is now the whole tier matrix, in three lines (12 Tier matrix; 06 Rules):
///
/// - **Free** the native stack, on every board. There is no `HistoryStore` at all off Pro, so
/// the absent git state *is* the tier test; nothing here reads a flag.
/// - **Pro, mode `git`** the git provider: undo as forward restore commits over HEAD's
/// first-parent ancestry.
/// - **Pro, mode `none` or `repoNested`** **no provider**. "A board without git has no
/// undo/redo", and a repo-nested board is one the app "leaves strictly alone" so Edit
/// Undo/Redo and the toolbar pair disable there ("the pair disabled on boards with no undo
/// provider in the composed tier under Pro, no-git and repo-nested boards, matching their menu
/// items", 03-board-ui.md Toolbar). Not the native stack: on Pro, a mode-none board's edits
/// are deliberately unhistoried, and half-undoing them from an in-memory stack would be a second
/// substrate the design does not have.
///
/// The `HistoryStore` argument is what makes that decidable here, and it is why `beginSession`
/// composes the git state *before* the provider: which substrate a board gets is a question about
/// its repository, and a root that had to ask the disk itself would be a second detection.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore, Tier) -> any HistoryProviding = { _, _ in
NativeHistoryProvider()
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
guard let git else { return NativeHistoryProvider() }
guard git.mode == .git else { return nil }
return GitHistoryProvider(boardRoot: store.rootURL)
}
// MARK: Sessions
@@ -323,7 +334,16 @@ public final class AppModel {
///
/// Which implementation it is, is the tier's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`.
public let history: any HistoryProviding
///
/// **`nil` is a board with no undo at all** under Pro, mode `none` and repo-nested boards
/// (06-history-undo.md Rules: "A board without git has **no undo/redo**"). The command
/// surface disables through `undoManager`, which answers the empty way over an absent
/// substrate.
///
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
/// sanctioned mid-session mode flip, binds a provider here on the board it flips
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it `tier` has no setter.
public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md The entitlement).
///
@@ -724,10 +744,6 @@ public final class AppModel {
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
// re-derives it.
let tier = currentTier()
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior".
let history = makeHistoryProvider(store, tier)
// **Mode detection** (06-history-undo.md Rules Detection: "checked at every board
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
// without looking at the disk at all the inert posture is unconditional there and under
@@ -736,7 +752,17 @@ public final class AppModel {
//
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
//
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
// disk itself would be a second detection able to disagree with this one.
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier)
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards
// it is instead the repository's own trail, which survives everything (06 Rules Undo
// survives relaunch) the seam's whole point.
let history = makeHistoryProvider(store, tier, git)
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md Fractal
// layout Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
@@ -763,6 +789,20 @@ public final class AppModel {
store?.banners.clearHistorySuspension()
}
store.commitSeam = .binding(to: committer)
// **The undo stack's ear on the committer** every commit this engine lands, and
// which of it was heal work (06 Rules The stack is HEAD's first-parent ancestry,
// live; Heal commits are transparent to undo). Bound here rather than in
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
// `activateAutoCommit` remembers on its behalf.
committer.reportLanded = { [weak self, ref] window in
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
provider.noteLanded(window)
}
}
// **Add-git binds undo too** (06 Rules Detection the one commanded mid-session mode
// flip). See `bindHistoryProvider(for:)` for the judgment call this records.
git.didAddGit = { [weak self] in
self?.bindHistoryProvider(for: ref)
}
}
// **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
@@ -789,10 +829,127 @@ public final class AppModel {
cardRefs: [],
access: access
)
wireGitUndo(history, store: store, git: git, ref: ref)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
// MARK: - The git provider's wiring
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of and does nothing
/// at all for any other substrate.
///
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason the provider
/// stays a thing that knows about commits, and the model stays the only object that knows what a
/// window is.
private func wireGitUndo(
_ history: (any HistoryProviding)?,
store: BoardStore,
git: HistoryStore?,
ref: BoardWindowRef
) {
guard let provider = history as? GitHistoryProvider, let git else { return }
provider.flushPendingCommit = { [weak git] in
await git?.committer?.flushNow()
}
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
provider.resumeCommitting = { [weak git] in git?.committer?.start() }
// **A restore that failed cleanly** (06 Interaction with external writers: "surfaces as a
// one-shot banner failure naming the operation and the error, the tree left as it was").
//
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
// `WriteOperation` vocabulary and a git operation is deliberately not one of those
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
// a banner-surface change, not this card's.
provider.reportFailure = { [weak store] failure in
store?.banners.postLoss(failure.description)
}
provider.runBracketed = { [weak store] subject, work in
guard let store else { return await work() }
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
// own subject the sentence the trail now carries, spoken once when the reload lands.
try? await store.performWholesale(announcing: subject) { await work() }
}
provider.settleSessions = { [weak self, weak provider] paths in
guard let self, let provider else { return .proceed }
return await self.settleGate(for: ref, provider: provider).settle(touching: paths)
}
provider.seed()
}
/// **The save-or-discard step for one board**, built from its open card windows
/// (06-history-undo.md Rules Undo restore vs open Edit sessions; Branch switching).
///
/// Built per ask rather than stored, because its whole content is "which card windows are open
/// right now" a set that changes under any operation slow enough to need the step at all.
func settleGate(for ref: BoardWindowRef, provider: GitHistoryProvider?) -> SessionSettleGate {
SessionSettleGate(
sessions: { [weak self] in
guard let self, let session = self.sessions[ref] else { return [] }
return session.cardRefs.compactMap { cardRef in
guard let flushing = self.cardSessions[cardRef],
let settlement = flushing.settlement else { return nil }
return SettleableSession(
id: cardRef.cardID,
cardFolderName: cardRef.cardID,
needsSettling: settlement.needsSettling,
saveAll: settlement.saveAll,
discard: { [weak provider] in
settlement.discard()
// The card's uncommitted on-disk saves are reverted by the restore
// itself, which compares this folder against the working tree rather
// than against HEAD see `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderPath: cardRef.cardID)
}
)
}
},
ask: { await SessionSettleStep.ask() },
focus: { [weak self] id in
guard let self, let session = self.sessions[ref] else { return }
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
// Opening a window that is already open is how SwiftUI's value-addressed groups say
// "bring that one forward" the same call `BoardWindowHost` makes to open a card, and
// the reason reopening a live card focuses its window rather than making a second one.
self.windowOpener?(id: WindowID.card, value: cardRef)
}
)
}
/// **Binds the git provider onto an already-open session** add-git's one caller.
///
/// 06 Rules Detection sanctions exactly one mid-session mode flip, the app's own add-git:
/// "clicking it flips the open board into git mode immediately the popover flows straight into
/// the git controls, the first auto-commit follows". It does not mention undo, so this is a
/// judgment call and it is recorded here: **the flip binds undo too**, live, rather than waiting
/// for the next open. Three reasons point the same way the mode flip already carries the
/// *committer* through (`HistoryStore.activateAutoCommit` remembers its wiring for precisely this
/// board); 12-editions.md's "an open board finishes with the provider it composed" is a rule about
/// a **tier** lapsing, which cannot change a running session at all; and a board that visibly
/// starts accumulating commits while Z stays greyed out until it is closed and reopened would
/// read as a defect rather than as a policy.
///
/// It only ever adds. A board that already has a provider keeps it, and nothing here can take one
/// away there is no un-add-git.
func bindHistoryProvider(for ref: BoardWindowRef) {
guard var session = sessions[ref], session.history == nil,
let git = session.git, git.mode == .git else { return }
guard let history = makeHistoryProvider(session.store, session.tier, git) else { return }
session.history = history
sessions[ref] = session
session.store.history = history
session.undoManager.history = history
wireGitUndo(history, store: session.store, git: git, ref: ref)
}
/// Registers a card window with its board's session, so the close flush can find it.
///
/// A card window whose board has no session is a card window with no board the ownership rule
@@ -1112,7 +1269,7 @@ public final class AppModel {
// Cleared rather than merely dropped because the steps hold closures over the store
// this line is about to release, and a stack that outlived its board would be a
// retain cycle wearing an undo stack's clothes.
session.history.clear()
session.history?.clear()
storeRegistry.release(session.store)
session.access?.stop()
}
+87
View File
@@ -77,6 +77,14 @@ final class CardWindowSession: CardSessionFlushing {
/// window that has not joined its board holds nothing, which is true.
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
/// The window's raw-source outlet, as the save-or-discard step's two writes: **Apply** (which
/// validates, and answers `false` when it refuses) and **Cancel**. Wired by the host beside
/// `rawSourceHoldsUnsavedText`, and for its reason the outlet is window state living beside
/// this object rather than inside it.
var rawSourceApply: (@MainActor () -> Bool)?
var rawSourceCancel: (@MainActor () -> Void)?
var rawSourceIsActive: (@MainActor () -> Bool)?
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
/// holding keystrokes its file has not got see `CardSessionFlushing`.
///
@@ -89,6 +97,44 @@ final class CardWindowSession: CardSessionFlushing {
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
}
/// **What a restore or a branch switch asks this window to settle** (06-history-undo.md Rules
/// Undo restore vs open Edit sessions).
///
/// ### The predicate is *open*, not *dirty*
///
/// An **open** Edit session is what needs settling even with a clean buffer, because its ~700 ms
/// saves are on disk and deliberately uncommitted the stage-around rule's whole point so a
/// restore landing over them would either bury text no commit protects or leave the session's
/// next debounced save to write pre-restore bytes back over the restored card, "a Z that visibly
/// doesn't happen". Same reading `CardBodyEditSession.isEditing` records for staging, applied to
/// the same fact.
///
/// An **open raw-source outlet** counts whether or not it has been typed in, and 06 says why: its
/// Apply "would write the *entire* pre-switch `index.md` byte-for-byte onto the new branch's
/// card". A buffer read from before the restore is the hazard; typing is not required for it.
var settlement: CardSessionSettlement? {
CardSessionSettlement(
needsSettling: { [self] in
body.isEditing || body.isDirty || rawSourceIsActive?() == true
},
saveAll: { [self] in
// The Edit session ends with its normal commit "each card's EditPreview flip".
body.endEditSession()
// Apply validates; a refusal is the whole operation's cancellation, and the alert it
// raised is already on the offending window.
guard rawSourceIsActive?() == true else { return true }
return rawSourceApply?() ?? true
},
discard: { [self] in
// The buffer goes back to what disk says; the *disk* goes back to the target state as
// part of the restore itself, which reconciles this card's folder against the working
// tree rather than against HEAD (`GitRestoreOperation.plan`).
body.discardBuffer()
rawSourceCancel?()
}
)
}
private var hasEnded = false
/// Both `let`s, wired to each other through a local the guard's two closures need the buffer,
@@ -185,6 +231,9 @@ struct CardWindowHost: View {
/// snapshot the store applies a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`).
@State private var thumbnails = AttachmentThumbnailCache()
/// This card's commit trail (05-card-window.md History). Held here for `thumbnails`' reason
/// it must survive every snapshot and surfaced to the view only in git mode (`cardHistory`).
@State private var history = CardHistory()
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
/// flush; cleared by the resolution that lets the close resume.
@State private var isClosePending = false
@@ -311,6 +360,29 @@ struct CardWindowHost: View {
.onDisappear { finish() }
}
/// **This card's commit trail, or nothing at all** (05-card-window.md History).
///
/// `nil` is the section's absence rule, read from the board's own git state rather than from a
/// flag: no `HistoryStore` means the free tier (12-editions.md where the section never exists),
/// and a mode other than `git` means a board the app manages no history for. The object is held
/// by this host so it survives every snapshot, `thumbnails`' reason exactly.
private var cardHistory: CardHistory? {
guard appModel.session(for: ref.board)?.gitMode == .git else { return nil }
return history
}
/// What a trail re-read depends on: this card, and the number of commits the board has landed.
///
/// The count is the committer's own (`GitAutoCommitter.commitCount`), which advances for every
/// commit the app makes the debounced ones, the launch catch-up, and a restore's. A foreign
/// commit an agent made *itself* moves HEAD without touching it; the trail then refreshes at the
/// next commit or the next open, which is the same freshness bound the popover's branch line has
/// and a great deal cheaper than polling HEAD from a sidebar.
private func historyReloadKey(store: BoardStore) -> String {
let commits = appModel.session(for: ref.board)?.git?.committer?.commitCount ?? 0
return "\(ref.cardID)#\(commits)"
}
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md
/// Composition) which is the whole reason the stacked mount exists, so a narrow display keeps
/// the minimum it always had.
@@ -343,11 +415,20 @@ struct CardWindowHost: View {
attachments: attachments,
comments: session.comments,
thumbnails: thumbnails,
history: cardHistory,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
}
)
// **The trail, re-read when a commit lands** (05 History). The id is the pair of facts
// the answer depends on: which card this is, and how many commits this board has made
// so the section refreshes after the app's own commits, after an agent's that the watcher
// committed, and after a Z's restore, with nothing here knowing what a committer is.
.task(id: historyReloadKey(store: store)) {
guard let cardHistory else { return }
await cardHistory.load(boardRoot: store.rootURL, cardFolderName: ref.cardID)
}
// **The listing is the snapshot's, republished** `Card.attachments`, which the loader
// fills from `attachments/`'s top-level files in Finder order. Every write in the
// section is bracketed, so the reload that refreshes this arrives by itself and the
@@ -544,6 +625,12 @@ struct CardWindowHost: View {
// The other half of the outlet's wiring: the session answers for this window's unsaved
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
// The save-or-discard step's half of the same wiring (06-history-undo.md Branch switching):
// Save All *applies* an open outlet validation included, so a refusal cancels the whole
// operation and Discard leaves it without writing.
session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
Self.configureAttachments(attachments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID)
}
+12
View File
@@ -42,11 +42,23 @@ public protocol CardSessionFlushing: AnyObject {
/// template that silently missed those keystrokes would break 09's never-misses-keystrokes
/// guarantee, which outranks the item's availability.
var holdsUnsavedContent: Bool { get }
/// **This window's answers to the save-or-discard step** (06-history-undo.md Rules Undo
/// restore vs open Edit sessions; Branch switching), or `nil` for a window with nothing a
/// wholesale tree operation could disturb.
///
/// Beside `holdsUnsavedContent` and deliberately not folded into it: that property answers "would
/// a *template copy* miss keystrokes", which is a read; this one carries the two writes Save
/// All and Discard that a restore or a branch switch needs the window to perform before it can
/// run on a settled tree. Same window, two different questions, and collapsing them would give
/// the settle step the draft-composer exemption `holdsUnsavedContent` deliberately makes.
var settlement: CardSessionSettlement? { get }
}
public extension CardSessionFlushing {
func endSession() async {}
var holdsUnsavedContent: Bool { false }
var settlement: CardSessionSettlement? { nil }
}
// MARK: - CloseFlushCoordinator
+253
View File
@@ -0,0 +1,253 @@
import AppKit
import Foundation
// MARK: - Vocabulary
/// What the user chose at the save-or-discard step (06-history-undo.md Branch switching: "**Save
/// All** ends every session with its normal commit , **Discard** reverts buffers and uncommitted
/// saves to HEAD, **Cancel** keeps the current branch and the sessions").
public enum SessionSettleChoice: Sendable, Equatable {
case saveAll
case discard
case cancel
}
/// What the gate concluded the only thing the operation behind it branches on.
public enum SessionSettleOutcome: Sendable, Equatable {
/// Nothing needed settling, or everything did and did. The tree is settled; run.
case proceed
/// The user chose Cancel. "Cancel keeps everything" nothing was written, nothing reverted.
case cancelled
/// **Save All met a raw-source buffer that would not validate.** "Since Apply validates, a buffer
/// that fails validation cancels the whole switch with focus on the offending window, nothing
/// half-switched" (06 Branch switching). The payload is that window's session id, already
/// focused by the gate.
case failed(String)
}
// MARK: - What one card window offers the step
/// **A card window's three answers to the save-or-discard step**, handed over as closures.
///
/// A type of its own rather than three members on `CardSessionFlushing` because it is optional as a
/// unit: a window with nothing settleable in it has no settlement, and the gate should not have to
/// ask three questions to find that out. `nil` is also every window in a build with no card session
/// at all, which is what the protocol's default supplies.
@MainActor
public struct CardSessionSettlement {
/// Whether this window is holding state a wholesale tree operation would disturb.
public let needsSettling: @MainActor () -> Bool
/// Ends the Edit session with its normal commit and *applies* the raw buffer. `false` means the
/// raw buffer failed validation.
public let saveAll: @MainActor () -> Bool
/// Reverts the Edit buffer and leaves raw source without writing.
public let discard: @MainActor () -> Void
public init(
needsSettling: @escaping @MainActor () -> Bool,
saveAll: @escaping @MainActor () -> Bool,
discard: @escaping @MainActor () -> Void
) {
self.needsSettling = needsSettling
self.saveAll = saveAll
self.discard = discard
}
}
// MARK: - One settleable session
/// **A card window, as the save-or-discard step sees it** three closures and the card it is over.
///
/// A value of closures rather than a protocol over `CardWindowSession`, for `CloseFlushCoordinator`'s
/// reason exactly: what this gate is *about* is a decision procedure, and a procedure written against
/// a live window is verifiable only by running the app. The production values come from the card
/// windows; a test builds them from a counter.
@MainActor
public struct SettleableSession {
/// The window's identity `CardWindowRef.cardID` is what production passes. Opaque to the gate,
/// and only ever handed back to `focus`.
public let id: String
/// The **card's folder name** its id, which is its folder on disk (01-storage-format.md).
///
/// Matched component-wise against the paths a wholesale operation would write, which is what
/// makes the match survive a lane move: a card's own folder component never changes, only the
/// lane above it (`GitHistoryWalk.path(_:isInsideFolderNamed:)`, the same trick, same reason).
public let cardFolderName: String
/// Whether this session is holding state a wholesale tree operation would disturb: unsaved
/// keystrokes, an **open** Edit session whose ~700 ms saves are deliberately uncommitted, or a
/// raw-source outlet that is open at all.
public let needsSettling: @MainActor () -> Bool
/// **Save All** for this one session: end the Edit session with its normal commit, and *apply*
/// the raw buffer. `false` means the raw buffer failed validation the whole operation is off.
public let saveAll: @MainActor () -> Bool
/// **Discard** for this one session: revert the buffer and leave raw source without writing. The
/// on-disk uncommitted saves are reverted by the operation itself, which is comparing this card's
/// folder against the working tree rather than against HEAD for exactly that reason
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
public let discard: @MainActor () -> Void
public init(
id: String,
cardFolderName: String,
needsSettling: @escaping @MainActor () -> Bool,
saveAll: @escaping @MainActor () -> Bool,
discard: @escaping @MainActor () -> Void
) {
self.id = id
self.cardFolderName = cardFolderName
self.needsSettling = needsSettling
self.saveAll = saveAll
self.discard = discard
}
}
// MARK: - SessionSettleGate
/// **The save-or-discard step**, as one reusable decision procedure (06-history-undo.md Rules
/// Undo restore vs open Edit sessions; Branch switching).
///
/// ### One machinery, two callers, by design
///
/// 06 does not describe two gates. It describes the branch-switch step and then hands undo the same
/// one by name: "When the diff *does* touch a session card, the restore **gates on the branch-switch
/// save-or-discard step** (Branch switching below Save All / Discard / Cancel, same machinery, same
/// rationale)." So this object is written for both from the start; the undo provider is its first
/// caller and the branch controls will be its second, passing the paths a checkout would write
/// instead of the paths a restore would.
///
/// ### The diff decides whether it appears at all
///
/// "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 its uncommitted ~700 ms
/// saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all."
/// That is `settle(touching:)`'s first line, and it is why the gate takes paths rather than a
/// yes/no: a modal that appeared on every Z because *some* window somewhere was in Edit would be a
/// different, much worse feature.
///
/// ### Why the ask is a closure
///
/// Presenting three buttons is AppKit's job and cannot be asserted without a display. The rule this
/// file exists to hold which sessions are asked about, what each answer does to them, and that a
/// failing raw buffer stops everything with focus on the offender is decidable from values, so the
/// presentation is a seam and the decision is testable.
@MainActor
public struct SessionSettleGate {
/// Every open card session on this board, read live: a window can open or close between the
/// moment an operation starts and the moment it asks.
public var sessions: () -> [SettleableSession]
/// Presents the three-button step and answers what the user chose.
public var ask: () async -> SessionSettleChoice
/// Brings one session's window forward the "focus on the offending window" half of the
/// validation-failure rule.
public var focus: (String) -> Void
public init(
sessions: @escaping () -> [SettleableSession],
ask: @escaping () async -> SessionSettleChoice,
focus: @escaping (String) -> Void = { _ in }
) {
self.sessions = sessions
self.ask = ask
self.focus = focus
}
// MARK: The decision
/// Settles whatever the operation's paths reach, and answers whether it may run.
///
/// - Parameter paths: board-root-relative paths the operation would write.
public func settle(touching paths: Set<String>) async -> SessionSettleOutcome {
let candidates = Self.reached(by: paths, among: sessions()).filter { $0.needsSettling() }
guard !candidates.isEmpty else { return .proceed }
switch await ask() {
case .cancel:
return .cancelled
case .discard:
for session in candidates { session.discard() }
return .proceed
case .saveAll:
for session in candidates {
guard session.saveAll() else {
// "Nothing half-switched": the sessions saved before this one are saved, which is
// an ordinary Save and loses nothing, but the operation itself does not run.
focus(session.id)
return .failed(session.id)
}
}
return .proceed
}
}
/// **Which sessions a set of paths reaches** pure, and the whole of "the diff touches a session
/// card".
///
/// Component-exact folder matching, so a card whose id happens to be a prefix of another's cannot
/// drag that other card's window into the step.
public static func reached(
by paths: Set<String>,
among sessions: [SettleableSession]
) -> [SettleableSession] {
guard !paths.isEmpty else { return [] }
return sessions.filter { session in
paths.contains { GitHistoryWalk.path($0, isInsideFolderNamed: session.cardFolderName) }
}
}
}
// MARK: - The presented step
/// **The three buttons**, as an `NSAlert` the production `SessionSettleGate.ask`.
///
/// One of 02-architecture.md's sanctioned modal moments, and it is modal for `DirtyBufferGuard`'s
/// reason exactly: the operation behind it cannot proceed until the user has decided what happens to
/// text no commit protects, and there is no non-modal shape for a question whose three answers are
/// mutually exclusive and immediate.
///
/// The wording is 06's own vocabulary. The default is **Cancel**, deliberately: a Return pressed
/// reflexively at a dialog nobody read must be the answer that changes nothing, and both other
/// answers write.
public enum SessionSettleStep {
public static let title = "Unsaved card edits"
public static let message = """
Restoring an earlier state would change cards you are editing. \
Save them, discard the changes, or cancel.
"""
@MainActor
public static func ask() async -> SessionSettleChoice {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = title
alert.informativeText = message
// Order matters for the key equivalents AppKit assigns: the first button takes Return, so
// Cancel leads and the two writing answers follow. Escape reaches Cancel either way.
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: "Save All")
alert.addButton(withTitle: "Discard")
switch alert.runModal() {
case .alertSecondButtonReturn: return .saveAll
case .alertThirdButtonReturn: return .discard
default: return .cancel
}
}
}
+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):
+34 -9
View File
@@ -48,7 +48,31 @@ public final class BoardUndoManager: UndoManager {
/// The substrate this manager is a face for. Strong: the session owns both, and the manager is
/// only ever reachable while the session that made it is alive.
private let history: any HistoryProviding
///
/// ### `nil` is a board with **no undo provider**, and it is a real state
///
/// Under Pro, a board in mode `none` or `repoNested` gets no provider at all "the pair disabled
/// on boards with no undo provider in the composed tier under Pro, no-git and repo-nested
/// boards, matching their menu items" (03-board-ui.md Toolbar Catalog; 06-history-undo.md
/// Rules). Every question below answers the empty way, so the Edit menu's rows, the toolbar
/// pair, and Z itself go quiet together, through the same validation path a lock uses. Modelling
/// it as an absent substrate rather than as a substrate that always says no is the honest shape:
/// there is nothing there, and nothing can accidentally accumulate in it.
///
/// ### Settable, for exactly one event
///
/// **Add-git** (06 Rules Detection) is the design's one sanctioned mid-session mode flip:
/// "clicking it flips the open board into git mode immediately the popover flows straight into
/// the git controls, the first auto-commit follows". A board that gains a repository mid-session
/// gains a commit trail, and a trail with a dead Z over it would read as a bug. The composition
/// root binds the git provider here on that flip, rather than rebuilding this object, so AppKit
/// keeps the identical manager it has already been handed by `windowWillReturnUndoManager`.
///
/// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it
/// composed" is about a subscription lapsing, which cannot change a running session's tier at
/// all `BoardSession.tier` is a `let` with no setter. Mode can change, by explicit command,
/// and only in this one direction.)
var history: (any HistoryProviding)?
/// Whether the board is refusing writes `BoardStore.isReadOnly`, read through a closure rather
/// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*,
@@ -58,7 +82,7 @@ public final class BoardUndoManager: UndoManager {
/// the adapter's own grammar should have.
private let isReadOnly: @MainActor () -> Bool
public init(history: any HistoryProviding, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
public init(history: (any HistoryProviding)?, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
self.history = history
self.isReadOnly = isReadOnly
super.init()
@@ -68,10 +92,11 @@ public final class BoardUndoManager: UndoManager {
/// **False under the lock, whatever the stack holds.** The steps are still there this is an
/// enablement answer, not a clearing so the first Z after the lock clears crosses the step it
/// would have crossed before it landed.
public override var canUndo: Bool { !isReadOnly() && history.canUndo }
/// would have crossed before it landed. False with no substrate at all, for the reason
/// `history` records.
public override var canUndo: Bool { !isReadOnly() && history?.canUndo == true }
public override var canRedo: Bool { !isReadOnly() && history.canRedo }
public override var canRedo: Bool { !isReadOnly() && history?.canRedo == true }
// MARK: Crossing
@@ -80,17 +105,17 @@ public final class BoardUndoManager: UndoManager {
/// started anyway is refused one layer down by `performWrite` which leaves the step on the
/// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second
/// guard here would be a second answer to one question.
public override func undo() { history.undo() }
public override func undo() { history?.undo() }
public override func redo() { history.redo() }
public override func redo() { history?.redo() }
// MARK: Titles
/// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" `""` when there is
/// nothing to cross, which is what its menu-title composition expects.
public override var undoActionName: String { history.undoActionName ?? "" }
public override var undoActionName: String { history?.undoActionName ?? "" }
public override var redoActionName: String { history.redoActionName ?? "" }
public override var redoActionName: String { history?.redoActionName ?? "" }
/// "Undo Move 3 Cards" composed and localized by the platform (`undoMenuTitle(forUndoActionName:)`
/// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app
+33
View File
@@ -1248,6 +1248,39 @@ public final class BoardStore: HealHost {
}
}
/// The same bracket over work that **awaits** the undo restore (06-history-undo.md) and, next,
/// the branch switch.
///
/// A sibling rather than a replacement, and the reason is a hard fact about the two callers: the
/// synchronous version above exists because `performWrite`-shaped work is synchronous, while a
/// git operation is a detached libgit2 task the main actor must not block on
/// (`GitRepository`'s isolation rule). Both keep the bracket, the reload floor and the completion
/// phrase in one place; the distinct argument label is what keeps overload resolution from having
/// to guess which one a trailing closure meant.
///
/// The refusal, the ordering and the arming are the synchronous version's, unchanged see its
/// doc comment for all three.
public func performWholesale(
announcing completion: String? = nil,
awaiting operation: () async throws -> Void
) async throws {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
watcherBrackets?.begin()
defer {
wholesaleReloadFloor = reloadGeneration + 1
wholesaleCompletion = completion
watcherBrackets?.end()
}
do {
try await operation()
} catch let error as BoardWriteError {
banners.post(error)
throw error
}
}
// MARK: - Lane width
/// Writes a lane's width the one commit point both width mechanisms share (03-board-ui.md §
+22
View File
@@ -226,6 +226,28 @@ public final class CardBodyEditSession {
return outcome
}
/// **Throws the buffer away and takes disk's word for it** the Discard branch of the
/// save-or-discard step (06-history-undo.md Branch switching: "Discard reverts buffers and
/// uncommitted saves to HEAD").
///
/// It reverts the *buffer* and ends the session; the uncommitted on-disk saves are the operation
/// behind the step's to undo, because only that operation knows which state it is restoring to
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)` reconciles the card's folder
/// against the working tree for exactly this reason). Splitting it that way is what keeps the two
/// halves from being two answers able to disagree: one pass writes the card's files, once.
///
/// The pending debounce is cancelled first, which is the load-bearing half a surviving timer
/// would write the discarded text back over the restored card a moment later.
public func discardBuffer() {
cancelPending()
text = disk
sessionOriginBody = nil
if isEditing {
isEditing = false
editSessionDidChange?(false)
}
}
/// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
/// reported.
///
+185
View File
@@ -0,0 +1,185 @@
import Observation
import SwiftUI
// MARK: - One row
/// One **History** row: a commit that touched this card's folder (05-card-window.md History).
///
/// A value rather than the `GitCommitRecord` itself, so the view renders strings a test has already
/// checked and never formats a date in a `body`.
struct CardHistoryRow: Identifiable, Equatable, Sendable {
/// The commit's oid the identity, and nothing the row shows.
let id: String
/// The commit's subject, exactly as the message engine wrote it.
let subject: String
/// "2 days ago · Claude" the row's second line.
let attribution: String
}
// MARK: - The seam
/// What the History section shows, as a pure function of commits and a clock
/// (05-card-window.md History: "newest first semantic subject, relative date, author").
enum CardHistoryRows {
/// The rows for one card's commits, newest first which is the order the walk already answers
/// in, so nothing here re-sorts and nothing can disagree with git about what "newest" means.
nonisolated static func rows(
for commits: [GitCommitRecord],
now: Date = Date(),
locale: Locale = .autoupdatingCurrent
) -> [CardHistoryRow] {
commits.map { commit in
CardHistoryRow(
id: commit.oid,
subject: commit.subject,
attribution: attribution(of: commit, now: now, locale: locale)
)
}
}
/// "relative date · author", with the author dropped when there is none to name.
///
/// The author is the commit's, which is where origin lives (06-history-undo.md Interaction with
/// external writers) so a foreign commit reads `Lanework External` and a `modified-by` agent
/// reads its own name, with no rendering rule of this section's own. That is 05's claim that the
/// trail "reads as a story, agent and hand edits included" arriving for free.
nonisolated static func attribution(
of commit: GitCommitRecord,
now: Date = Date(),
locale: Locale = .autoupdatingCurrent
) -> String {
let when = relativeDate(commit.date, now: now, locale: locale)
let author = commit.authorName.trimmingCharacters(in: .whitespacesAndNewlines)
return author.isEmpty ? when : "\(when) · \(author)"
}
/// A relative date in the system's own words ("2 days ago"), with **"just now"** for anything
/// inside a minute.
///
/// The floor is a judgment call, recorded: `RelativeFormatStyle` renders a five-second-old commit
/// as "in 0 seconds" whenever the clock rounds the wrong way, and a trail whose newest row reads
/// as the future is worse than one that rounds down. Everything past a minute is the platform's
/// answer verbatim, localized and abbreviated to suit a 26-character-wide sidebar.
nonisolated static func relativeDate(
_ date: Date,
now: Date = Date(),
locale: Locale = .autoupdatingCurrent
) -> String {
guard now.timeIntervalSince(date) >= 60 else { return "just now" }
var style = Date.RelativeFormatStyle(presentation: .named, unitsStyle: .wide)
style.locale = locale
return date.formatted(style.locale(locale))
}
}
// MARK: - The loader
/// **One card window's commit trail** the object the sidebar renders and the host refreshes.
///
/// ### Its existence is the section's visibility rule
///
/// "The section is **absent** on boards without app-managed git (mode none, repo-nested) same
/// honesty rule as the popover's git section" (05 History), and the free tier has no git state at
/// all (12-editions.md). So the host builds one of these only in mode `git`, and `nil` is the whole
/// of the absence no placeholder, no empty header, nothing to explain.
///
/// ### It re-reads rather than subscribes
///
/// The trail changes when a commit lands, which is exactly what `GitAutoCommitter.commitCount`
/// counts. The host re-asks on that number and on the card's own folder, so a trail refreshes after
/// every commit the app's own, an agent's the watcher committed, and a restore's without this
/// object learning what a committer is.
@MainActor
@Observable
final class CardHistory {
/// The rows, newest first. Empty until the first load answers, which is also the honest answer for
/// a card whose folder no commit has touched yet.
private(set) var rows: [CardHistoryRow] = []
/// Whether a load is in flight what keeps the section from flashing "no history yet" during the
/// first walk of a large repository.
private(set) var isLoading = false
init() {}
/// Reads the commits that touched `cardFolderName` under `boardRoot`, off the main actor.
func load(boardRoot: URL, cardFolderName: String) async {
isLoading = true
defer { isLoading = false }
let commits = await Task.detached(priority: .utility) {
GitHistoryWalk.commitsTouching(folderNamed: cardFolderName, at: boardRoot)
}.value
rows = CardHistoryRows.rows(for: commits)
}
}
// MARK: - The section
/// The sidebar's **History** section: the card's commit trail, read-only, newest first
/// (05-card-window.md History).
///
/// ### No actions, deliberately
///
/// "Rows are focusable (arrows), but carry **no actions in v1** restoring an old version stays a
/// git-client task for now; a per-row forward-restore and lane history are wishlist items,
/// deliberately." So the rows are text: selectable, copyable, and nothing else. The one restore this
/// milestone ships is board-level Z, which is a different gesture with a different target.
///
/// ### Empty says so, rather than disappearing
///
/// Contrast Details beside it, which vanishes when a card has no unknown keys. The distinction is
/// what an empty state would *imply*: an absent Details section implies nothing (most cards have no
/// unknown keys), while an absent History section on a git board would imply the board has no
/// history the exact claim 05 reserves for boards that genuinely have none. A card whose folder is
/// newer than its last commit is a real and temporary state, and one quiet line is the honest way to
/// say so.
struct CardHistorySection: View {
let history: CardHistory
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "History")
if history.rows.isEmpty {
Text(history.isLoading ? "Reading history…" : "No commits yet")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
ForEach(history.rows) { row in
self.row(row)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
/// Subject over attribution `CardDetailsSection.row`'s shape, inverted: there the quiet line is
/// the key and the loud one the value; here the *subject* is what a reader scans for and the date
/// and author are the qualifier. Same two fonts, same wrap-rather-than-truncate rule, so the two
/// sections read as one column at any text size.
private func row(_ row: CardHistoryRow) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(row.subject)
.font(.callout)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
Text(row.attribution)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(row.subject), \(row.attribution)")
}
}
+15 -12
View File
@@ -91,6 +91,10 @@ struct CardWindowView: View {
let comments: CardComments
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
let thumbnails: AttachmentThumbnailCache
/// **This card's commit trail** (05 History), or `nil` on every board with no app-managed git
/// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule;
/// see `historySlot`.
let history: CardHistory?
/// The whole-window file drop (05 Attachments: "the drop surface remains the **whole
/// window**"). `nil` only where a caller has no store to import through.
let fileDrop: CardWindowDropDelegate?
@@ -325,22 +329,21 @@ struct CardWindowView: View {
}
}
/// **The History section's reserved place in the stack** between Details and Actions, 05's
/// order (05 History: "the card's commit trail, read-only newest first semantic subject,
/// relative date, author").
/// **The History section** between Details and Actions, 05's order (05 History: "the card's
/// commit trail, read-only newest first semantic subject, relative date, author").
///
/// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not
/// exist here, so a header over empty space would claim a commit trail on every board and on
/// the boards where it is *absent* by design (mode none, repo-nested) it would be claiming one
/// that can never arrive. What the slot reserves is the **position**, so filling it in moves
/// nothing above or below it.
/// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without
/// app-managed git (mode none, repo-nested) same honesty rule as the popover's git section",
/// and the free tier has no git state at all (12-editions.md The free tier and `.git`). The host
/// builds a `CardHistory` only in mode `git`, so there is no placeholder here to decide about:
/// what the slot reserves is the **position**, and on every other board that position is empty.
///
// m7-git: the trail itself, plus the two rules that come with it absence on boards without
// app-managed git (the same honesty rule as the board popover's git section, 06-history-undo.md)
// and View History, which focuses this section (11-command-nexus.md).
// A later card: View History, which focuses this section (11-command-nexus.md).
@ViewBuilder
private var historySlot: some View {
EmptyView()
if let history {
CardHistorySection(history: history)
}
}
}