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
+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
}
}