Files
lanework/Kanban/Git/GitHistoryWalk.swift
T
rzen 142c6e75fe 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
2026-07-31 15:54:22 -04:00

214 lines
9.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}