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