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:
@@ -334,6 +334,27 @@ public final class GitAutoCommitter {
|
||||
arm()
|
||||
}
|
||||
|
||||
// MARK: - The pause, asked for
|
||||
|
||||
/// **Re-reads the repository's state without attempting anything** — what the popover's git
|
||||
/// section calls when it appears (06 ▸ Rules ▸ Abnormal repo states: "the popover's git section
|
||||
/// names the state plainly").
|
||||
///
|
||||
/// The engine learns about a pause by *trying to commit* and being held, which is the right
|
||||
/// cadence for committing and the wrong one for a surface: a board opened into a detached HEAD
|
||||
/// would show live branch controls for as long as the debounce takes to fire. This is the same
|
||||
/// read the flush takes (`GitCommitOperation.reading`), asked by a surface instead of by a write,
|
||||
/// and it changes nothing else — no arming, no retry, no commit.
|
||||
///
|
||||
/// A flush landing while this is in flight wins, which is correct: it read the repository later
|
||||
/// and it read it in order to write.
|
||||
public func refreshPause() async {
|
||||
let root = boardRoot
|
||||
pause = await Task.detached(priority: .userInitiated) {
|
||||
GitCommitOperation.reading(at: root).pause
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Whether a card window's folder is currently staged around — the stage-around rule, made
|
||||
/// assertable without reaching into private state.
|
||||
public var stagedAroundFolders: [URL] {
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import Foundation
|
||||
import libgit2
|
||||
import os
|
||||
|
||||
// MARK: - Outcome
|
||||
|
||||
/// **How a branch operation ended** — the four answers 06-history-undo.md gives every app-initiated
|
||||
/// git operation, in the shape `GitCommitOutcome` already gives the committer's.
|
||||
///
|
||||
/// The kinship is deliberate: contention is never an error, a paused repository is a hold rather than
|
||||
/// a failure, and everything else is a clean failure carrying libgit2's own message ("An operation
|
||||
/// that fails *cleanly* — disk error, refused checkout … surfaces as a one-shot banner failure naming
|
||||
/// the operation and the error, the tree left as it was").
|
||||
public enum GitBranchOutcome: Sendable, Equatable {
|
||||
|
||||
/// HEAD now names this branch and the working tree is its state.
|
||||
case switched(String)
|
||||
|
||||
/// `index.lock` was held. The payload is the lock file's path — what an implausibly long wait
|
||||
/// names (06 ▸ Interaction with external writers: "a wait that persists implausibly long names
|
||||
/// the lock path").
|
||||
case locked(path: String)
|
||||
|
||||
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Branch controls
|
||||
/// disable in that state, so this is the race — a terminal started a merge between the popover
|
||||
/// rendering and the click landing.
|
||||
case held(GitRepositoryPause)
|
||||
|
||||
/// A clean failure: a refused checkout, an unwritable object store, a name that is not a branch.
|
||||
/// **The tree is untouched** — libgit2's safe checkout either applies wholly or refuses.
|
||||
case failed(GitOperationFailure)
|
||||
}
|
||||
|
||||
// MARK: - GitBranchOperation
|
||||
|
||||
/// **Branch switching and create-and-switch, over the bundled libgit2** (06-history-undo.md ▸ Branch
|
||||
/// switching) — the repository half of the operation, with nothing in it that knows about editors,
|
||||
/// banners, or the undo stack.
|
||||
///
|
||||
/// ### The checkout is `SAFE`, and that is the whole safety story
|
||||
///
|
||||
/// `git_checkout_tree` with `GIT_CHECKOUT_SAFE` "allows safe updates that cannot overwrite
|
||||
/// uncommitted data": a working tree carrying changes that conflict with the target refuses the
|
||||
/// checkout wholesale (`GIT_ECONFLICT`) and leaves every byte where it was. Nothing here ever passes
|
||||
/// `GIT_CHECKOUT_FORCE` — not on the switch, not on the create-and-switch, and not on the
|
||||
/// own-leftovers abort, which is the one path that could plausibly want it. That is what makes "a
|
||||
/// refused checkout is a clean one-shot failure, tree untouched" a property of the call rather than a
|
||||
/// promise, and it is checkable by grepping this file for `FORCE`.
|
||||
///
|
||||
/// The caller's contract is the other half: the switch runs on a settled tree — open Edit sessions
|
||||
/// settled explicitly, the pending auto-commit flushed — so in practice `SAFE` has nothing to refuse
|
||||
/// ("checkout runs on a truly settled tree: it cannot fail dirty").
|
||||
///
|
||||
/// ### Isolation
|
||||
///
|
||||
/// `GitCommitOperation`'s rule, unchanged and for its reason: every function is `nonisolated`, opens
|
||||
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
|
||||
/// `await`, a `Task`, or a stored property.
|
||||
enum GitBranchOperation {
|
||||
|
||||
/// What a failure calls itself on the banner — in the user's words, not libgit2's.
|
||||
static let operationName = "Switching branches"
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
/// libgit2's global state — `GitCommitOperation.startUp`'s twin, and for its reason.
|
||||
private static let startUp: Bool = {
|
||||
git_libgit2_init() >= 0
|
||||
}()
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// **Every local branch**, sorted the way a menu should list them.
|
||||
///
|
||||
/// Local only: remote-tracking branches are 07-sync-collab.md's, and a picker that offered
|
||||
/// `origin/main` would be offering a detached HEAD — precisely the state 06 pauses the whole git
|
||||
/// surface for.
|
||||
///
|
||||
/// An unborn HEAD answers with an empty list, which is honest: `git init` has created no branch
|
||||
/// yet, only a symbolic ref naming the one the first commit will make.
|
||||
nonisolated static func localBranches(at boardRoot: URL) -> [String] {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return [] }
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
var iterator: OpaquePointer?
|
||||
guard git_branch_iterator_new(&iterator, repository, GIT_BRANCH_LOCAL) == 0, let iterator else {
|
||||
return []
|
||||
}
|
||||
defer { git_branch_iterator_free(iterator) }
|
||||
|
||||
var names: [String] = []
|
||||
var reference: OpaquePointer?
|
||||
var kind = GIT_BRANCH_LOCAL
|
||||
while git_branch_next(&reference, &kind, iterator) == 0 {
|
||||
defer {
|
||||
reference.map(git_reference_free)
|
||||
reference = nil
|
||||
}
|
||||
var name: UnsafePointer<CChar>?
|
||||
guard git_branch_name(&name, reference) == 0, let name else { continue }
|
||||
names.append(String(cString: name))
|
||||
}
|
||||
return names.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Whether libgit2 would accept `name` as a branch name — `git check-ref-format --branch`'s
|
||||
/// answer, asked before anything is created so the failure names the input rather than a ref.
|
||||
nonisolated static func isValidBranchName(_ name: String) -> Bool {
|
||||
_ = startUp
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
var valid: Int32 = 0
|
||||
guard git_branch_name_is_valid(&valid, trimmed) == 0 else { return false }
|
||||
return valid == 1
|
||||
}
|
||||
|
||||
/// Whether a local branch by this name already exists — the create path's own refusal, phrased
|
||||
/// against the name the user typed instead of against libgit2's `GIT_EEXISTS`.
|
||||
nonisolated static func branchExists(_ name: String, at boardRoot: URL) -> Bool {
|
||||
localBranches(at: boardRoot).contains(name)
|
||||
}
|
||||
|
||||
/// `.git/index.lock`'s path, for the waiting state that names it.
|
||||
nonisolated static func indexLockPath(at boardRoot: URL) -> String {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else {
|
||||
return boardRoot.appendingPathComponent(".git/index.lock").path
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
return gitDirectory(of: repository).appendingPathComponent("index.lock").path
|
||||
}
|
||||
|
||||
// MARK: - The switch
|
||||
|
||||
/// **The checkout itself** (06 ▸ Branch switching): materialize the branch's tree with the safe
|
||||
/// strategy, then move HEAD's symbolic ref onto it.
|
||||
///
|
||||
/// The order is libgit2's own recommended one and it matters: the checkout's baseline is the
|
||||
/// *current* HEAD, so the tree is updated against what is actually checked out, and HEAD moves
|
||||
/// only once the bytes are there. An interruption between the two leaves a tree that matches the
|
||||
/// target under a HEAD that does not — which is exactly the leftover `GitOperationStamp` exists to
|
||||
/// recognize as the app's own.
|
||||
///
|
||||
/// - Parameter allowingPause: whether to proceed against a repository in a pause state. `false`
|
||||
/// everywhere except the own-leftovers abort, which is 06's one exemption from "the app never
|
||||
/// mutates repo state it didn't create" — see `abort(_:at:)`.
|
||||
nonisolated static func checkout(
|
||||
_ branch: String,
|
||||
at boardRoot: URL,
|
||||
allowingPause: Bool = false
|
||||
) -> GitBranchOutcome {
|
||||
_ = startUp
|
||||
|
||||
// The state check runs immediately before the write, never from a caller's earlier read: 06's
|
||||
// rule is that it runs "again before every flush", and a terminal can start a merge between a
|
||||
// popover rendering and a click landing.
|
||||
let reading = GitCommitOperation.reading(at: boardRoot)
|
||||
if let pause = reading.pause, !allowingPause { return .held(pause) }
|
||||
if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }
|
||||
|
||||
guard let repository = open(boardRoot) else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "this board's repository could not be opened"
|
||||
))
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
|
||||
let fullName = "refs/heads/" + branch
|
||||
var reference: OpaquePointer?
|
||||
guard git_reference_lookup(&reference, repository, fullName) == 0, let reference else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "there is no local branch named '\(branch)'"
|
||||
))
|
||||
}
|
||||
defer { git_reference_free(reference) }
|
||||
|
||||
var target: OpaquePointer?
|
||||
guard git_reference_peel(&target, reference, GIT_OBJECT_COMMIT) == 0, let target else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
defer { git_object_free(target) }
|
||||
|
||||
var options = git_checkout_options()
|
||||
guard git_checkout_options_init(&options, UInt32(GIT_CHECKOUT_OPTIONS_VERSION)) == 0 else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
// **`SAFE`, never `FORCE`** — see the type's note. The value is libgit2's zero, spelled out
|
||||
// rather than left implicit so the strategy is visible at the point it is chosen.
|
||||
options.checkout_strategy = GIT_CHECKOUT_SAFE.rawValue
|
||||
|
||||
let checked = git_checkout_tree(repository, target, &options)
|
||||
guard checked == 0 else { return classify(checked, at: boardRoot) }
|
||||
|
||||
guard git_repository_set_head(repository, fullName) == 0 else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
logger.notice("checked out branch \(branch, privacy: .public)")
|
||||
return .switched(branch)
|
||||
}
|
||||
|
||||
/// **Create-and-switch** (06 ▸ Branch switching, and 03-board-ui.md ▸ Board popover: "branch
|
||||
/// switching and creation"): a new branch at the current HEAD, then the ordinary switch onto it.
|
||||
///
|
||||
/// **The checkout is not skipped**, though the new branch's tree is HEAD's by construction and the
|
||||
/// working tree therefore cannot change. The reason is a race the app shares its repository with
|
||||
/// by design (06 ▸ "Two writers, one repository"): an agent's self-commit landing between the
|
||||
/// branch's creation and the switch moves HEAD, and a `set_head` with no checkout would then leave
|
||||
/// the working tree describing a commit the new branch does not point at. Running the same
|
||||
/// checkout every switch runs costs one no-op index write in the ordinary case and is correct in
|
||||
/// the racing one.
|
||||
///
|
||||
/// **An unborn HEAD creates nothing and only moves the symbolic ref** — which is exactly what
|
||||
/// `git checkout -b` does on a repository with no commits: there is no commit to branch from, and
|
||||
/// the name HEAD points at is the branch the first commit will make (06 ▸ Rules ▸ Abnormal repo
|
||||
/// states: "an unborn HEAD … is normal git mode").
|
||||
nonisolated static func createAndSwitch(_ branch: String, at boardRoot: URL) -> GitBranchOutcome {
|
||||
_ = startUp
|
||||
let name = branch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard isValidBranchName(name) else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "'\(branch)' is not a valid branch name"
|
||||
))
|
||||
}
|
||||
guard !branchExists(name, at: boardRoot) else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "a branch named '\(name)' already exists"
|
||||
))
|
||||
}
|
||||
|
||||
let reading = GitCommitOperation.reading(at: boardRoot)
|
||||
if let pause = reading.pause { return .held(pause) }
|
||||
if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }
|
||||
|
||||
guard let repository = open(boardRoot) else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "this board's repository could not be opened"
|
||||
))
|
||||
}
|
||||
|
||||
if reading.isUnborn {
|
||||
defer { git_repository_free(repository) }
|
||||
guard git_repository_set_head(repository, "refs/heads/" + name) == 0 else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
return .switched(name)
|
||||
}
|
||||
|
||||
var created: OpaquePointer?
|
||||
let outcome: GitBranchOutcome? = {
|
||||
defer { git_repository_free(repository) }
|
||||
guard let head = headCommit(of: repository) else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
defer { git_commit_free(head) }
|
||||
guard git_branch_create(&created, repository, name, head, 0) == 0 else {
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
created.map(git_reference_free)
|
||||
return nil
|
||||
}()
|
||||
if let outcome { return outcome }
|
||||
|
||||
return checkout(name, at: boardRoot)
|
||||
}
|
||||
|
||||
// MARK: - The app's own leftovers
|
||||
|
||||
/// **Aborts an interrupted app-run switch** (06 ▸ Rules ▸ Abnormal repo states: "The one exemption
|
||||
/// is the app's own leftovers … finding a pause state with a matching stamp, the app **aborts its
|
||||
/// own unfinished operation** to restore the pre-operation state").
|
||||
///
|
||||
/// The abort *is* a checkout back to the branch the stamp recorded — the interrupted operation ran
|
||||
/// forwards, so undoing it is running the same operation backwards. It carries `allowingPause`
|
||||
/// because the leftover it is clearing is precisely a state that would otherwise refuse; that
|
||||
/// exemption is the stamp's whole purpose, and it is why nothing else in the app passes the flag.
|
||||
///
|
||||
/// **Still `SAFE`, still never `FORCE`.** An abort that overwrote uncommitted work to tidy up
|
||||
/// would be the app losing the user's bytes on its own initiative — and "abort discards nothing"
|
||||
/// is the design's own promise about it. A refused abort therefore stays refused and says so.
|
||||
///
|
||||
/// It deliberately does **not** call `git_repository_state_cleanup`: a branch switch never creates
|
||||
/// `MERGE_HEAD` or a rebase directory, so a leftover of *that* shape is not this operation's even
|
||||
/// when a stamp is standing, and removing it would be the never-mutate rule broken in the one
|
||||
/// place the exemption does not reach. (Recorded as a judgment call; the rebase that can leave one
|
||||
/// is 07-sync-collab.md's pull, whose own abort will own it.)
|
||||
nonisolated static func abort(_ stamp: GitOperationStamp, at boardRoot: URL) -> GitBranchOutcome {
|
||||
guard !stamp.fromBranch.isEmpty else {
|
||||
return .failed(GitOperationFailure(
|
||||
operation: operationName,
|
||||
message: "the interrupted operation recorded no branch to return to"
|
||||
))
|
||||
}
|
||||
return checkout(stamp.fromBranch, at: boardRoot, allowingPause: true)
|
||||
}
|
||||
|
||||
// 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 gitDirectory(of repository: OpaquePointer) -> URL {
|
||||
URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
|
||||
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_COMMIT) == 0 else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
|
||||
pointer.map { String(cString: $0) }
|
||||
}
|
||||
|
||||
private static func lastErrorMessage() -> String {
|
||||
guard let error = git_error_last(), let message = error.pointee.message else {
|
||||
return "libgit2 reported no reason"
|
||||
}
|
||||
return String(cString: message)
|
||||
}
|
||||
|
||||
/// Turns a libgit2 status into the outcome 06 gives it — contention apart from failure, exactly as
|
||||
/// `GitCommitOperation.classify` does for a commit.
|
||||
private static func classify(_ status: Int32, at boardRoot: URL) -> GitBranchOutcome {
|
||||
if status == GIT_ELOCKED.rawValue { return .locked(path: indexLockPath(at: boardRoot)) }
|
||||
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - GitBranchSwitcher
|
||||
|
||||
/// **The branch switch, in the order 06-history-undo.md ▸ Branch switching fixes it** — settle the
|
||||
/// editors, flush the pending commit, stamp the intent, check out, reseed undo — with every step that
|
||||
/// needs a window, a store, or a banner arriving as a seam.
|
||||
///
|
||||
/// ### Why the sequence is an object rather than a method
|
||||
///
|
||||
/// Because five of its six steps belong to somebody else. Settling editors is the card windows'
|
||||
/// (`SessionSettleGate`), flushing is the committer's, bracketing is the store's, reseeding is the
|
||||
/// undo provider's, and the in-progress row is the banner strip's — and 06 fixes the *order* they run
|
||||
/// in, which is the one thing none of them can hold. `GitHistoryProvider` is the same shape for the
|
||||
/// same reason, and its seams are wired from the same place (`AppModel.wireGitUndo`).
|
||||
///
|
||||
/// A `nil` seam is always the honest degenerate case rather than a disabled feature: a board with no
|
||||
/// card windows has nothing to settle, a repository-level test has no store to bracket with, and a
|
||||
/// board whose popover is closed has no spinner to update. The sequence runs the same way through all
|
||||
/// of them.
|
||||
///
|
||||
/// ### What it deliberately does not do
|
||||
///
|
||||
/// Nothing remote. Tracking, ahead/behind, Pull, Push and push-on-commit follow the current branch
|
||||
/// (06 ▸ Branch switching) and are 07-sync-collab.md's own card; this object moves HEAD and tells the
|
||||
/// undo stack, and the remote half will join by reading the same `didSwitch` seam.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class GitBranchSwitcher {
|
||||
|
||||
/// The board this switches branches on — in git mode, the repository's working-tree root.
|
||||
public let boardRoot: URL
|
||||
|
||||
// MARK: - Seams
|
||||
|
||||
/// **The save-or-discard step, over every open session** (06 ▸ Branch switching: "if any open card
|
||||
/// window has one … the switch presents a save-or-discard step").
|
||||
///
|
||||
/// Unlike the undo restore's, this gate is **not** narrowed by a diff. A restore materializes only
|
||||
/// the paths it changes, so a session the diff never touches is genuinely unaffected; a branch
|
||||
/// switch moves the whole tree out from under every session at once, and the raw-source hazard 06
|
||||
/// names — "its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new
|
||||
/// branch's card" — does not care whether the checkout touched that card at all. So the seam takes
|
||||
/// no paths, and `SessionSettleGate.settleAll()` is what production passes.
|
||||
@ObservationIgnored
|
||||
public var settleSessions: (@MainActor () async -> SessionSettleOutcome)?
|
||||
|
||||
/// The pending auto-commit, flushed once the sessions are settled — "with sessions settled, the
|
||||
/// pending auto-commit flushes (flush-before-overwrite) and checkout runs on a truly settled tree:
|
||||
/// it cannot fail dirty".
|
||||
@ObservationIgnored
|
||||
public var flushPendingCommit: (@MainActor () async -> Void)?
|
||||
|
||||
/// Stops and restarts the auto-commit debounce around the checkout, so a timer cannot fire
|
||||
/// mid-materialization. `GitHistoryProvider`'s pair, for its reason.
|
||||
@ObservationIgnored
|
||||
public var suspendCommitting: (@MainActor () -> Void)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var resumeCommitting: (@MainActor () -> Void)?
|
||||
|
||||
/// **The undo/redo reseed** (06 ▸ 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") — `GitHistoryProvider.reseed`, which is already exactly that.
|
||||
@ObservationIgnored
|
||||
public var reseedUndo: (@MainActor () async -> Void)?
|
||||
|
||||
/// The store's wholesale bracket: watcher suspended, one full reload at the end, the board locked
|
||||
/// read-only if that reload fails (02-architecture.md; `BoardStore.performWholesale(announcing:awaiting:)`).
|
||||
@ObservationIgnored
|
||||
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
|
||||
|
||||
/// The in-progress banner row: begin, relabel (the lock's waiting state), end.
|
||||
///
|
||||
/// Three seams rather than one object because the banner is the *store's*, and this type is
|
||||
/// composed on boards that have none. Relabelling is its own call because a held lock must change
|
||||
/// what the row says without replacing the row: "contention outlasting the brief retry surfaces as
|
||||
/// a *waiting* state in the operation's in-progress banner row" — the same operation, still
|
||||
/// running, now explaining itself.
|
||||
@ObservationIgnored
|
||||
public var beginProgress: (@MainActor (String) -> UUID)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var updateProgress: (@MainActor (UUID, String) -> Void)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var endProgress: (@MainActor (UUID) -> Void)?
|
||||
|
||||
/// A clean failure — "surfaces as a one-shot banner failure naming the operation and the error,
|
||||
/// the tree left as it was" (06 ▸ Interaction with external writers).
|
||||
///
|
||||
/// The banner rather than the popover's inline caption, deliberately, and 06 draws the line: the
|
||||
/// popover-anchored answer is for operations that answer *at the form* (add-git, verify-remote),
|
||||
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
|
||||
/// one surface" — which a branch switch is by construction, since its bracket locks the board and
|
||||
/// its completion is announced.
|
||||
@ObservationIgnored
|
||||
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
||||
|
||||
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
|
||||
@ObservationIgnored
|
||||
public var reportRecovery: (@MainActor (String) -> Void)?
|
||||
|
||||
/// The per-board registry's stamp — read at open, written before the repository is touched, and
|
||||
/// cleared when the operation is over (`GitOperationStamp`).
|
||||
@ObservationIgnored
|
||||
public var readStamp: (@MainActor () -> GitOperationStamp?)?
|
||||
|
||||
@ObservationIgnored
|
||||
public var writeStamp: (@MainActor (GitOperationStamp?) -> Void)?
|
||||
|
||||
/// Whether the git surface is held (`GitAutoCommitter.pause != nil`). The controls disable on it,
|
||||
/// and this is the pre-flight that keeps a click that raced the render from presenting a modal
|
||||
/// step for an operation the repository is about to refuse.
|
||||
@ObservationIgnored
|
||||
public var isHeld: (@MainActor () -> Bool)?
|
||||
|
||||
/// HEAD moved — what refreshes the popover's branch line (`HistoryStore.refreshBranch`). Called
|
||||
/// after a successful switch and after a successful abort, and by nothing else.
|
||||
@ObservationIgnored
|
||||
public var didSwitch: (@MainActor () async -> Void)?
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
/// Every local branch, as of the last refresh — the picker's contents.
|
||||
public private(set) var branches: [String] = []
|
||||
|
||||
/// Whether a switch is in flight: the controls' disabled state, and the guard that keeps a second
|
||||
/// click from starting a second checkout.
|
||||
public private(set) var isSwitching = false
|
||||
|
||||
/// The last clean failure, or `nil`. Held beside the banner it is also posted to, so the popover
|
||||
/// can show what happened while it was open without the banner having to be its only witness.
|
||||
public private(set) var lastFailure: GitOperationFailure?
|
||||
|
||||
/// Folders whose card session the settle step's **Discard** branch just abandoned — reverted to
|
||||
/// HEAD before anything else happens (see `perform`). Filled through `noteDiscarded(cardFolderName:)`,
|
||||
/// which is how `AppModel`'s gate reports each one.
|
||||
@ObservationIgnored
|
||||
private var discardedFolders: Set<String> = []
|
||||
|
||||
/// **A settle step discarded this card's session.** "Discard reverts buffers and uncommitted saves
|
||||
/// to HEAD" — the window reverted the buffer, and this is the switch remembering to revert the
|
||||
/// saves.
|
||||
public func noteDiscarded(cardFolderName: String) {
|
||||
discardedFolders.insert(cardFolderName)
|
||||
}
|
||||
|
||||
// MARK: - Tunables
|
||||
|
||||
/// The brief, silent backoff: "pull, push, branch switch, and undo restore meeting a held lock
|
||||
/// wait and retry briefly, silently" (06 ▸ Interaction with external writers). The committer's own
|
||||
/// numbers, for the committer's reason.
|
||||
@ObservationIgnored
|
||||
public var lockRetryDelay: Duration = .milliseconds(120)
|
||||
|
||||
@ObservationIgnored
|
||||
public var lockRetryAttempts = 3
|
||||
|
||||
/// The cadence the waiting state retries on, once the brief backoff is spent.
|
||||
@ObservationIgnored
|
||||
public var lockWaitInterval: Duration = .seconds(1)
|
||||
|
||||
/// How long a wait runs before the row names the lock path — "a wait that persists implausibly
|
||||
/// long names the lock path (a crashed writer's leftover is the user's to clear)".
|
||||
@ObservationIgnored
|
||||
public var lockPathNamingDelay: Duration = .seconds(5)
|
||||
|
||||
/// **The bound on the wait, recorded as a judgment call.** 06 describes a waiting state that
|
||||
/// retries on its cadence and never becomes an error dialog; it does not say when — or whether —
|
||||
/// it gives up. An unbounded wait would hold the board's wholesale bracket, and with it the
|
||||
/// read-only lock, for as long as a crashed writer's `index.lock` sits on disk, with no way out
|
||||
/// but quitting. So the wait ends, generously, at a clean failure that names the lock path — the
|
||||
/// tree untouched, the branch unchanged, the banner explaining exactly what to clear. Never a
|
||||
/// dialog, never a hammer, and never a board wedged by another process's litter.
|
||||
@ObservationIgnored
|
||||
public var lockWaitLimit: Duration = .seconds(30)
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||
|
||||
public init(boardRoot: URL) {
|
||||
self.boardRoot = boardRoot
|
||||
}
|
||||
|
||||
// MARK: - Phrases
|
||||
|
||||
/// The in-progress row while the checkout runs (02-architecture.md ▸ The banner surface:
|
||||
/// "Switching to 'main'…").
|
||||
public static func progressLabel(target: String) -> String {
|
||||
"Switching to '\(target)'…"
|
||||
}
|
||||
|
||||
/// The bracket's completion announcement (10-accessibility.md ▸ Live board announcements:
|
||||
/// "bracketed operations announce once, at completion").
|
||||
public static func completionAnnouncement(target: String) -> String {
|
||||
"Switched to branch '\(target)'"
|
||||
}
|
||||
|
||||
/// The waiting state, and the same sentence once the wait is long enough to name what is holding
|
||||
/// the lock.
|
||||
public static let waitingLabel = "Waiting for another writer's git lock"
|
||||
|
||||
public static func waitingLabel(path: String) -> String {
|
||||
"\(waitingLabel) (\(path))"
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// Reloads the branch list — what the popover's `.task` calls when it appears, and what every
|
||||
/// completed operation calls for itself.
|
||||
public func refreshBranches() async {
|
||||
let root = boardRoot
|
||||
branches = await Task.detached(priority: .userInitiated) {
|
||||
GitBranchOperation.localBranches(at: root)
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - The two operations
|
||||
|
||||
/// **Switches to an existing local branch.** Answers whether HEAD actually moved.
|
||||
@discardableResult
|
||||
public func switchTo(_ branch: String) async -> Bool {
|
||||
await perform(target: branch, creating: false)
|
||||
}
|
||||
|
||||
/// **Creates a branch at the current HEAD and switches to it.**
|
||||
///
|
||||
/// The full sequence runs — settle step included — and that is a judgment call, recorded. The card
|
||||
/// this was built for allows skipping the settle "only if you can prove the tree cannot change",
|
||||
/// and the proof does not hold: the new branch is created at whatever HEAD is *at that moment*,
|
||||
/// and this app shares its repository with self-committing agents by design (06 ▸ "Two writers,
|
||||
/// one repository"), so a commit landing between the flush and the create leaves a working tree
|
||||
/// that the new branch does not describe. Two smaller reasons point the same way — the flush puts
|
||||
/// pending work on the branch it was made on rather than on the branch that did not exist when it
|
||||
/// was made, and one sequence is one thing to reason about. The step costs nothing when nothing is
|
||||
/// dirty: the gate never appears unless a session is actually holding unsaved state.
|
||||
@discardableResult
|
||||
public func createAndSwitch(to branch: String) async -> Bool {
|
||||
await perform(target: branch.trimmingCharacters(in: .whitespacesAndNewlines), creating: true)
|
||||
}
|
||||
|
||||
private func perform(target: String, creating: Bool) async -> Bool {
|
||||
guard !isSwitching, !target.isEmpty else { return false }
|
||||
// A held repository disables the controls; this is the click that raced the render.
|
||||
guard isHeld?() != true else { return false }
|
||||
|
||||
isSwitching = true
|
||||
defer { isSwitching = false }
|
||||
lastFailure = nil
|
||||
discardedFolders = []
|
||||
|
||||
// **a. Settle the editors first — explicitly, never silently** (06 ▸ Branch switching). Before
|
||||
// the bracket, because the step is modal and a modal inside a suspended watcher would hold the
|
||||
// board read-only for as long as the user took to read it.
|
||||
if let settleSessions {
|
||||
switch await settleSessions() {
|
||||
case .cancelled, .failed:
|
||||
// "Cancel keeps the current branch and the sessions", and a raw buffer that will not
|
||||
// validate "cancels the whole switch with focus on the offending window, nothing
|
||||
// half-switched".
|
||||
discardedFolders = []
|
||||
return false
|
||||
case .proceed:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let root = boardRoot
|
||||
|
||||
// **a′. Discard's second half**: the windows reverted their buffers, and the *uncommitted
|
||||
// saves* those sessions left on disk go back to HEAD here — before the flush, which would
|
||||
// otherwise commit them the instant the ended session stopped being staged around
|
||||
// (`GitRestoreOperation.revertToHead`).
|
||||
let discarded = discardedFolders
|
||||
discardedFolders = []
|
||||
if !discarded.isEmpty {
|
||||
let reverted = await Task.detached(priority: .userInitiated) {
|
||||
GitRestoreOperation.revertToHead(folderNames: discarded, at: root)
|
||||
}.value
|
||||
guard reverted else {
|
||||
fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: "this board's repository could not be read"
|
||||
))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// **b. Flush the pending auto-commit** — the tree is settled from here on.
|
||||
await flushPendingCommit?()
|
||||
|
||||
// **c. Stamp the intent, before the repository is touched** (06 ▸ Rules ▸ Abnormal repo
|
||||
// states). Everything above this line is app-side; everything below can be interrupted.
|
||||
let head = await Task.detached(priority: .userInitiated) {
|
||||
GitHistoryWalk.headOID(at: root)
|
||||
}.value
|
||||
let current = await Task.detached(priority: .userInitiated) {
|
||||
GitRepository.branchName(at: root)
|
||||
}.value
|
||||
writeStamp?(GitOperationStamp(
|
||||
fromBranch: current ?? "",
|
||||
toBranch: target,
|
||||
headOID: head
|
||||
))
|
||||
|
||||
// **d. The checkout, bracketed** — watcher suspended, one full reload at the end, the board
|
||||
// locked read-only if that reload fails.
|
||||
let progress = beginProgress?(Self.progressLabel(target: target))
|
||||
var landed = false
|
||||
let work: @MainActor () async -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.suspendCommitting?()
|
||||
defer { self.resumeCommitting?() }
|
||||
|
||||
switch await self.runWaitingOutLocks(target: target, creating: creating, progress: progress) {
|
||||
case .switched:
|
||||
landed = true
|
||||
// **e. Reseed undo/redo from the new HEAD**, inside the bracket: the stack must never
|
||||
// be readable in a state where it describes the branch that is no longer checked out.
|
||||
await self.reseedUndo?()
|
||||
case let .failed(failure):
|
||||
self.fail(failure)
|
||||
case let .held(pause):
|
||||
self.fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: pause.explanation
|
||||
))
|
||||
case let .locked(path):
|
||||
self.fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: "another program is still using this repository's index (\(path))"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if let runBracketed {
|
||||
await runBracketed(Self.completionAnnouncement(target: target), work)
|
||||
} else {
|
||||
await work()
|
||||
}
|
||||
|
||||
// The operation is over, whichever way it went: a clean failure left the tree exactly as it
|
||||
// was, so there is nothing for a later open to abort.
|
||||
writeStamp?(nil)
|
||||
if let progress { endProgress?(progress) }
|
||||
await didSwitch?()
|
||||
await refreshBranches()
|
||||
return landed
|
||||
}
|
||||
|
||||
/// The checkout, with 06's lock posture around it: brief silent retries, then a waiting state in
|
||||
/// the operation's own row, then — at `lockWaitLimit` — a clean failure naming the lock path.
|
||||
private func runWaitingOutLocks(
|
||||
target: String,
|
||||
creating: Bool,
|
||||
progress: UUID?
|
||||
) async -> GitBranchOutcome {
|
||||
let root = boardRoot
|
||||
let startedWaiting = ContinuousClock.now
|
||||
var attempt = 0
|
||||
var announced = false
|
||||
var named = false
|
||||
|
||||
while true {
|
||||
let outcome = await Task.detached(priority: .userInitiated) {
|
||||
creating
|
||||
? GitBranchOperation.createAndSwitch(target, at: root)
|
||||
: GitBranchOperation.checkout(target, at: root)
|
||||
}.value
|
||||
|
||||
guard case let .locked(path) = outcome else { return outcome }
|
||||
|
||||
attempt += 1
|
||||
if attempt <= max(0, lockRetryAttempts) {
|
||||
// Brief and silent: "a held lock is another writer doing its job".
|
||||
try? await Task.sleep(for: lockRetryDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
let waited = ContinuousClock.now - startedWaiting
|
||||
guard waited < lockWaitLimit else {
|
||||
return .locked(path: path)
|
||||
}
|
||||
if !announced, let progress {
|
||||
updateProgress?(progress, Self.waitingLabel)
|
||||
announced = true
|
||||
}
|
||||
if !named, waited >= lockPathNamingDelay, let progress {
|
||||
updateProgress?(progress, Self.waitingLabel(path: path))
|
||||
named = true
|
||||
}
|
||||
try? await Task.sleep(for: lockWaitInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The app's own leftovers
|
||||
|
||||
/// **Recovers an interrupted app-run switch, at board open** (06 ▸ Rules ▸ Abnormal repo states).
|
||||
///
|
||||
/// Called once per session, beside the committer's start — which is where the pause it is looking
|
||||
/// for is first knowable, and before any of it reaches a user. Three outcomes, all of
|
||||
/// `GitOperationRecovery`'s: nothing to do, a stale stamp dropped silently, or the app's own
|
||||
/// leftover aborted with a banner.
|
||||
///
|
||||
/// **A failed abort keeps the stamp**, which is this file's second judgment call. 06 says the app
|
||||
/// "aborts its own unfinished operation … then clears the stamp"; that sentence describes the
|
||||
/// abort that worked. An abort refused by a conflicting working tree has restored nothing, and
|
||||
/// clearing the stamp would demote the leftover to somebody else's on the next open — the app
|
||||
/// would then defer forever to an operation only it ever started. So the stamp stands, the failure
|
||||
/// is surfaced, and the next open tries again.
|
||||
public func recoverInterruptedOperation() async {
|
||||
guard let stamp = readStamp?() else { return }
|
||||
let root = boardRoot
|
||||
let pause = await Task.detached(priority: .userInitiated) {
|
||||
GitCommitOperation.reading(at: root).pause
|
||||
}.value
|
||||
|
||||
switch GitOperationRecovery.decide(stamp: stamp, pause: pause) {
|
||||
case .nothingToDo:
|
||||
return
|
||||
|
||||
case .clearStamp:
|
||||
writeStamp?(nil)
|
||||
|
||||
case let .abort(stamp):
|
||||
Self.logger.notice("aborting this app's own interrupted branch switch")
|
||||
var restored = 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) {
|
||||
GitBranchOperation.abort(stamp, at: root)
|
||||
}.value
|
||||
switch outcome {
|
||||
case .switched:
|
||||
restored = true
|
||||
await self.reseedUndo?()
|
||||
case let .failed(failure):
|
||||
self.fail(failure)
|
||||
case let .held(pause):
|
||||
self.fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: pause.explanation
|
||||
))
|
||||
case let .locked(path):
|
||||
self.fail(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: "another program is using this repository's index (\(path))"
|
||||
))
|
||||
}
|
||||
}
|
||||
if let runBracketed {
|
||||
await runBracketed(GitOperationStamp.interruptionMessage, work)
|
||||
} else {
|
||||
await work()
|
||||
}
|
||||
guard restored else { return }
|
||||
writeStamp?(nil)
|
||||
reportRecovery?(GitOperationStamp.interruptionMessage)
|
||||
await didSwitch?()
|
||||
}
|
||||
|
||||
await refreshBranches()
|
||||
}
|
||||
|
||||
// MARK: - Failure
|
||||
|
||||
private func fail(_ failure: GitOperationFailure) {
|
||||
lastFailure = failure
|
||||
Self.logger.error("branch operation failed: \(failure.description, privacy: .public)")
|
||||
reportFailure?(failure)
|
||||
}
|
||||
}
|
||||
@@ -629,6 +629,55 @@ enum GitCommitOperation {
|
||||
)
|
||||
}
|
||||
|
||||
/// **What repo-local config actually says** — the two values behind the popover's identity fields,
|
||||
/// each `nil` when the file does not name it (06 ▸ Interaction with external writers).
|
||||
///
|
||||
/// Deliberately *not* `userIdentity(at:)`: that answers "who will this commit be by", derived
|
||||
/// default included, and a field pre-filled with a derived value would turn a placeholder into a
|
||||
/// value the moment the user typed anywhere else in the popover. The fields show what the file
|
||||
/// says and nothing more; the derived default is their placeholder.
|
||||
nonisolated static func repoLocalIdentity(at boardRoot: URL) -> (name: String?, email: String?) {
|
||||
_ = startUp
|
||||
guard let repository = open(boardRoot) else { return (nil, nil) }
|
||||
defer { git_repository_free(repository) }
|
||||
return GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository))
|
||||
}
|
||||
|
||||
/// **Writes the popover's identity fields into repo-local config** — the one write of those keys
|
||||
/// in the app (`GitConfigFile.writeIdentity`, where the file-format rules live).
|
||||
///
|
||||
/// The `.git` directory comes from libgit2 rather than from `boardRoot/.git`, for
|
||||
/// `userIdentity(at:)`'s reason: a board whose `.git` is a *file* (a linked worktree) has its real
|
||||
/// config somewhere else, and writing beside the pointer would be writing to nothing.
|
||||
nonisolated static func writeRepoLocalIdentity(
|
||||
name: String?,
|
||||
email: String?,
|
||||
at boardRoot: URL
|
||||
) -> Result<Void, GitOperationFailure> {
|
||||
_ = startUp
|
||||
let operation = "Saving this board's commit identity"
|
||||
guard let repository = open(boardRoot) else {
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: "this board's repository could not be opened"
|
||||
))
|
||||
}
|
||||
defer { git_repository_free(repository) }
|
||||
do {
|
||||
try GitConfigFile.writeIdentity(
|
||||
name: name,
|
||||
email: email,
|
||||
inGitDirectory: gitDirectory(of: repository)
|
||||
)
|
||||
return .success(())
|
||||
} catch {
|
||||
return .failure(GitOperationFailure(
|
||||
operation: operation,
|
||||
message: (error as NSError).localizedDescription
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private static func signature(_ identity: GitIdentity) -> UnsafeMutablePointer<git_signature>? {
|
||||
var signature: UnsafeMutablePointer<git_signature>?
|
||||
let now = Date()
|
||||
|
||||
@@ -439,8 +439,8 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
/// 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(cardFolderPath: String) {
|
||||
discardedFolders.insert(cardFolderPath)
|
||||
public func noteDiscarded(cardFolderName: String) {
|
||||
discardedFolders.insert(cardFolderName)
|
||||
}
|
||||
|
||||
// MARK: - The pointer
|
||||
|
||||
@@ -175,6 +175,142 @@ enum GitConfigFile {
|
||||
return (name, email)
|
||||
}
|
||||
|
||||
// MARK: Writing
|
||||
|
||||
/// **The popover's identity fields, landing in the file** (06-history-undo.md ▸ Interaction with
|
||||
/// external writers: "The board popover's git section exposes name/email fields that **write that
|
||||
/// repo-local config** — the setting *is* the file, portable to any git client, per-board by
|
||||
/// nature").
|
||||
///
|
||||
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
|
||||
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
|
||||
/// never written into repo config", because a value the app wrote there would outrank the user's
|
||||
/// own global `~/.gitconfig` for their terminal commits in that board. What lands here is what the
|
||||
/// user typed and nothing else.
|
||||
///
|
||||
/// **Empty clears the key** rather than writing an empty value — the fields show the derived
|
||||
/// default as a *placeholder*, so an empty field means "no repo-local opinion", which in this file
|
||||
/// is spelled by the key's absence. A `[user]` section left with nothing in it is removed too, so
|
||||
/// clearing both fields leaves a config indistinguishable from one the user never edited.
|
||||
///
|
||||
/// Everything else in the file survives verbatim: other sections, comments, indentation, and any
|
||||
/// `[user]` key this app has no opinion about (`signingkey`, say).
|
||||
static func writeIdentity(
|
||||
name: String?,
|
||||
email: String?,
|
||||
inGitDirectory gitDirectory: URL
|
||||
) throws {
|
||||
let configURL = gitDirectory.appendingPathComponent("config")
|
||||
let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
|
||||
let updated = applying(name: name, email: email, to: existing)
|
||||
try Data(updated.utf8).write(to: configURL, options: .atomic)
|
||||
}
|
||||
|
||||
/// The edit, over text — the pure half, which is where every rule above is decided and the only
|
||||
/// half a test needs.
|
||||
static func applying(name: String?, email: String?, to text: String) -> String {
|
||||
func cleaned(_ value: String?) -> String? {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty else { return nil }
|
||||
return trimmed
|
||||
}
|
||||
// `nil` is "clear this key"; a key absent from the dictionary has already been dealt with.
|
||||
var pending: [String: String?] = ["name": cleaned(name), "email": cleaned(email)]
|
||||
|
||||
// Split on `\n` and rejoin, so the file's own trailing-newline shape survives the round trip
|
||||
// (`components(separatedBy:)` renders a trailing newline as a final empty element).
|
||||
var output: [String] = []
|
||||
/// Whether the lines being read belong to the **plain** `[user]` section. A subsectioned
|
||||
/// `[user "work"]` is a different scope in git's own model (`user.work.name`, not
|
||||
/// `user.name`), and editing keys inside one would be this app rewriting a setting the user
|
||||
/// aimed somewhere else — much the worse error, whatever the read side does with it.
|
||||
///
|
||||
/// (The read side, `identity(inConfigText:)`, deliberately takes the last matching value it
|
||||
/// meets whichever section it is in — its own recorded call. The two agree in practice for
|
||||
/// every file this writer has touched, because a plain section it *adds* goes at the end, so
|
||||
/// its keys are the last ones the reader meets.)
|
||||
var isPlainUserSection = false
|
||||
/// Where a key the file does not yet have would be inserted: just after the last line of the
|
||||
/// plain `[user]` section, or `nil` while there is no such section.
|
||||
var insertionPoint: Int?
|
||||
|
||||
for line in text.isEmpty ? [] : text.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.hasPrefix("[") {
|
||||
let header = trimmed.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
|
||||
let section = header
|
||||
.split(separator: " ", maxSplits: 1)
|
||||
.first
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
||||
isPlainUserSection = section == "user" && !header.contains("\"")
|
||||
output.append(line)
|
||||
if isPlainUserSection { insertionPoint = output.count }
|
||||
continue
|
||||
}
|
||||
|
||||
let isUserSection = isPlainUserSection
|
||||
if isUserSection, let separator = trimmed.firstIndex(of: "=") {
|
||||
let key = trimmed[trimmed.startIndex..<separator]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.lowercased()
|
||||
if let replacement = pending[key] {
|
||||
pending.removeValue(forKey: key)
|
||||
if let replacement {
|
||||
output.append("\t\(key) = \(replacement)")
|
||||
insertionPoint = output.count
|
||||
}
|
||||
// A cleared key simply does not join the output.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
output.append(line)
|
||||
if isUserSection, insertionPoint != nil, !trimmed.isEmpty { insertionPoint = output.count }
|
||||
}
|
||||
|
||||
// Name before email, always — a file this app wrote reads the same whichever field was
|
||||
// filled first.
|
||||
let additions = ["name", "email"].compactMap { key -> String? in
|
||||
guard let value = pending[key] ?? nil else { return nil }
|
||||
return "\t\(key) = \(value)"
|
||||
}
|
||||
if !additions.isEmpty {
|
||||
if let insertionPoint {
|
||||
output.insert(contentsOf: additions, at: insertionPoint)
|
||||
} else {
|
||||
if let last = output.last, !last.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
output.append("")
|
||||
}
|
||||
output.append("[user]")
|
||||
output.append(contentsOf: additions)
|
||||
output.append("")
|
||||
}
|
||||
}
|
||||
|
||||
return removingEmptyUserSection(from: output).joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Drops a `[user]` header with no keys under it — what clearing both fields leaves behind, and
|
||||
/// what a config the user never touched does not have.
|
||||
private static func removingEmptyUserSection(from lines: [String]) -> [String] {
|
||||
guard let header = lines.firstIndex(where: {
|
||||
let trimmed = $0.trimmingCharacters(in: .whitespaces)
|
||||
return trimmed.lowercased().hasPrefix("[user]")
|
||||
}) else { return lines }
|
||||
|
||||
var end = header + 1
|
||||
while end < lines.count {
|
||||
let trimmed = lines[end].trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("[") { break }
|
||||
if !trimmed.isEmpty, !trimmed.hasPrefix("#"), !trimmed.hasPrefix(";") { return lines }
|
||||
end += 1
|
||||
}
|
||||
var kept = lines
|
||||
kept.removeSubrange(header..<end)
|
||||
return kept
|
||||
}
|
||||
|
||||
/// Strips one layer of surrounding quotes, and an unquoted trailing comment. A `#` inside
|
||||
/// quotes is content — git's own rule, and the one place a naive strip would corrupt a name.
|
||||
private static func unquoted(_ value: String) -> String {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
@@ -78,6 +78,43 @@ public final class HistoryStore {
|
||||
/// a test, a storeless consumer — therefore has a committer that never runs.
|
||||
public private(set) var committer: GitAutoCommitter?
|
||||
|
||||
/// **The branch controls** (06-history-undo.md ▸ Branch switching), or `nil` on a board there is
|
||||
/// no repository to switch branches in.
|
||||
///
|
||||
/// Its existence is exactly `mode == .git`, the committer's rule for the committer's reason — and
|
||||
/// like the committer it is composed inert: the seams that make it a *sequence* (the settle step,
|
||||
/// the store's bracket, the undo reseed, the banner strip) arrive from the session, and a
|
||||
/// `HistoryStore` built without one has a switcher that can list branches and nothing else.
|
||||
public private(set) var switcher: GitBranchSwitcher?
|
||||
|
||||
// MARK: - Commit identity
|
||||
|
||||
/// **What repo-local `.git/config` says right now** — the popover's two fields, as values rather
|
||||
/// than as a resolved identity (06 ▸ Interaction with external writers: "The board popover's git
|
||||
/// section exposes name/email fields that write that repo-local config — the setting *is* the
|
||||
/// file").
|
||||
///
|
||||
/// Empty means the file names no such key, which is what an empty field means: the derived default
|
||||
/// applies, shown as the field's *placeholder*. Filling the field in with the derived value would
|
||||
/// be the app writing its own guess into the user's repository the first time they edited anything
|
||||
/// else in the popover — the exact thing 06 rules out.
|
||||
public private(set) var identityName = ""
|
||||
|
||||
public private(set) var identityEmail = ""
|
||||
|
||||
/// **The derived default**, for the placeholders — `nil` until `refreshIdentity()` has run.
|
||||
///
|
||||
/// Deliberately not computed at composition: `GitIdentity.derivedDefault()` reads
|
||||
/// `ProcessInfo.hostName`, which can block on a machine whose name resolution is slow, and the
|
||||
/// board-open path is where 02-architecture.md's hang-avoidance doctrine is strictest. It is read
|
||||
/// off the main actor with the config, when the popover asks.
|
||||
public private(set) var derivedIdentity: GitIdentity?
|
||||
|
||||
/// The last identity-write failure, surfaced as an inline caption in the popover beside the fields
|
||||
/// — 06's popover-anchored posture ("the user asked from a form still under their eye"), which is
|
||||
/// exactly where `lastFailure` above already puts add-git's.
|
||||
public private(set) var identityFailure: GitOperationFailure?
|
||||
|
||||
/// The board's write-provenance ledger, held so an add-git flip can build a committer over the
|
||||
/// same one the session's store owns.
|
||||
@ObservationIgnored
|
||||
@@ -108,6 +145,7 @@ public final class HistoryStore {
|
||||
self.ledger = ledger
|
||||
if mode == .git {
|
||||
committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
|
||||
switcher = GitBranchSwitcher(boardRoot: boardRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +239,9 @@ public final class HistoryStore {
|
||||
self.committer = committer
|
||||
autoCommitWiring?(committer)
|
||||
committer.start()
|
||||
// The branch controls appear with the repository they switch branches in — and before
|
||||
// `didAddGit`, which is what wires their seams (`AppModel.wireGitUndo`).
|
||||
switcher = GitBranchSwitcher(boardRoot: root)
|
||||
// Last, after the mode and the committer: the undo binding reads both.
|
||||
didAddGit?()
|
||||
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
||||
@@ -222,6 +263,47 @@ public final class HistoryStore {
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - Commit identity
|
||||
|
||||
/// Reads repo-local config and the derived default into the popover's fields. A no-op outside git
|
||||
/// mode, `refreshBranch()`'s rule.
|
||||
///
|
||||
/// Both reads run off the main actor: one opens a repository, the other asks the system for the
|
||||
/// account and host names.
|
||||
public func refreshIdentity() async {
|
||||
guard mode == .git else { return }
|
||||
let root = boardRoot
|
||||
let read = await Task.detached(priority: .userInitiated) {
|
||||
(
|
||||
repoLocal: GitCommitOperation.repoLocalIdentity(at: root),
|
||||
derived: GitIdentity.derivedDefault()
|
||||
)
|
||||
}.value
|
||||
identityName = read.repoLocal.name ?? ""
|
||||
identityEmail = read.repoLocal.email ?? ""
|
||||
derivedIdentity = read.derived
|
||||
}
|
||||
|
||||
/// **Writes the fields into repo-local `.git/config`** — "the setting *is* the file".
|
||||
///
|
||||
/// An empty value clears its key rather than writing an empty string, which is what the
|
||||
/// placeholder promises: an empty field means the derived default applies. The read afterwards is
|
||||
/// not ceremony — it is how the fields end up showing what the file says rather than what was
|
||||
/// typed at it, which is the only version that survives a foreign edit landing in between.
|
||||
public func writeIdentity(name: String, email: String) async {
|
||||
guard mode == .git else { return }
|
||||
let root = boardRoot
|
||||
identityFailure = nil
|
||||
let outcome = await Task.detached(priority: .userInitiated) {
|
||||
GitCommitOperation.writeRepoLocalIdentity(name: name, email: email, at: root)
|
||||
}.value
|
||||
if case let .failure(failure) = outcome {
|
||||
identityFailure = failure
|
||||
Self.logger.error("identity write failed: \(failure.description, privacy: .public)")
|
||||
}
|
||||
await refreshIdentity()
|
||||
}
|
||||
|
||||
// MARK: - The loader's history seam
|
||||
|
||||
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md ▸ Fractal layout ▸ Rules;
|
||||
|
||||
Reference in New Issue
Block a user