GitBranchSwitcher holds 06's sequence as one object: settle editors
explicitly (SessionSettleGate — Save All applies raw buffers with
validation and a refused buffer cancels the whole switch; Discard
reverts buffers AND reconciles the session folders against HEAD;
never silent), flush the pending auto-commit, stamp intent in the
per-board registry, bracketed safe checkout (git_checkout_tree
GIT_CHECKOUT_SAFE + set_head — no path passes FORCE, abort
included), one reload via the async wholesale bracket (failed final
reload engages the existing read-only lock), reseed undo/redo from
the new HEAD with redo empty, clear the stamp. Create-and-switch
keeps the full sequence — the tree-cannot-change proof fails under
concurrent writers. Lock contention shows the 02 in-progress row's
waiting state ("waiting for another writer's git lock"), bounded at
30s then failing cleanly naming the lock path.
GitOperationStamp + GitOperationRecovery: the own-leftovers rule as
a pure conjunction — pause state AND matching stamp = the app's own
interrupted operation, aborted to the pre-operation state with a
banner, stamp cleared on success only; either alone defers to the
pause-and-defer stance. Checked where the committer starts.
BoardGitControls replaces the read-only branch line: branch picker,
inline create-and-switch, the abnormal-state pause note in 06's own
words with controls dimmed, and commit-identity fields that read and
write repo-local .git/config (derived default as placeholder, never
value; unfocused resync, focused keystrokes kept; 2s poll while
visible — .git is watcher-filtered by design).
Also fixes a shipped bug from the undo card: plan(reconciling:)
matched card ids as path prefixes, so the reconcile branch was inert
on every board (<lane>/<card> never matches a bare id) — a session
file the restore diff couldn't name (attachment, comment, draft)
survived Discard and landed in the next flush's commit. One shared
component-exact folder-name resolver now serves both Discard paths;
noteDiscarded takes cardFolderName; regression test verified failing
against the pre-fix code.
41 branch tests + the regression; 2374 tests / 409 suites green;
InertGitTests untouched.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
478 lines
23 KiB
Swift
478 lines
23 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
// MARK: - GitHistoryProvider
|
|
|
|
/// **Pro's undo substrate: the commit trail itself** (06-history-undo.md; 12-editions.md ▸ The
|
|
/// provider seam) — the second implementation of `HistoryProviding`, and the one the seam was
|
|
/// designed around.
|
|
///
|
|
/// ### The stack is not a stack
|
|
///
|
|
/// "The stack **is** HEAD's first-parent ancestry, live" (06 ▸ Rules). Nothing here records a step
|
|
/// when the board is written to; `register(_:)` is a deliberate no-op, because on a git board an undo
|
|
/// step is a *commit* and commits are made by the auto-committer, by an agent, or by a terminal. What
|
|
/// this object holds is a **pointer into that ancestry** — which commit ⌘Z would cross next — plus a
|
|
/// redo list of the commits already crossed in this session. Both are caches over a repository that
|
|
/// remains the only truth, which is what makes "no sidecar state, nothing ever lost" (14 ▸ C8) a
|
|
/// property of the shape rather than a discipline.
|
|
///
|
|
/// ### Four rules, and where each one lives
|
|
///
|
|
/// - **Forward only.** A crossing writes an older state as a *new commit* — `GitRestoreOperation`,
|
|
/// which cannot reset because it never resolves a reset symbol. Old commits stay reachable; refs
|
|
/// only move forward.
|
|
/// - **Exactly one commit per ⌘Z.** The pre-flight sync (`syncToHEAD`) re-reads HEAD before every
|
|
/// crossing, so agents' self-commits landed since the last operation become the new top and ⌘Z
|
|
/// steps back over *them* rather than silently reverting twenty minutes of their work.
|
|
/// - **Any arrival clears redo.** From the pre-flight sync for commits made outside the app, and from
|
|
/// `noteLanded(_:)` for the ones this app's committer made — with the one exception the heal rule
|
|
/// requires (below).
|
|
/// - **In-session and post-relaunch are one rule.** `reseed()` is the same ancestry walk from
|
|
/// scratch, so a relaunch, a branch switch and a foreign arrival all take the same path.
|
|
///
|
|
/// ### Heal transparency, and its honest limit
|
|
///
|
|
/// Heal-class commits never become steps: the pointer passes over them, and a restore excludes the
|
|
/// paths whose divergence is heal work — so a ⌘Z run never reverts a repair and never re-arms the
|
|
/// healer (06 ▸ Rules ▸ Heal commits are transparent to undo, in-session). Both halves are learned
|
|
/// from `GitAutoCommitter.reportLanded`, which fires while the Writer's heal-marked receipts still
|
|
/// exist. **In-session is the whole of it, deliberately**: the reseed is sidecar-free, so after a
|
|
/// relaunch old heal commits reappear as ordinary steps — the accepted one-bounce residual, named in
|
|
/// 06 and not worked around here.
|
|
///
|
|
/// ### Asynchrony
|
|
///
|
|
/// `HistoryProviding.undo()` is synchronous because a menu item is; a restore is a settle step, a
|
|
/// libgit2 diff, a set of writes and a commit. So the protocol methods start a `Task` and return, and
|
|
/// `cross(_:)` is the awaitable one a test drives. Enablement never waits on any of it: `canUndo`,
|
|
/// `canRedo` and both action names answer from the cached ancestry, so menu validation costs nothing.
|
|
@MainActor
|
|
@Observable
|
|
public final class GitHistoryProvider: HistoryProviding {
|
|
|
|
// MARK: - Identity
|
|
|
|
/// The board this is the history of — in git mode, the repository's working-tree root.
|
|
public let boardRoot: URL
|
|
|
|
// MARK: - Seams
|
|
|
|
/// **The pending auto-commit, flushed before a restore commits** (06 ▸ Rules ▸ Flush-before-
|
|
/// overwrite, applied here by the card's own rule: settled tree first, then one more commit).
|
|
///
|
|
/// Without it a ⌘Z would commit an older state on top of edits that never got a commit of their
|
|
/// own — the forward trail would be missing the very version the undo is stepping back from.
|
|
@ObservationIgnored
|
|
public var flushPendingCommit: (@MainActor () async -> Void)?
|
|
|
|
/// Whether the git surface is **held** — a detached HEAD or an in-progress merge/rebase
|
|
/// (06 ▸ Rules ▸ Abnormal repo states: "Undo/Redo and the branch controls disable"). Reads
|
|
/// `GitAutoCommitter.pause`, which is in-memory state, so enablement stays free.
|
|
@ObservationIgnored
|
|
public var isHeld: (@MainActor () -> Bool)?
|
|
|
|
/// Stops and restarts the auto-commit debounce around a restore, so its own writes cannot be
|
|
/// half-committed by a timer that fires mid-materialization.
|
|
@ObservationIgnored
|
|
public var suspendCommitting: (@MainActor () -> Void)?
|
|
|
|
@ObservationIgnored
|
|
public var resumeCommitting: (@MainActor () -> Void)?
|
|
|
|
/// **The save-or-discard step** (06 ▸ Rules ▸ Undo restore vs open Edit sessions). `nil` is a
|
|
/// board with no card windows to settle — every storeless test, and a session composed before any
|
|
/// window opened.
|
|
@ObservationIgnored
|
|
public var settleSessions: (@MainActor (Set<String>) async -> SessionSettleOutcome)?
|
|
|
|
/// Runs the restore inside the store's wholesale bracket — watcher suspended, one full reload at
|
|
/// the end, the board locked if that reload fails (02-architecture.md; `BoardStore.performWholesale`).
|
|
/// `nil` runs the work bare, which is what a repository-level test wants.
|
|
@ObservationIgnored
|
|
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
|
|
|
|
/// A genuine restore failure — surfaced as 02's one-shot banner by whoever wires it.
|
|
@ObservationIgnored
|
|
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
|
|
|
// MARK: - The cached stack
|
|
|
|
/// HEAD's first-parent ancestry as of the last sync, newest first. The *stack*, cached.
|
|
public private(set) var ancestry: [GitCommitRecord] = []
|
|
|
|
/// The oid of the commit ⌘Z would cross next, or `nil` before the first seed. Not always
|
|
/// `ancestry.first`: after an undo the pointer sits below the restore commit the undo just made,
|
|
/// which is the whole mechanism behind "the undo-menu labels are the *crossed* commit's subject,
|
|
/// so labels never nest" (06 ▸ Commit messages).
|
|
public private(set) var pointerOID: String?
|
|
|
|
/// Commits crossed by ⌘Z in this session, oldest crossed first — ⇧⌘Z restores the state *at* the
|
|
/// last of them. Empty on every seed: "redo starts empty" (06 ▸ Rules ▸ Undo survives relaunch).
|
|
public private(set) var redoCommits: [GitCommitRecord] = []
|
|
|
|
/// The HEAD this cache was built against — the pre-flight sync's comparison.
|
|
private var knownHead: String?
|
|
|
|
/// Heal-class commits landed **in this session**, which the pointer passes over.
|
|
private var healOIDs: Set<String> = []
|
|
|
|
/// Paths committed as heal work in this session, which a restore never materializes.
|
|
private var healPaths: Set<String> = []
|
|
|
|
/// Whether a crossing is in flight — a second ⌘Z during a restore must not start a second one.
|
|
public private(set) var isCrossing = false
|
|
|
|
/// How many restores this provider has landed — the trail's own testimony, so a test need not
|
|
/// infer a crossing from a commit walk.
|
|
public private(set) var restoreCount = 0
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
|
|
|
public init(boardRoot: URL) {
|
|
self.boardRoot = boardRoot
|
|
}
|
|
|
|
// MARK: - Seeding
|
|
|
|
/// **Reseeds the stack from HEAD's first-parent ancestry, with an empty redo** — the API a board
|
|
/// open, a relaunch, and a **branch switch** all enter through (06 ▸ Rules ▸ Undo survives
|
|
/// relaunch; ▸ Branch switching: "The undo/redo stack does not survive a switch. It is discarded
|
|
/// and reseeded from the new HEAD's first-parent ancestry … redo starts empty").
|
|
///
|
|
/// Synchronous and off the main actor is not an option — the walk is libgit2 — so this is the
|
|
/// awaitable seed and `seed()` is the fire-and-forget one an open path can call.
|
|
public func reseed() async {
|
|
let root = boardRoot
|
|
let records = await Task.detached(priority: .userInitiated) {
|
|
GitHistoryWalk.ancestry(at: root)
|
|
}.value
|
|
adopt(records)
|
|
}
|
|
|
|
/// `reseed()` without waiting — what a board open and an add-git flip call.
|
|
public func seed() {
|
|
Task { await reseed() }
|
|
}
|
|
|
|
/// The reseed's main-actor half, split out so the sync path can reuse it.
|
|
private func adopt(_ records: [GitCommitRecord]) {
|
|
ancestry = records
|
|
knownHead = records.first?.oid
|
|
pointerOID = records.first?.oid
|
|
redoCommits = []
|
|
}
|
|
|
|
/// **The pre-flight sync** (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live):
|
|
/// "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), so ⌘Z always steps back exactly
|
|
/// **one** commit."
|
|
///
|
|
/// One reference read when nothing moved, a full reseed when something did. A reseed here is the
|
|
/// same reseed a relaunch does, which is the point: "In-session and post-relaunch behavior are
|
|
/// thereby one rule."
|
|
private func syncToHEAD() async {
|
|
let root = boardRoot
|
|
let head = await Task.detached(priority: .userInitiated) {
|
|
GitHistoryWalk.headOID(at: root)
|
|
}.value
|
|
guard head != knownHead else { return }
|
|
Self.logger.debug("undo stack re-syncing: HEAD moved outside the stack's knowledge")
|
|
await reseed()
|
|
}
|
|
|
|
// MARK: - What the committer tells it
|
|
|
|
/// **A flush landed** — `GitAutoCommitter.reportLanded`.
|
|
///
|
|
/// Two behaviours, and the split between them is the heal rule:
|
|
///
|
|
/// - A window of **nothing but heal commits** leaves the pointer and the redo list exactly where
|
|
/// they were, and only records what was healed. That is what keeps an undo run from being
|
|
/// trapped on an ever-renewing top: "the fresh heal commit is in-session, transparent, and the
|
|
/// undo run continues past it" (06).
|
|
/// - **Anything else is an arrival**, and "any commit arriving from anywhere clears the redo
|
|
/// stack (classic behavior)" — with the new commit becoming the top of the stack, so the next
|
|
/// ⌘Z crosses what just happened.
|
|
public func noteLanded(_ window: GitLandedWindow) {
|
|
healOIDs.formUnion(window.healOIDs)
|
|
healPaths.formUnion(window.healPaths)
|
|
guard !window.commits.isEmpty else { return }
|
|
|
|
// The walk is libgit2 work and the committer reports from a synchronous outcome handler, so
|
|
// the cache catches up on its own turn. `settled()` is how anything that must not race it
|
|
// waits — `cross(_:)` first of all.
|
|
refresh = Task { [weak self] in
|
|
guard let self else { return }
|
|
if window.isEntirelyHeal {
|
|
// The ancestry gained a commit the pointer must be able to walk past; the pointer and
|
|
// the redo list are untouched.
|
|
await self.refreshAncestryKeepingPointer()
|
|
} else {
|
|
await self.reseed()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The cache catch-up started by the last `noteLanded(_:)`, if it is still running.
|
|
@ObservationIgnored
|
|
private var refresh: Task<Void, Never>?
|
|
|
|
/// **Waits for the cache to have heard about the last commit** — so "⌘Z now crosses what just
|
|
/// landed" is a fact to await rather than a race.
|
|
///
|
|
/// Every crossing awaits it, which is the production caller; a test awaits it to assert on
|
|
/// enablement the instant a flush returns, where a menu would simply be validated a turn later.
|
|
public func settled() async {
|
|
await refresh?.value
|
|
refresh = nil
|
|
}
|
|
|
|
/// Re-reads the ancestry without disturbing the pointer or the redo list — the heal window's
|
|
/// path, and the one every successful restore takes.
|
|
private func refreshAncestryKeepingPointer() async {
|
|
let root = boardRoot
|
|
let records = await Task.detached(priority: .userInitiated) {
|
|
GitHistoryWalk.ancestry(at: root)
|
|
}.value
|
|
ancestry = records
|
|
knownHead = records.first?.oid
|
|
if let pointerOID, !records.contains(where: { $0.oid == pointerOID }) {
|
|
// The pointer's commit is no longer in HEAD's first-parent ancestry — a rebase remapped
|
|
// it (07-sync-collab.md's pull). The honest answer is the seed's: start again from the
|
|
// top, redo empty.
|
|
adopt(records)
|
|
}
|
|
}
|
|
|
|
// MARK: - HistoryProviding
|
|
|
|
/// **Deliberately nothing.** On a git board an undo step is a commit, and the Writer boundary's
|
|
/// inverse operations are the *free* tier's substrate (13-native-undo.md). `BoardStore` registers
|
|
/// against whatever provider the session bound, and this one has a repository to read instead —
|
|
/// so the registrations arrive and are dropped, which is exactly what "the commit trail itself is
|
|
/// the substrate" (14 ▸ C1) means in code.
|
|
public func register(_ step: HistoryStep) {}
|
|
|
|
public var canUndo: Bool {
|
|
guard !isCrossing, isHeld?() != true else { return false }
|
|
return crossableIndex() != nil
|
|
}
|
|
|
|
public var canRedo: Bool {
|
|
guard !isCrossing, isHeld?() != true else { return false }
|
|
return !redoCommits.isEmpty
|
|
}
|
|
|
|
/// **The crossed commit's own subject** (06 ▸ Commit messages: "the undo-menu labels are the
|
|
/// *crossed* commit's subject, so labels never nest") — so the Edit menu reads "Undo Move card
|
|
/// 'Fix login' to Doing", never "Undo Undo: …" for a restore this session made.
|
|
public var undoActionName: String? {
|
|
guard canUndo, let index = crossableIndex() else { return nil }
|
|
return ancestry[index].subject
|
|
}
|
|
|
|
public var redoActionName: String? {
|
|
guard canRedo else { return nil }
|
|
return redoCommits.last?.subject
|
|
}
|
|
|
|
public func undo() {
|
|
Task { await cross(.undo) }
|
|
}
|
|
|
|
public func redo() {
|
|
Task { await cross(.redo) }
|
|
}
|
|
|
|
/// Drops the cache. The session's teardown, and nothing else — the *repository* is untouched, so
|
|
/// a board reopened a second later has exactly the same trail.
|
|
public func clear() {
|
|
ancestry = []
|
|
pointerOID = nil
|
|
redoCommits = []
|
|
knownHead = nil
|
|
healOIDs = []
|
|
healPaths = []
|
|
}
|
|
|
|
// MARK: - The crossing
|
|
|
|
/// One ⌘Z or ⇧⌘Z, awaitable — the whole restore, in the order the rules fix it.
|
|
public func cross(_ direction: HistoryDirection) async {
|
|
guard !isCrossing, isHeld?() != true else { return }
|
|
isCrossing = true
|
|
defer { isCrossing = false }
|
|
|
|
// **The settled tree first** (06 ▸ Rules ▸ Flush-before-overwrite, and this card's own rule):
|
|
// whatever the debounce is still holding becomes a commit of its own before a restore lands
|
|
// on top of it, so both states exist in the trail.
|
|
await flushPendingCommit?()
|
|
await settled()
|
|
await syncToHEAD()
|
|
|
|
switch direction {
|
|
case .undo:
|
|
guard let index = crossableIndex() else { return }
|
|
let crossed = ancestry[index]
|
|
guard let target = crossed.parentOID else { return }
|
|
let landed = await restore(to: target, message: "Undo: \(crossed.subject)")
|
|
guard landed else { return }
|
|
redoCommits.append(crossed)
|
|
pointerOID = target
|
|
await refreshAncestryKeepingPointer()
|
|
|
|
case .redo:
|
|
guard let target = redoCommits.last else { return }
|
|
let landed = await restore(to: target.oid, message: "Redo: \(target.subject)")
|
|
guard landed else { return }
|
|
redoCommits.removeLast()
|
|
// The commit just restored *to* is the one the next ⌘Z crosses again — the classic dance,
|
|
// with the pointer where the undo found it.
|
|
pointerOID = target.oid
|
|
await refreshAncestryKeepingPointer()
|
|
}
|
|
}
|
|
|
|
/// Materializes one target state as a new commit. Answers whether the crossing may advance.
|
|
///
|
|
/// `message` is both the commit's subject and the bracket's completion announcement
|
|
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
|
|
/// completion") — one sentence, so the trail and the speech cannot disagree about what happened.
|
|
private func restore(to target: String, message: String) async -> Bool {
|
|
let root = boardRoot
|
|
let excluded = healPaths
|
|
|
|
// The **preliminary** plan: what the restore would write, which is the only thing that can
|
|
// say whether any open session is in its way.
|
|
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
|
|
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
|
|
}).value else {
|
|
report("this board's repository could not be read")
|
|
return false
|
|
}
|
|
|
|
var reconciling: Set<String> = []
|
|
if !preliminary.isEmpty, let settleSessions {
|
|
switch await settleSessions(Set(preliminary.paths)) {
|
|
case .cancelled, .failed:
|
|
// "Cancel keeps everything" — and a raw buffer that would not validate cancels the
|
|
// whole restore, focused on the offender (06 ▸ Branch switching).
|
|
return false
|
|
case .proceed:
|
|
reconciling = discardedFolders
|
|
discardedFolders = []
|
|
}
|
|
if reconciling.isEmpty {
|
|
// **Save All ended sessions, which commits them**: the tree moved, so the plan is
|
|
// recomputed below against the HEAD that now exists rather than the one it was
|
|
// drafted against.
|
|
//
|
|
// **Discard deliberately does not flush.** Ending a session un-stages-around its
|
|
// folder, so a flush here would commit exactly the uncommitted saves the user just
|
|
// asked to lose — a Discard that wrote them into history forever. Nothing is left
|
|
// behind by skipping it: everything else pending was already flushed at the top of
|
|
// the crossing, and the discarded folder is reconciled against the working tree by
|
|
// the plan itself.
|
|
await flushPendingCommit?()
|
|
await settled()
|
|
}
|
|
}
|
|
|
|
// Bound before the closure that crosses actors reads it — the settle step is over, and what
|
|
// it decided is a value from here on.
|
|
let folders = reconciling
|
|
var landed = false
|
|
let work: @MainActor () async -> Void = { [weak self] in
|
|
guard let self else { return }
|
|
self.suspendCommitting?()
|
|
defer { self.resumeCommitting?() }
|
|
let outcome = await Task.detached(priority: .userInitiated, operation: {
|
|
guard let plan = GitRestoreOperation.plan(
|
|
at: root,
|
|
target: target,
|
|
excluding: excluded,
|
|
reconciling: folders
|
|
) else {
|
|
return GitCommitOutcome.failed(GitOperationFailure(
|
|
operation: GitRestoreOperation.operationName,
|
|
message: "this board's repository could not be read"
|
|
))
|
|
}
|
|
return GitRestoreOperation.apply(plan, at: root, message: message)
|
|
}).value
|
|
|
|
switch outcome {
|
|
case .committed:
|
|
self.restoreCount += 1
|
|
landed = true
|
|
case .nothingToCommit:
|
|
// The step was crossed and needed no bytes — every path its diff would have written
|
|
// was heal work, or the two states are byte-identical. The pointer still advances:
|
|
// a step that changed nothing is still a step the user asked to walk past.
|
|
landed = true
|
|
case .locked:
|
|
// "Contention outlasting the brief retry surfaces as a *waiting* state" (06); the
|
|
// in-progress banner row is the branch card's surface. Here the honest answer is to
|
|
// leave the stack where it is so ⌘Z can simply be pressed again.
|
|
Self.logger.debug("restore found index.lock held — the stack is unchanged")
|
|
case let .held(pause):
|
|
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
|
|
case let .failed(failure):
|
|
self.reportFailure?(failure)
|
|
}
|
|
}
|
|
|
|
if let runBracketed {
|
|
await runBracketed(message, work)
|
|
} else {
|
|
await work()
|
|
}
|
|
return landed
|
|
}
|
|
|
|
/// Folders the settle step's Discard branch left for the plan to reconcile against the working
|
|
/// tree. Filled by the gate's wiring through `noteDiscarded(_:)`.
|
|
private var discardedFolders: Set<String> = []
|
|
|
|
/// **A settle step discarded this card's session** — its folder is compared against the working
|
|
/// tree rather than against HEAD, so the uncommitted saves the user just chose to lose are
|
|
/// reverted by the restore itself rather than by a second pass that could disagree with it
|
|
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
|
|
public func noteDiscarded(cardFolderName: String) {
|
|
discardedFolders.insert(cardFolderName)
|
|
}
|
|
|
|
// MARK: - The pointer
|
|
|
|
/// The index in `ancestry` of the commit ⌘Z would cross, or `nil` when there is none.
|
|
///
|
|
/// Two commits are never steps:
|
|
///
|
|
/// - **Heal commits**, which the pointer passes over (06 ▸ Rules ▸ Heal commits are transparent).
|
|
/// - **The root commit** — a judgment call, recorded. It has no parent, so "the state before it"
|
|
/// is the empty tree: crossing it would delete every file the board has ever had, in one
|
|
/// keystroke, on a board whose entire history is that one commit. 06 says an unborn repository's
|
|
/// "undo trail simply starts empty"; a repository with exactly one commit is that repository one
|
|
/// commit later, and the honest reading is that the board's existence is not a step. (Nothing is
|
|
/// lost either way: the commit stays reachable in any git client.)
|
|
private func crossableIndex() -> Int? {
|
|
guard !ancestry.isEmpty else { return nil }
|
|
let start = pointerOID.flatMap { oid in ancestry.firstIndex { $0.oid == oid } } ?? 0
|
|
for index in start..<ancestry.count {
|
|
let commit = ancestry[index]
|
|
guard !healOIDs.contains(commit.oid) else { continue }
|
|
guard commit.parentOID != nil else { return nil }
|
|
return index
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func report(_ message: String) {
|
|
reportFailure?(GitOperationFailure(
|
|
operation: GitRestoreOperation.operationName,
|
|
message: message
|
|
))
|
|
}
|
|
}
|