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 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. They arrive as folder **names**, not paths; see `folderPaths(named:at:)` /// for why that distinction is the difference between the rule working and silently not. /// /// ### 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: card **folder names** — the ids `SettleableSession.cardFolderName` carries — /// whose folders are compared against the working tree rather than against HEAD (the Discard /// branch of the save-or-discard step). Resolved to real paths here, once, by the resolver /// both callers share. nonisolated static func plan( at boardRoot: URL, target: String, excluding: Set = [], reconciling folderNames: Set = [] ) -> GitRestorePlan? { _ = startUp guard let repository = open(boardRoot) else { return nil } defer { git_repository_free(repository) } // **Names in, paths out — the one resolution both Discard paths take** (the undo restore's, // and the branch switch's `revertToHead`). A card's folder name is its id; its *path* is // `/`, and every live card has a lane above it, so treating the name as a path // matched nothing at all and made the whole Discard branch silently inert. let reconciling = folderPaths(named: folderNames, at: boardRoot) 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 } if let failure = materialize(plan, at: boardRoot) { return .failed(failure) } 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 ) } /// **The writes, without the commit** — the plan materialized onto disk. `nil` means every change /// landed. /// /// Split out of `apply` for the branch switch's Discard branch (`revertToHead(folders:at:)`), /// which needs the bytes moved and emphatically does *not* want a commit attempted over them. nonisolated static func materialize(_ plan: GitRestorePlan, at boardRoot: URL) -> GitOperationFailure? { 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 GitOperationFailure( operation: operationName, message: (error as NSError).localizedDescription ) } } return nil } /// **"Discard reverts buffers and uncommitted saves to HEAD"** (06-history-undo.md ▸ Branch /// switching) — the *uncommitted saves* half, for the operation that has no restore plan to fold /// it into. /// /// An undo restore reconciles a discarded card's folder inside its own plan, because it is /// materializing a target state anyway and one pass that does both cannot disagree with itself. A /// branch switch materializes nothing — libgit2's checkout does the moving — so the discard has to /// be its own step, and it has to run **before** the pending auto-commit is flushed: `discard` /// ends the Edit session, which un-stages-around the card's folder, so a flush over a folder still /// holding those saves would commit exactly the text the user just asked to lose. /// /// It is expressed as a restore *to HEAD* with the folders reconciled against the working tree, /// which is the same machinery under a different target: every path outside those folders compares /// HEAD against HEAD and produces nothing, and inside them the working tree's own files are what /// the plan replaces. Nothing is committed — by construction there is nothing new to commit, since /// the tree afterwards is HEAD's. /// /// Answers whether the revert ran cleanly; `false` is a repository that could not be read, which /// the caller reports as its operation's clean failure. /// /// - Parameter folderNames: card **folder names** — the ids `SettleableSession.cardFolderName` /// carries, not paths. Resolved against the tree here for that property's own reason: "a card's /// own folder component never changes, only the lane above it", so a session that began before a /// lane move is still matched afterwards. nonisolated static func revertToHead(folderNames: Set, at boardRoot: URL) -> Bool { _ = startUp guard !folderNames.isEmpty else { return true } guard let head = GitHistoryWalk.headOID(at: boardRoot) else { return false } // A card whose folder is not on disk resolves to nothing, plans nothing, and writes nothing: // it was deleted, or it never existed, and either way there are no uncommitted saves to // revert. guard let plan = plan(at: boardRoot, target: head, reconciling: folderNames) else { return false } return materialize(plan, at: boardRoot) == nil } /// **Board-root-relative paths of every folder whose last component is one of `names`** — the one /// place a card id becomes a place on disk. /// /// Component-exact, which is the same match `SessionSettleGate` uses to decide *which* sessions an /// operation reaches (`GitHistoryWalk.path(_:isInsideFolderNamed:)`) and it is chosen for that /// rule's own reason: "a card's own folder component never changes, only the lane above it", so a /// session that began before a lane move is still found afterwards. Matching a name as a path /// prefix instead is what made the Discard branch inert — a bug this resolver exists to make /// unrepeatable, since both callers now go through it. /// /// `.git` is never walked — it is not part of any board's tree, and nothing here may write into /// it. private static func folderPaths(named names: Set, at boardRoot: URL) -> Set { guard let walker = FileManager.default.enumerator( at: boardRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants] ) else { return [] } var found: Set = [] for case let url as URL in walker { let name = url.lastPathComponent if name == ".git" { walker.skipDescendants() continue } guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true, names.contains(name), let relative = relativePath(of: url, under: boardRoot) else { continue } found.insert(relative) } return found } // 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.. 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, 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) -> 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 } }