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
111 lines
5.6 KiB
Swift
111 lines
5.6 KiB
Swift
import Foundation
|
|
|
|
// MARK: - GitOperationStamp
|
|
|
|
/// **The app's declaration that it is about to touch the repository** (06-history-undo.md ▸ Rules
|
|
/// ▸ Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board registry)
|
|
/// before touching the repo, so an interrupted app-run rebase or checkout is recognizable as
|
|
/// Lanework's").
|
|
///
|
|
/// ### Why it exists at all
|
|
///
|
|
/// The app's standing posture toward a repository it finds in a pause state is to **hold and defer**:
|
|
/// name the state, disable the controls, and let the tool that created it finish. That posture is
|
|
/// correct for every leftover except one — the app's own. A checkout interrupted by a crash (or by a
|
|
/// volume vanishing mid-flight — 02-architecture.md ▸ the root-change composition) leaves a repository
|
|
/// whose state nobody in a terminal is going to finish, and deferring to a rebase that does not exist
|
|
/// would leave the board's git surface paused forever.
|
|
///
|
|
/// So the app writes down what it is about to do, **before** it does it. Finding a pause state on a
|
|
/// later open *with* a matching stamp, it aborts its own unfinished work and says so; finding one
|
|
/// without, the pause-and-defer stance is unchanged. The stamp is the only thing that distinguishes
|
|
/// the two, which is why it is written before the first byte and cleared after the last.
|
|
///
|
|
/// ### Where it lives, and why not in the repository
|
|
///
|
|
/// The **per-board registry** — `BoardRecord.gitOperationStamp`, in the app's own Application Support
|
|
/// home. Two rules pin it there. Files-first is absolute (02 ▸ Per-board app state): "no frontmatter
|
|
/// key, no sidecar, no xattr" — a marker file in the board folder would be board content, committed by
|
|
/// the very operation it describes. And the never-mutate rule forbids the obvious git-shaped home: a
|
|
/// file under `.git/` would be app-written repo state, which is exactly what this mechanism exists to
|
|
/// keep the app out of.
|
|
///
|
|
/// A consequence worth stating: the stamp is **per machine**, like every other registry record. A
|
|
/// board whose switch was interrupted on one Mac and then opened on another reads as an ordinary
|
|
/// unexplained pause — hold, name it, defer — which is the honest answer, since the second machine
|
|
/// genuinely does not know whose leftover it is.
|
|
public struct GitOperationStamp: Codable, Sendable, Equatable {
|
|
|
|
/// Which bracketed operation this stamp is for.
|
|
///
|
|
/// One case today. It is an enum rather than a bare marker because 06 names the mechanism for
|
|
/// "every bracketed operation" and the pull's rebase (07-sync-collab.md) is the next one to stamp;
|
|
/// a new case then needs no migration, because an unknown-to-old-builds case never appears in a
|
|
/// file an old build wrote.
|
|
public enum Kind: String, Codable, Sendable, CaseIterable {
|
|
case branchSwitch
|
|
}
|
|
|
|
public let kind: Kind
|
|
|
|
/// The branch HEAD named **before** the operation — where an abort returns to. Empty when the
|
|
/// repository had no branch to name (a detached HEAD the switch was starting from, which the
|
|
/// paused-surface rule makes unreachable today).
|
|
public let fromBranch: String
|
|
|
|
/// The branch the operation was heading for. Not used by the abort — recorded because a recovery
|
|
/// that could not say what was interrupted would be a worse diagnostic than one that can.
|
|
public let toBranch: String
|
|
|
|
/// HEAD's commit before the operation, or `nil` on an unborn HEAD. Recorded for the same reason:
|
|
/// it is the fact a support question ("what was it doing?") is answered with.
|
|
public let headOID: String?
|
|
|
|
public init(kind: Kind = .branchSwitch, fromBranch: String, toBranch: String, headOID: String?) {
|
|
self.kind = kind
|
|
self.fromBranch = fromBranch
|
|
self.toBranch = toBranch
|
|
self.headOID = headOID
|
|
}
|
|
|
|
/// **What the banner says after a successful abort** — 06's own sentence, with the app's
|
|
/// sentence-shaped capitalization.
|
|
public static let interruptionMessage =
|
|
"A branch switch was interrupted — the previous state is restored."
|
|
}
|
|
|
|
// MARK: - Recovery
|
|
|
|
/// **What to do about a stamp found at open** — a pure decision, so the mechanism's whole rule is
|
|
/// provable without a repository in a broken state.
|
|
public enum GitOperationRecovery: Sendable, Equatable {
|
|
|
|
/// No stamp: the ordinary case, and the one every board is in. The pause-and-defer stance applies
|
|
/// unchanged to whatever state the repository happens to be in.
|
|
case nothingToDo
|
|
|
|
/// A stamp, but a repository in a state the app writes in perfectly well. The operation finished
|
|
/// and the clear did not land — a quit between the two, or a registry write that lost a race — so
|
|
/// there is nothing to abort and the stamp is stale. Dropping it silently is right: nothing
|
|
/// happened that the user needs told about.
|
|
case clearStamp
|
|
|
|
/// A stamp **and** a pause state: the app's own unfinished operation. Abort it, restore the
|
|
/// pre-operation state, say so, then clear.
|
|
case abort(GitOperationStamp)
|
|
|
|
/// The whole rule, in one function.
|
|
///
|
|
/// The conjunction is the point: a pause **without** a stamp is somebody else's operation and the
|
|
/// app must not touch it, and a stamp **without** a pause is the app's own finished work. Only
|
|
/// both together are "the app's own leftovers".
|
|
public static func decide(
|
|
stamp: GitOperationStamp?,
|
|
pause: GitRepositoryPause?
|
|
) -> GitOperationRecovery {
|
|
guard let stamp else { return .nothingToDo }
|
|
guard pause != nil else { return .clearStamp }
|
|
return .abort(stamp)
|
|
}
|
|
}
|