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 = [], reconciling: Set = [] ) -> 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.. 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 } }