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