Build branch switching and the popover git surface
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
This commit is contained in:
@@ -61,12 +61,13 @@ public struct GitRestorePlan: Sendable, Equatable {
|
||||
/// - **`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.
|
||||
/// - **`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
|
||||
///
|
||||
@@ -96,18 +97,26 @@ enum GitRestoreOperation {
|
||||
/// - 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).
|
||||
/// - 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<String> = [],
|
||||
reconciling: Set<String> = []
|
||||
reconciling folderNames: Set<String> = []
|
||||
) -> 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
|
||||
// `<lane>/<id>`, 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] = [:]
|
||||
@@ -165,25 +174,8 @@ enum GitRestoreOperation {
|
||||
_ = 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
|
||||
))
|
||||
}
|
||||
if let failure = materialize(plan, at: boardRoot) {
|
||||
return .failed(failure)
|
||||
}
|
||||
|
||||
let identity = GitCommitOperation.userIdentity(at: boardRoot)
|
||||
@@ -200,6 +192,104 @@ enum GitRestoreOperation {
|
||||
)
|
||||
}
|
||||
|
||||
/// **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<String>, 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<String>, at boardRoot: URL) -> Set<String> {
|
||||
guard let walker = FileManager.default.enumerator(
|
||||
at: boardRoot,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsPackageDescendants]
|
||||
) else { return [] }
|
||||
|
||||
var found: Set<String> = []
|
||||
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? {
|
||||
|
||||
Reference in New Issue
Block a user