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
214 lines
9.7 KiB
Swift
214 lines
9.7 KiB
Swift
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
|
||
}
|
||
}
|