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,337 @@
|
||||
import Foundation
|
||||
import libgit2
|
||||
import os
|
||||
|
||||
// MARK: - The plan
|
||||
|
||||
/// **One restore, as the writes it will make** — computed before anything touches the working tree,
|
||||
/// so the whole of what a ⌘Z is about to do is a value a caller can inspect, gate on, and test.
|
||||
public struct GitRestorePlan: Sendable, Equatable {
|
||||
|
||||
/// One file the restore will write or remove.
|
||||
public struct Change: Sendable, Equatable {
|
||||
/// Board-root-relative, in git's own spelling.
|
||||
public let path: String
|
||||
/// The bytes to write, or `nil` to remove the file.
|
||||
public let contents: Data?
|
||||
|
||||
public init(path: String, contents: Data?) {
|
||||
self.path = path
|
||||
self.contents = contents
|
||||
}
|
||||
}
|
||||
|
||||
public let changes: [Change]
|
||||
|
||||
public init(changes: [Change]) {
|
||||
self.changes = changes
|
||||
}
|
||||
|
||||
public var paths: [String] { changes.map(\.path) }
|
||||
|
||||
public var isEmpty: Bool { changes.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - GitRestoreOperation
|
||||
|
||||
/// **Undo and redo, as forward commits** (14-git-operations.md ▸ The forward-restore model; the
|
||||
/// load-bearing extraction): "Every restorative operation moves history forward. Nothing the app does
|
||||
/// ever rewrites a published commit: no reset, no force-push, no revert-by-rewrite."
|
||||
///
|
||||
/// ### What this file is allowed to call, and what it is not
|
||||
///
|
||||
/// It materializes an older state as **ordinary working-tree writes** and then commits them through
|
||||
/// the same signature-capable path every auto-commit takes (`GitCommitOperation.perform`). It never
|
||||
/// calls `git_reset`, never moves a reference by hand, never writes `refs/`, and never touches the
|
||||
/// reflog: the only ref movement in the whole restore is `git_commit_create`'s own advance of HEAD,
|
||||
/// which is what a commit *is*. That is the property "verifiable by trail inspection in any git
|
||||
/// client" reduces to, and it is checkable here by reading the imports: nothing below resolves a
|
||||
/// reset or a checkout symbol at all.
|
||||
///
|
||||
/// ### Only the diff, never the tree
|
||||
///
|
||||
/// "A restore materializes only the diff between the current tree and the target state, so a card
|
||||
/// whose open Edit session the diff doesn't touch is simply unaffected" (06-history-undo.md ▸ Rules
|
||||
/// ▸ Undo restore vs open Edit sessions). So the plan is HEAD's tree against the target's, file by
|
||||
/// file — never a checkout of the whole target, which would sweep every unrelated file on the board
|
||||
/// through a write it did not need.
|
||||
///
|
||||
/// Two deliberate narrowings ride on that:
|
||||
///
|
||||
/// - **`excluding`** — the heal-transparency rule's second half (06 ▸ Rules ▸ Heal commits are
|
||||
/// transparent to undo): "a restore materializing an older target **excludes paths whose divergence
|
||||
/// is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler."
|
||||
/// - **`reconciling`** — the folders of card sessions the user chose to **Discard** at the
|
||||
/// save-or-discard step (06 ▸ Branch switching: "Discard reverts buffers and uncommitted saves to
|
||||
/// HEAD"). Those folders are compared against the **working tree** rather than against HEAD,
|
||||
/// because their uncommitted on-disk saves are precisely the state HEAD does not have — one pass
|
||||
/// that both drops the discarded saves and applies the restore, instead of a revert followed by a
|
||||
/// restore that would have to agree with it.
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in
|
||||
/// the same synchronous scope, and no handle crosses an `await`. Called from a detached task.
|
||||
enum GitRestoreOperation {
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// The operation name a failure carries into the banner (06 ▸ Interaction with external writers:
|
||||
/// "surfaces as a one-shot banner failure naming the operation and the error").
|
||||
static let operationName = "Restoring an earlier state"
|
||||
|
||||
/// libgit2's global state — `GitCommitOperation.startUp`'s twin, and for its reason.
|
||||
private static let startUp: Bool = {
|
||||
git_libgit2_init() >= 0
|
||||
}()
|
||||
|
||||
// MARK: - Planning
|
||||
|
||||
/// **The writes that would turn the working tree into `target`'s state**, or `nil` when the
|
||||
/// repository could not be read.
|
||||
///
|
||||
/// `nil` is emphatically not "nothing to do": a restore that silently did nothing because a tree
|
||||
/// would not load is the one failure mode a forward-only undo could not explain afterwards.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - target: the oid of the commit whose state is being restored.
|
||||
/// - excluding: board-root-relative paths whose divergence is heal work — never materialized.
|
||||
/// - reconciling: board-root-relative folders compared against the working tree rather than
|
||||
/// against HEAD (the Discard branch of the save-or-discard step).
|
||||
nonisolated static func plan(
|
||||
at boardRoot: URL,
|
||||
target: String,
|
||||
excluding: Set<String> = [],
|
||||
reconciling: Set<String> = []
|
||||
) -> GitRestorePlan? {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return nil }
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
guard let targetTree = tree(of: target, in: repository) else { return nil }
|
||||
defer { git_tree_free(targetTree) }
|
||||
var wanted: [String: git_oid] = [:]
|
||||
fileMap(of: targetTree, in: repository, prefix: "", depth: 0, into: &wanted)
|
||||
|
||||
var current: [String: git_oid] = [:]
|
||||
if let headTree = headTree(of: repository) {
|
||||
defer { git_tree_free(headTree) }
|
||||
fileMap(of: headTree, in: repository, prefix: "", depth: 0, into: ¤t)
|
||||
}
|
||||
|
||||
// The reconciled folders answer from disk instead: their committed state is beside the point,
|
||||
// because what is being discarded is exactly what is *not* committed.
|
||||
if !reconciling.isEmpty {
|
||||
for folder in reconciling {
|
||||
current = current.filter { !isInside($0.key, folder: folder) }
|
||||
}
|
||||
for path in workingTreeFiles(under: reconciling, at: boardRoot) {
|
||||
// A sentinel oid nothing can equal: the comparison below only ever asks "same or
|
||||
// different", and a working-tree file's bytes are not addressed by the object store.
|
||||
current[path] = git_oid()
|
||||
}
|
||||
}
|
||||
|
||||
var changes: [GitRestorePlan.Change] = []
|
||||
for (path, oid) in wanted.sorted(by: { $0.key < $1.key }) {
|
||||
guard !excluding.contains(path) else { continue }
|
||||
if let held = current[path], equal(held, oid), !isInside(path, folders: reconciling) { continue }
|
||||
guard let data = blob(oid, in: repository) else { continue }
|
||||
changes.append(GitRestorePlan.Change(path: path, contents: data))
|
||||
}
|
||||
for path in current.keys.sorted() where wanted[path] == nil {
|
||||
guard !excluding.contains(path) else { continue }
|
||||
changes.append(GitRestorePlan.Change(path: path, contents: nil))
|
||||
}
|
||||
return GitRestorePlan(changes: changes.sorted { $0.path < $1.path })
|
||||
}
|
||||
|
||||
// MARK: - Applying
|
||||
|
||||
/// **Writes the plan and commits it** — one new commit on the current branch, nothing rewound.
|
||||
///
|
||||
/// The commit goes through `GitCommitOperation.perform` unchanged, so it takes the ordinary
|
||||
/// signature path (06 ▸ Interaction with external writers) and is authored by the user: a restore
|
||||
/// is the user acting through the app, whatever the origin of the commit it crosses.
|
||||
///
|
||||
/// A plan that turns out to write nothing new commits nothing — `perform`'s own empty-tree skip —
|
||||
/// and answers `.nothingToCommit`, which the caller reads as "the step was crossed and needed no
|
||||
/// bytes", not as a failure.
|
||||
nonisolated static func apply(
|
||||
_ plan: GitRestorePlan,
|
||||
at boardRoot: URL,
|
||||
message: String
|
||||
) -> GitCommitOutcome {
|
||||
_ = startUp
|
||||
guard !plan.isEmpty else { return .nothingToCommit }
|
||||
|
||||
let manager = FileManager.default
|
||||
for change in plan.changes {
|
||||
let url = boardRoot.appendingPathComponent(change.path)
|
||||
guard let contents = change.contents else {
|
||||
try? manager.removeItem(at: url)
|
||||
pruneEmptyFolders(above: url, upTo: boardRoot)
|
||||
continue
|
||||
}
|
||||
let folder = url.deletingLastPathComponent()
|
||||
do {
|
||||
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try contents.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
logger.error("restore could not write \(change.path, privacy: .public)")
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: (error as NSError).localizedDescription
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let identity = GitCommitOperation.userIdentity(at: boardRoot)
|
||||
return GitCommitOperation.perform(
|
||||
at: boardRoot,
|
||||
commits: [PlannedCommit(
|
||||
paths: plan.paths,
|
||||
message: message,
|
||||
author: identity,
|
||||
committer: identity,
|
||||
kind: .user
|
||||
)],
|
||||
allowRootCommit: false
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private plumbing
|
||||
|
||||
private static func open(_ boardRoot: URL) -> OpaquePointer? {
|
||||
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
|
||||
var repository: OpaquePointer?
|
||||
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
|
||||
return repository
|
||||
}
|
||||
|
||||
private static func tree(of oid: String, in repository: OpaquePointer) -> OpaquePointer? {
|
||||
var id = git_oid()
|
||||
guard git_oid_fromstr(&id, oid) == 0 else { return nil }
|
||||
var commit: OpaquePointer?
|
||||
guard git_commit_lookup(&commit, repository, &id) == 0, let commit else { return nil }
|
||||
defer { git_commit_free(commit) }
|
||||
var tree: OpaquePointer?
|
||||
guard git_commit_tree(&tree, commit) == 0 else { return nil }
|
||||
return tree
|
||||
}
|
||||
|
||||
private static func headTree(of repository: OpaquePointer) -> OpaquePointer? {
|
||||
guard git_repository_head_unborn(repository) != 1 else { return nil }
|
||||
var reference: OpaquePointer?
|
||||
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
|
||||
defer { git_reference_free(reference) }
|
||||
var object: OpaquePointer?
|
||||
guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0 else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
/// Every blob under a tree, board-root-relative, with its object id.
|
||||
///
|
||||
/// The depth cap is `GitHeadSnapshot.materialize`'s, for its reason: a guard against a
|
||||
/// pathological repository, not a statement about boards.
|
||||
private static func fileMap(
|
||||
of tree: OpaquePointer,
|
||||
in repository: OpaquePointer,
|
||||
prefix: String,
|
||||
depth: Int,
|
||||
into map: inout [String: git_oid]
|
||||
) {
|
||||
guard depth < 8 else { return }
|
||||
for position in 0..<git_tree_entrycount(tree) {
|
||||
guard let entry = git_tree_entry_byindex(tree, position),
|
||||
let rawName = git_tree_entry_name(entry),
|
||||
let id = git_tree_entry_id(entry) else { continue }
|
||||
let name = String(cString: rawName)
|
||||
// A `/` in a tree entry name is impossible in a well-formed tree and would be a path
|
||||
// escape if it were not: refuse rather than interpret (`GitHeadSnapshot`'s rule).
|
||||
guard !name.isEmpty, name != ".", name != "..", !name.contains("/") else { continue }
|
||||
let path = prefix.isEmpty ? name : prefix + "/" + name
|
||||
|
||||
switch git_tree_entry_type(entry) {
|
||||
case GIT_OBJECT_TREE:
|
||||
var child: OpaquePointer?
|
||||
guard git_tree_lookup(&child, repository, id) == 0, let child else { continue }
|
||||
defer { git_tree_free(child) }
|
||||
fileMap(of: child, in: repository, prefix: path, depth: depth + 1, into: &map)
|
||||
case GIT_OBJECT_BLOB:
|
||||
map[path] = id.pointee
|
||||
default:
|
||||
// Submodules and symlinks: neither is a board, and neither is followed anywhere else
|
||||
// in this app either.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func blob(_ oid: git_oid, in repository: OpaquePointer) -> Data? {
|
||||
var id = oid
|
||||
var blob: OpaquePointer?
|
||||
guard git_blob_lookup(&blob, repository, &id) == 0, let blob else { return nil }
|
||||
defer { git_blob_free(blob) }
|
||||
let size = Int(git_blob_rawsize(blob))
|
||||
guard size > 0, let bytes = git_blob_rawcontent(blob) else { return Data() }
|
||||
return Data(bytes: bytes, count: size)
|
||||
}
|
||||
|
||||
/// Every file on disk under one of `folders`, board-root-relative. `.git` is never walked — it is
|
||||
/// not part of any board's tree and nothing here may write into it.
|
||||
private static func workingTreeFiles(under folders: Set<String>, at boardRoot: URL) -> [String] {
|
||||
var found: [String] = []
|
||||
for folder in folders {
|
||||
let root = boardRoot.appendingPathComponent(folder)
|
||||
guard let walker = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isRegularFileKey],
|
||||
options: [.skipsHiddenFiles, .skipsPackageDescendants]
|
||||
) else { continue }
|
||||
for case let url as URL in walker {
|
||||
guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
|
||||
else { continue }
|
||||
guard let relative = relativePath(of: url, under: boardRoot) else { continue }
|
||||
found.append(relative)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private static func relativePath(of url: URL, under boardRoot: URL) -> String? {
|
||||
let root = boardRoot.standardizedFileURL.path
|
||||
let path = url.standardizedFileURL.path
|
||||
guard path.hasPrefix(root + "/") else { return nil }
|
||||
return String(path.dropFirst(root.count + 1))
|
||||
}
|
||||
|
||||
private static func isInside(_ path: String, folder: String) -> Bool {
|
||||
path == folder || path.hasPrefix(folder + "/")
|
||||
}
|
||||
|
||||
private static func isInside(_ path: String, folders: Set<String>) -> Bool {
|
||||
folders.contains { isInside(path, folder: $0) }
|
||||
}
|
||||
|
||||
/// Removes folders emptied by a deletion, up to (never including) the board root — the same
|
||||
/// tidiness a card's own delete leaves behind, so a restore does not litter a board with empty
|
||||
/// UUID folders that the loader would then have to ignore.
|
||||
private static func pruneEmptyFolders(above file: URL, upTo boardRoot: URL) {
|
||||
let manager = FileManager.default
|
||||
let root = boardRoot.standardizedFileURL.path
|
||||
var folder = file.deletingLastPathComponent().standardizedFileURL
|
||||
while folder.path != root, folder.path.hasPrefix(root + "/") {
|
||||
let contents = (try? manager.contentsOfDirectory(atPath: folder.path)) ?? []
|
||||
guard contents.isEmpty || contents == [".DS_Store"] else { return }
|
||||
try? manager.removeItem(at: folder)
|
||||
folder = folder.deletingLastPathComponent().standardizedFileURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
|
||||
var left = lhs
|
||||
var right = rhs
|
||||
return git_oid_cmp(&left, &right) == 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user