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:
@@ -880,9 +880,90 @@ public final class AppModel {
|
|||||||
}
|
}
|
||||||
provider.settleSessions = { [weak self, weak provider] paths in
|
provider.settleSessions = { [weak self, weak provider] paths in
|
||||||
guard let self, let provider else { return .proceed }
|
guard let self, let provider else { return .proceed }
|
||||||
return await self.settleGate(for: ref, provider: provider).settle(touching: paths)
|
let gate = self.settleGate(for: ref) { [weak provider] folder in
|
||||||
|
// The card's uncommitted on-disk saves are reverted by the restore itself, which
|
||||||
|
// compares this folder against the working tree rather than against HEAD — see
|
||||||
|
// `GitRestoreOperation.plan`.
|
||||||
|
provider?.noteDiscarded(cardFolderName: folder)
|
||||||
|
}
|
||||||
|
return await gate.settle(touching: paths)
|
||||||
}
|
}
|
||||||
provider.seed()
|
provider.seed()
|
||||||
|
|
||||||
|
wireBranchSwitching(git: git, store: store, provider: provider, ref: ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The branch controls' seams** (06-history-undo.md ▸ Branch switching) — the five things the
|
||||||
|
/// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an
|
||||||
|
/// interrupted switch recognizable as this app's.
|
||||||
|
///
|
||||||
|
/// Wired beside the undo provider's rather than in a place of its own, because the two are the
|
||||||
|
/// same board's git session seen from two sides — and because both must be re-wired on exactly the
|
||||||
|
/// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`).
|
||||||
|
private func wireBranchSwitching(
|
||||||
|
git: HistoryStore,
|
||||||
|
store: BoardStore,
|
||||||
|
provider: GitHistoryProvider,
|
||||||
|
ref: BoardWindowRef
|
||||||
|
) {
|
||||||
|
guard let switcher = git.switcher else { return }
|
||||||
|
let recordID = sessions[ref]?.recordID
|
||||||
|
|
||||||
|
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
|
||||||
|
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
|
||||||
|
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
||||||
|
switcher.resumeCommitting = { [weak git] in git?.committer?.start() }
|
||||||
|
// **The undo/redo reseed** — the provider's own API, which is the relaunch reseed by
|
||||||
|
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry … redo
|
||||||
|
// starts empty".
|
||||||
|
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
|
||||||
|
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
|
||||||
|
switcher.runBracketed = { [weak store] announcement, work in
|
||||||
|
guard let store else { return await work() }
|
||||||
|
try? await store.performWholesale(announcing: announcement) { await work() }
|
||||||
|
}
|
||||||
|
switcher.beginProgress = { [weak store] label in
|
||||||
|
store?.banners.beginOperation(label: label) ?? UUID()
|
||||||
|
}
|
||||||
|
switcher.updateProgress = { [weak store] id, label in
|
||||||
|
store?.banners.updateOperation(id, label: label)
|
||||||
|
}
|
||||||
|
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
|
||||||
|
// The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise — see it for why
|
||||||
|
// a git failure cannot be a `OneShotBanner` today.
|
||||||
|
switcher.reportFailure = { [weak store] failure in
|
||||||
|
store?.banners.postLoss(failure.description)
|
||||||
|
}
|
||||||
|
switcher.reportRecovery = { [weak store] message in
|
||||||
|
store?.banners.postLoss(message)
|
||||||
|
}
|
||||||
|
// **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no
|
||||||
|
// record — a store-level test — simply carries no stamp, and recovery then has nothing to
|
||||||
|
// recognize, which is the honest answer for a board the app has no state for.
|
||||||
|
switcher.readStamp = { [weak self] in
|
||||||
|
guard let self, let recordID else { return nil }
|
||||||
|
return self.boardRegistry.gitOperationStamp(id: recordID)
|
||||||
|
}
|
||||||
|
switcher.writeStamp = { [weak self] stamp in
|
||||||
|
guard let self, let recordID else { return }
|
||||||
|
self.boardRegistry.setGitOperationStamp(id: recordID, stamp)
|
||||||
|
}
|
||||||
|
switcher.settleSessions = { [weak self, weak switcher] in
|
||||||
|
guard let self, let switcher else { return .proceed }
|
||||||
|
let gate = self.settleGate(
|
||||||
|
for: ref,
|
||||||
|
message: SessionSettleStep.branchSwitchMessage
|
||||||
|
) { [weak switcher] folder in
|
||||||
|
switcher?.noteDiscarded(cardFolderName: folder)
|
||||||
|
}
|
||||||
|
// Every open session, not the ones a diff reaches — see `SessionSettleGate.settleAll`.
|
||||||
|
return await gate.settleAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The own-leftovers check, at open** (06 ▸ Rules ▸ Abnormal repo states). Beside the
|
||||||
|
// committer's start, which is where a pause first becomes knowable, and before anything the
|
||||||
|
// user does can land on top of a half-finished checkout.
|
||||||
|
Task { await switcher.recoverInterruptedOperation() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The save-or-discard step for one board**, built from its open card windows
|
/// **The save-or-discard step for one board**, built from its open card windows
|
||||||
@@ -890,7 +971,19 @@ public final class AppModel {
|
|||||||
///
|
///
|
||||||
/// Built per ask rather than stored, because its whole content is "which card windows are open
|
/// Built per ask rather than stored, because its whole content is "which card windows are open
|
||||||
/// right now" — a set that changes under any operation slow enough to need the step at all.
|
/// right now" — a set that changes under any operation slow enough to need the step at all.
|
||||||
func settleGate(for ref: BoardWindowRef, provider: GitHistoryProvider?) -> SessionSettleGate {
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - message: what the step says it is about. The two callers describe different consequences —
|
||||||
|
/// a restore changes the cards being edited, a switch replaces them — and 06 gives the step to
|
||||||
|
/// both without giving either the other's wording.
|
||||||
|
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
|
||||||
|
/// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it
|
||||||
|
/// into its plan; the switch reverts before it flushes).
|
||||||
|
func settleGate(
|
||||||
|
for ref: BoardWindowRef,
|
||||||
|
message: String = SessionSettleStep.message,
|
||||||
|
didDiscard: @escaping (String) -> Void = { _ in }
|
||||||
|
) -> SessionSettleGate {
|
||||||
SessionSettleGate(
|
SessionSettleGate(
|
||||||
sessions: { [weak self] in
|
sessions: { [weak self] in
|
||||||
guard let self, let session = self.sessions[ref] else { return [] }
|
guard let self, let session = self.sessions[ref] else { return [] }
|
||||||
@@ -902,17 +995,14 @@ public final class AppModel {
|
|||||||
cardFolderName: cardRef.cardID,
|
cardFolderName: cardRef.cardID,
|
||||||
needsSettling: settlement.needsSettling,
|
needsSettling: settlement.needsSettling,
|
||||||
saveAll: settlement.saveAll,
|
saveAll: settlement.saveAll,
|
||||||
discard: { [weak provider] in
|
discard: {
|
||||||
settlement.discard()
|
settlement.discard()
|
||||||
// The card's uncommitted on-disk saves are reverted by the restore
|
didDiscard(cardRef.cardID)
|
||||||
// itself, which compares this folder against the working tree rather
|
|
||||||
// than against HEAD — see `GitRestoreOperation.plan`.
|
|
||||||
provider?.noteDiscarded(cardFolderPath: cardRef.cardID)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ask: { await SessionSettleStep.ask() },
|
ask: { await SessionSettleStep.ask(message: message) },
|
||||||
focus: { [weak self] id in
|
focus: { [weak self] id in
|
||||||
guard let self, let session = self.sessions[ref] else { return }
|
guard let self, let session = self.sessions[ref] else { return }
|
||||||
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
|
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
|
||||||
|
|||||||
@@ -171,7 +171,26 @@ public struct SessionSettleGate {
|
|||||||
///
|
///
|
||||||
/// - Parameter paths: board-root-relative paths the operation would write.
|
/// - Parameter paths: board-root-relative paths the operation would write.
|
||||||
public func settle(touching paths: Set<String>) async -> SessionSettleOutcome {
|
public func settle(touching paths: Set<String>) async -> SessionSettleOutcome {
|
||||||
let candidates = Self.reached(by: paths, among: sessions()).filter { $0.needsSettling() }
|
await decide(over: Self.reached(by: paths, among: sessions()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Settles every open session, whatever the operation writes** — the branch switch's gate
|
||||||
|
/// (06 ▸ Branch switching).
|
||||||
|
///
|
||||||
|
/// The path filter above is the *restore's* narrowing and belongs to it alone: "a restore
|
||||||
|
/// materializes only the diff between the current tree and the target state, so a card whose open
|
||||||
|
/// Edit session the diff doesn't touch is simply unaffected". A branch switch has no such
|
||||||
|
/// property. It moves the whole tree at once, and the raw-source hazard 06 singles out — "an
|
||||||
|
/// unsettled raw buffer … its Apply later writes the *entire* pre-switch `index.md` byte-for-byte
|
||||||
|
/// onto the new branch's card" — is about the buffer belonging to the old branch, not about
|
||||||
|
/// whether the checkout happened to rewrite that card. So this asks about every session that is
|
||||||
|
/// holding something, and about no path at all.
|
||||||
|
public func settleAll() async -> SessionSettleOutcome {
|
||||||
|
await decide(over: sessions())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decide(over candidates: [SettleableSession]) async -> SessionSettleOutcome {
|
||||||
|
let candidates = candidates.filter { $0.needsSettling() }
|
||||||
guard !candidates.isEmpty else { return .proceed }
|
guard !candidates.isEmpty else { return .proceed }
|
||||||
|
|
||||||
switch await ask() {
|
switch await ask() {
|
||||||
@@ -232,8 +251,17 @@ public enum SessionSettleStep {
|
|||||||
Save them, discard the changes, or cancel.
|
Save them, discard the changes, or cancel.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
/// The same three buttons, asked for the other caller. **One sentence differs, deliberately**: the
|
||||||
|
/// consequence a user is deciding about is not the same one — a restore would change the cards
|
||||||
|
/// being edited, while a switch takes every card to a different branch — and a step that described
|
||||||
|
/// the wrong operation would be a worse modal than no wording at all.
|
||||||
|
public static let branchSwitchMessage = """
|
||||||
|
Switching branches would replace the cards you are editing. \
|
||||||
|
Save them, discard the changes, or cancel.
|
||||||
|
"""
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public static func ask() async -> SessionSettleChoice {
|
public static func ask(message: String = message) async -> SessionSettleChoice {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = title
|
alert.messageText = title
|
||||||
|
|||||||
@@ -334,6 +334,27 @@ public final class GitAutoCommitter {
|
|||||||
arm()
|
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
|
/// Whether a card window's folder is currently staged around — the stage-around rule, made
|
||||||
/// assertable without reaching into private state.
|
/// assertable without reaching into private state.
|
||||||
public var stagedAroundFolders: [URL] {
|
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>? {
|
private static func signature(_ identity: GitIdentity) -> UnsafeMutablePointer<git_signature>? {
|
||||||
var signature: UnsafeMutablePointer<git_signature>?
|
var signature: UnsafeMutablePointer<git_signature>?
|
||||||
let now = Date()
|
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
|
/// 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
|
/// reverted by the restore itself rather than by a second pass that could disagree with it
|
||||||
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
|
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
|
||||||
public func noteDiscarded(cardFolderPath: String) {
|
public func noteDiscarded(cardFolderName: String) {
|
||||||
discardedFolders.insert(cardFolderPath)
|
discardedFolders.insert(cardFolderName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The pointer
|
// MARK: - The pointer
|
||||||
|
|||||||
@@ -175,6 +175,142 @@ enum GitConfigFile {
|
|||||||
return (name, email)
|
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
|
/// 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.
|
/// 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 {
|
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
|
/// - **`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
|
/// 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."
|
/// 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
|
/// - **`reconciling`** — the card sessions the user chose to **Discard** at the save-or-discard step
|
||||||
/// save-or-discard step (06 ▸ Branch switching: "Discard reverts buffers and uncommitted saves to
|
/// (06 ▸ Branch switching: "Discard reverts buffers and uncommitted saves to HEAD"). Those folders
|
||||||
/// HEAD"). Those folders are compared against the **working tree** rather than against HEAD,
|
/// are compared against the **working tree** rather than against HEAD, because their uncommitted
|
||||||
/// because their uncommitted on-disk saves are precisely the state HEAD does not have — one pass
|
/// on-disk saves are precisely the state HEAD does not have — one pass that both drops the
|
||||||
/// that both drops the discarded saves and applies the restore, instead of a revert followed by a
|
/// discarded saves and applies the restore, instead of a revert followed by a restore that would
|
||||||
/// restore that would have to agree with it.
|
/// 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
|
/// ### Isolation
|
||||||
///
|
///
|
||||||
@@ -96,18 +97,26 @@ enum GitRestoreOperation {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - target: the oid of the commit whose state is being restored.
|
/// - target: the oid of the commit whose state is being restored.
|
||||||
/// - excluding: board-root-relative paths whose divergence is heal work — never materialized.
|
/// - excluding: board-root-relative paths whose divergence is heal work — never materialized.
|
||||||
/// - reconciling: board-root-relative folders compared against the working tree rather than
|
/// - reconciling: card **folder names** — the ids `SettleableSession.cardFolderName` carries —
|
||||||
/// against HEAD (the Discard branch of the save-or-discard step).
|
/// 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(
|
nonisolated static func plan(
|
||||||
at boardRoot: URL,
|
at boardRoot: URL,
|
||||||
target: String,
|
target: String,
|
||||||
excluding: Set<String> = [],
|
excluding: Set<String> = [],
|
||||||
reconciling: Set<String> = []
|
reconciling folderNames: Set<String> = []
|
||||||
) -> GitRestorePlan? {
|
) -> GitRestorePlan? {
|
||||||
_ = startUp
|
_ = startUp
|
||||||
guard let repository = open(boardRoot) else { return nil }
|
guard let repository = open(boardRoot) else { return nil }
|
||||||
defer { git_repository_free(repository) }
|
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 }
|
guard let targetTree = tree(of: target, in: repository) else { return nil }
|
||||||
defer { git_tree_free(targetTree) }
|
defer { git_tree_free(targetTree) }
|
||||||
var wanted: [String: git_oid] = [:]
|
var wanted: [String: git_oid] = [:]
|
||||||
@@ -165,25 +174,8 @@ enum GitRestoreOperation {
|
|||||||
_ = startUp
|
_ = startUp
|
||||||
guard !plan.isEmpty else { return .nothingToCommit }
|
guard !plan.isEmpty else { return .nothingToCommit }
|
||||||
|
|
||||||
let manager = FileManager.default
|
if let failure = materialize(plan, at: boardRoot) {
|
||||||
for change in plan.changes {
|
return .failed(failure)
|
||||||
let url = boardRoot.appendingPathComponent(change.path)
|
|
||||||
guard let contents = change.contents else {
|
|
||||||
try? manager.removeItem(at: url)
|
|
||||||
pruneEmptyFolders(above: url, upTo: boardRoot)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let folder = url.deletingLastPathComponent()
|
|
||||||
do {
|
|
||||||
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
||||||
try contents.write(to: url, options: .atomic)
|
|
||||||
} catch {
|
|
||||||
logger.error("restore could not write \(change.path, privacy: .public)")
|
|
||||||
return .failed(GitOperationFailure(
|
|
||||||
operation: operationName,
|
|
||||||
message: (error as NSError).localizedDescription
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let identity = GitCommitOperation.userIdentity(at: boardRoot)
|
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
|
// MARK: - Private plumbing
|
||||||
|
|
||||||
private static func open(_ boardRoot: URL) -> OpaquePointer? {
|
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.
|
/// a test, a storeless consumer — therefore has a committer that never runs.
|
||||||
public private(set) var committer: GitAutoCommitter?
|
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
|
/// 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.
|
/// same one the session's store owns.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
@@ -108,6 +145,7 @@ public final class HistoryStore {
|
|||||||
self.ledger = ledger
|
self.ledger = ledger
|
||||||
if mode == .git {
|
if mode == .git {
|
||||||
committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
|
committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
|
||||||
|
switcher = GitBranchSwitcher(boardRoot: boardRoot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +239,9 @@ public final class HistoryStore {
|
|||||||
self.committer = committer
|
self.committer = committer
|
||||||
autoCommitWiring?(committer)
|
autoCommitWiring?(committer)
|
||||||
committer.start()
|
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.
|
// Last, after the mode and the committer: the undo binding reads both.
|
||||||
didAddGit?()
|
didAddGit?()
|
||||||
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
||||||
@@ -222,6 +263,47 @@ public final class HistoryStore {
|
|||||||
}.value
|
}.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
|
// MARK: - The loader's history seam
|
||||||
|
|
||||||
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md ▸ Fractal layout ▸ Rules;
|
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md ▸ Fractal layout ▸ Rules;
|
||||||
|
|||||||
@@ -604,6 +604,22 @@ public final class BannerCenter {
|
|||||||
return operation.id
|
return operation.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Relabels a running operation** — the same row, still spinning, now saying something else.
|
||||||
|
///
|
||||||
|
/// It exists for one sentence in 06-history-undo.md ▸ Interaction with external writers:
|
||||||
|
/// "contention outlasting the brief retry surfaces as a *waiting* state in the operation's
|
||||||
|
/// in-progress banner row ('waiting for another writer's git lock'), retrying on its cadence".
|
||||||
|
/// The waiting state is explicitly *the operation's own row*, not a second row and not a
|
||||||
|
/// replacement — the operation has not restarted, it is explaining itself — so the id is stable
|
||||||
|
/// and the view neither churns nor re-animates.
|
||||||
|
///
|
||||||
|
/// An unknown id is a no-op: a completion racing a relabel is ordinary, not a bug.
|
||||||
|
public func updateOperation(_ id: UUID, label: String) {
|
||||||
|
guard let index = operations.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
let existing = operations[index]
|
||||||
|
operations[index] = InProgressOperation(id: existing.id, label: label, cancel: existing.cancel)
|
||||||
|
}
|
||||||
|
|
||||||
/// Ends one — the row leaves the strip.
|
/// Ends one — the row leaves the strip.
|
||||||
///
|
///
|
||||||
/// **Completion and failure both come through here.** There is no `failOperation`: a failure is
|
/// **Completion and failure both come through here.** There is no `failOperation`: a failure is
|
||||||
|
|||||||
@@ -158,6 +158,16 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
/// `UserDefaults`.
|
/// `UserDefaults`.
|
||||||
public var remoteLocationWarned: Bool
|
public var remoteLocationWarned: Bool
|
||||||
|
|
||||||
|
/// **A bracketed git operation this app started and has not finished** (06-history-undo.md ▸ Rules
|
||||||
|
/// ▸ Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board
|
||||||
|
/// registry) before touching the repo").
|
||||||
|
///
|
||||||
|
/// `nil` for every board that is not mid-operation, which is every board almost all of the time:
|
||||||
|
/// the stamp is written immediately before the repository is touched and cleared as soon as the
|
||||||
|
/// operation is over, so finding one at open means the app died in between. See
|
||||||
|
/// `GitOperationStamp` for why it lives here rather than in the board folder or under `.git`.
|
||||||
|
public var gitOperationStamp: GitOperationStamp?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
bookmark: Data,
|
bookmark: Data,
|
||||||
@@ -172,7 +182,8 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
pushOnCommit: Bool = false,
|
pushOnCommit: Bool = false,
|
||||||
remoteLocationWarned: Bool = false,
|
remoteLocationWarned: Bool = false,
|
||||||
icon: String? = nil,
|
icon: String? = nil,
|
||||||
iconColor: String? = nil
|
iconColor: String? = nil,
|
||||||
|
gitOperationStamp: GitOperationStamp? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.bookmark = bookmark
|
self.bookmark = bookmark
|
||||||
@@ -188,6 +199,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
self.remoteLocationWarned = remoteLocationWarned
|
self.remoteLocationWarned = remoteLocationWarned
|
||||||
self.icon = icon
|
self.icon = icon
|
||||||
self.iconColor = iconColor
|
self.iconColor = iconColor
|
||||||
|
self.gitOperationStamp = gitOperationStamp
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Codable
|
// MARK: Codable
|
||||||
@@ -215,6 +227,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
case iconColor
|
case iconColor
|
||||||
case pushOnCommit
|
case pushOnCommit
|
||||||
case remoteLocationWarned
|
case remoteLocationWarned
|
||||||
|
case gitOperationStamp
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
@@ -233,6 +246,11 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor)
|
iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor)
|
||||||
pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false
|
pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false
|
||||||
remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false
|
remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false
|
||||||
|
// Tolerant twice over: absent on every record written before this key existed, and absent
|
||||||
|
// again — rather than fatal — if a future build's stamp `Kind` is one this build cannot name.
|
||||||
|
// A stamp that cannot be read is a stamp that cannot recover anything, which degrades to the
|
||||||
|
// pause-and-defer stance rather than to a quarantined registry.
|
||||||
|
gitOperationStamp = try? container.decodeIfPresent(GitOperationStamp.self, forKey: .gitOperationStamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: any Encoder) throws {
|
public func encode(to encoder: any Encoder) throws {
|
||||||
@@ -251,6 +269,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
|||||||
try container.encodeIfPresent(iconColor, forKey: .iconColor)
|
try container.encodeIfPresent(iconColor, forKey: .iconColor)
|
||||||
try container.encode(pushOnCommit, forKey: .pushOnCommit)
|
try container.encode(pushOnCommit, forKey: .pushOnCommit)
|
||||||
try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned)
|
try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned)
|
||||||
|
try container.encodeIfPresent(gitOperationStamp, forKey: .gitOperationStamp)
|
||||||
// An unknown key is dropped, exactly as the synthesized conformance dropped it: the file's
|
// An unknown key is dropped, exactly as the synthesized conformance dropped it: the file's
|
||||||
// forward tolerance is a decoding property, and nothing here preserves what it cannot read.
|
// forward tolerance is a decoding property, and nothing here preserves what it cannot read.
|
||||||
}
|
}
|
||||||
@@ -580,6 +599,22 @@ public final class BoardRegistry {
|
|||||||
update(id) { $0.remoteLocationWarned = true }
|
update(id) { $0.remoteLocationWarned = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Records — or clears — the bracketed git operation this app is about to run**
|
||||||
|
/// (`GitOperationStamp`).
|
||||||
|
///
|
||||||
|
/// It saves the file synchronously like every other setter here, and that is load-bearing rather
|
||||||
|
/// than incidental: the whole value of the stamp is that it is on disk *before* the repository is
|
||||||
|
/// touched, so a crash a millisecond later is still recognizable as this app's. `save()` writes
|
||||||
|
/// atomically, so the file a next launch reads is either the old one or this one.
|
||||||
|
public func setGitOperationStamp(id: UUID, _ stamp: GitOperationStamp?) {
|
||||||
|
update(id) { $0.gitOperationStamp = stamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stamp this board is carrying, if any — read once per session, at open.
|
||||||
|
public func gitOperationStamp(id: UUID) -> GitOperationStamp? {
|
||||||
|
record(id: id)?.gitOperationStamp
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Reading
|
// MARK: - Reading
|
||||||
|
|
||||||
/// Every known board, most recently opened first, each classified by whether its bookmark
|
/// Every known board, most recently opened first, each classified by whether its bookmark
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - The git-mode section's state
|
||||||
|
|
||||||
|
/// **What the popover's git section shows on a git-mode board** — a pure function of four facts, so
|
||||||
|
/// the surface 06-history-undo.md describes is assertable without a popover on screen.
|
||||||
|
///
|
||||||
|
/// It exists for the same reason `BoardGitSection.resolve` does one level up: the *posture* is the
|
||||||
|
/// part worth pinning, and the SwiftUI that renders it is not. Three rules live here —
|
||||||
|
///
|
||||||
|
/// - **A paused repository names its state and disables the controls** (06 ▸ Rules ▸ Abnormal repo
|
||||||
|
/// states: "Undo/Redo and the branch controls disable … the popover's git section names the state
|
||||||
|
/// plainly … and says resolving it belongs to the tool that created it").
|
||||||
|
/// - **A read-only board disables them too** (02-architecture.md ▸ The lock's scope, which names "the
|
||||||
|
/// popover's git controls" outright).
|
||||||
|
/// - **A switch in flight disables them**, so a second click cannot start a second checkout.
|
||||||
|
struct BoardGitBranchSurface: Equatable {
|
||||||
|
|
||||||
|
/// What the branch line reads — the branch name, the short hash on a detached HEAD
|
||||||
|
/// (`GitRepository.branchName` decides which), or the placeholder while the first read is in
|
||||||
|
/// flight.
|
||||||
|
let branchLabel: String
|
||||||
|
|
||||||
|
/// Whether that label is the placeholder rather than an answer.
|
||||||
|
let isReadingBranch: Bool
|
||||||
|
|
||||||
|
/// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
|
||||||
|
let pauseExplanation: String?
|
||||||
|
|
||||||
|
/// Whether the branch picker and the create action accept a click.
|
||||||
|
let controlsEnabled: Bool
|
||||||
|
|
||||||
|
/// The line the branch display is read as by VoiceOver.
|
||||||
|
var accessibilityLabel: String {
|
||||||
|
isReadingBranch ? "Reading branch" : "Branch \(branchLabel)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static let placeholder = "…"
|
||||||
|
|
||||||
|
/// The second half of the paused sentence — 06's "says resolving it belongs to the tool that
|
||||||
|
/// created it", said in the app's own voice and paired with the promise that makes it safe to
|
||||||
|
/// wait: the app is not going to touch the repository behind the user's back.
|
||||||
|
static let pauseCaption =
|
||||||
|
"Finishing it belongs to the tool that started it; Lanework leaves the repository untouched."
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
branch: String?,
|
||||||
|
pause: GitRepositoryPause?,
|
||||||
|
isSwitching: Bool,
|
||||||
|
isWritable: Bool
|
||||||
|
) -> BoardGitBranchSurface {
|
||||||
|
BoardGitBranchSurface(
|
||||||
|
branchLabel: branch ?? placeholder,
|
||||||
|
isReadingBranch: branch == nil,
|
||||||
|
pauseExplanation: pause?.explanation,
|
||||||
|
controlsEnabled: pause == nil && isWritable && !isSwitching
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The git-mode section
|
||||||
|
|
||||||
|
/// **The popover's git section on a board that has a repository** (03-board-ui.md ▸ Board popover;
|
||||||
|
/// 06-history-undo.md ▸ Branch switching, ▸ Interaction with external writers).
|
||||||
|
///
|
||||||
|
/// Three surfaces, in the order the design lists them: the branch display with switching and
|
||||||
|
/// creation, the pause explanation when there is one, and the commit-identity fields.
|
||||||
|
///
|
||||||
|
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push, push-on-commit and the
|
||||||
|
/// authentication surface are 07-sync-collab.md's own cards, and this section is arranged so they
|
||||||
|
/// join as one more block between the branch controls and the identity fields — nothing here is
|
||||||
|
/// nested inside anything they would have to be pulled out of, and nothing about the branch controls
|
||||||
|
/// assumes there is no upstream to show beside them.
|
||||||
|
struct BoardGitControls: View {
|
||||||
|
|
||||||
|
let git: HistoryStore
|
||||||
|
|
||||||
|
/// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses writes
|
||||||
|
/// refuses a checkout most of all — it rewrites the tree the lock exists to stop describing.
|
||||||
|
let isEnabled: Bool
|
||||||
|
|
||||||
|
@State private var isNaming = false
|
||||||
|
@State private var draftBranch = ""
|
||||||
|
|
||||||
|
private var surface: BoardGitBranchSurface {
|
||||||
|
BoardGitBranchSurface.resolve(
|
||||||
|
branch: git.branch,
|
||||||
|
pause: git.committer?.pause,
|
||||||
|
isSwitching: git.switcher?.isSwitching ?? false,
|
||||||
|
isWritable: isEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
branchRow
|
||||||
|
|
||||||
|
if isNaming {
|
||||||
|
newBranchField
|
||||||
|
}
|
||||||
|
|
||||||
|
if let explanation = surface.pauseExplanation {
|
||||||
|
pauseNote(explanation)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let failure = git.switcher?.lastFailure {
|
||||||
|
caption(failure.message, tone: .red)
|
||||||
|
}
|
||||||
|
|
||||||
|
BoardGitIdentityFields(git: git, isEnabled: isEnabled)
|
||||||
|
}
|
||||||
|
// Every read the section needs, taken when it appears rather than held live: the popover is
|
||||||
|
// built fresh on each open (`BoardInfoWidget`), and none of these is a fact the board's
|
||||||
|
// watcher could deliver — `.git` is filtered out of the watch by design.
|
||||||
|
.task {
|
||||||
|
await git.refreshBranch()
|
||||||
|
await git.committer?.refreshPause()
|
||||||
|
await git.switcher?.refreshBranches()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The branch line
|
||||||
|
|
||||||
|
/// The branch display and the switch, as one control: the line *is* the picker, which is what
|
||||||
|
/// makes "branch/source display, branch switching and creation" one affordance rather than a label
|
||||||
|
/// with a button beside it.
|
||||||
|
private var branchRow: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "arrow.triangle.branch")
|
||||||
|
.imageScale(.small)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
ForEach(otherBranches, id: \.self) { name in
|
||||||
|
Button(name) {
|
||||||
|
Task { await git.switcher?.switchTo(name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !otherBranches.isEmpty {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
Button("New Branch…") {
|
||||||
|
draftBranch = ""
|
||||||
|
isNaming = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(surface.branchLabel)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(surface.isReadingBranch ? .secondary : .primary)
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
.disabled(!surface.controlsEnabled)
|
||||||
|
.accessibilityLabel(surface.accessibilityLabel)
|
||||||
|
.accessibilityHint("Switch branches or create a branch")
|
||||||
|
|
||||||
|
if git.switcher?.isSwitching == true {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.accessibilityLabel("Switching branches")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every local branch except the one already checked out — a picker offering the current branch
|
||||||
|
/// would be offering a no-op, and the switch refuses one anyway.
|
||||||
|
private var otherBranches: [String] {
|
||||||
|
(git.switcher?.branches ?? []).filter { $0 != git.branch }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Create and switch
|
||||||
|
|
||||||
|
/// Named inline rather than in a sheet: the popover is where the operation was asked for, and a
|
||||||
|
/// sheet over a transient popover would dismiss the surface it came from.
|
||||||
|
private var newBranchField: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
TextField("New branch name", text: $draftBranch)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1)
|
||||||
|
.onSubmit { create() }
|
||||||
|
// Escape steps outward one layer (04-interactions.md ▸ Grammar): it abandons the
|
||||||
|
// naming rather than dismissing the popover under it.
|
||||||
|
.onKeyPress(.escape) {
|
||||||
|
isNaming = false
|
||||||
|
draftBranch = ""
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
Button("Create", action: create)
|
||||||
|
.disabled(trimmedDraft.isEmpty)
|
||||||
|
}
|
||||||
|
.disabled(!surface.controlsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trimmedDraft: String {
|
||||||
|
draftBranch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create() {
|
||||||
|
let name = trimmedDraft
|
||||||
|
guard !name.isEmpty else { return }
|
||||||
|
isNaming = false
|
||||||
|
draftBranch = ""
|
||||||
|
Task { await git.switcher?.createAndSwitch(to: name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The pause
|
||||||
|
|
||||||
|
/// **The abnormal-state surface** (06 ▸ Rules ▸ Abnormal repo states) — deferred here from the
|
||||||
|
/// auto-commit card, which built the hold this explains.
|
||||||
|
///
|
||||||
|
/// Two sentences, both of them the design's: what the repository is doing, and whose job it is to
|
||||||
|
/// finish. Never a Repair button — "the app never mutates repo state it didn't create".
|
||||||
|
private func pauseNote(_ explanation: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(explanation)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Text(BoardGitBranchSurface.pauseCaption)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func caption(_ text: String, tone: Color) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(tone)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Commit identity
|
||||||
|
|
||||||
|
/// **The name and email that repo-local `.git/config` carries** (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").
|
||||||
|
///
|
||||||
|
/// ### The placeholder is the whole of the identity rule made visible
|
||||||
|
///
|
||||||
|
/// An empty field shows the **derived default** — the macOS account's full name and
|
||||||
|
/// `shortname@hostname` — as a placeholder, never as a value. That is the difference between "this
|
||||||
|
/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says
|
||||||
|
/// this", and the file is where the difference lives: 06 forbids the app writing its own derived
|
||||||
|
/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own
|
||||||
|
/// terminal commits in that board. A field pre-filled with the derived value would write it on the
|
||||||
|
/// first focus loss.
|
||||||
|
///
|
||||||
|
/// ### The dirty-buffer courtesy, copied from `BoardRenameField`
|
||||||
|
///
|
||||||
|
/// A foreign config edit landing while the popover is open updates an *unfocused* field and never a
|
||||||
|
/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md ▸ Board popover). The
|
||||||
|
/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher`
|
||||||
|
/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and
|
||||||
|
/// the alternative to a small periodic read is a field that is stale for as long as the popover
|
||||||
|
/// stays open. The poll lives and dies with this view.
|
||||||
|
private struct BoardGitIdentityFields: View {
|
||||||
|
|
||||||
|
let git: HistoryStore
|
||||||
|
let isEnabled: Bool
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var email = ""
|
||||||
|
@FocusState private var focused: Field?
|
||||||
|
|
||||||
|
private enum Field: Hashable {
|
||||||
|
case name
|
||||||
|
case email
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often an open popover re-reads the config file. Slow enough to be free, fast enough that a
|
||||||
|
/// terminal `git config user.email …` shows up while the user is still looking at the popover.
|
||||||
|
private static let pollInterval: Duration = .seconds(2)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text("Commit Identity")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name)
|
||||||
|
field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email)
|
||||||
|
|
||||||
|
if let failure = git.identityFailure {
|
||||||
|
Text(failure.message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
// The first read, then the courtesy poll. Cancellation is the view's disappearance, which
|
||||||
|
// is the popover closing.
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await git.refreshIdentity()
|
||||||
|
try? await Task.sleep(for: Self.pollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
name = git.identityName
|
||||||
|
email = git.identityEmail
|
||||||
|
}
|
||||||
|
.onChange(of: git.identityName) { _, value in
|
||||||
|
guard focused != .name else { return }
|
||||||
|
name = value
|
||||||
|
}
|
||||||
|
.onChange(of: git.identityEmail) { _, value in
|
||||||
|
guard focused != .email else { return }
|
||||||
|
email = value
|
||||||
|
}
|
||||||
|
// A dismissal is a commit like any other click-away — `BoardRenameField`'s rule, and the same
|
||||||
|
// idempotence makes the overlap harmless.
|
||||||
|
.onDisappear { commit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func field(
|
||||||
|
_ label: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
placeholder: String,
|
||||||
|
tag: Field
|
||||||
|
) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 44, alignment: .leading)
|
||||||
|
TextField(placeholder, text: text)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1)
|
||||||
|
.focused($focused, equals: tag)
|
||||||
|
.onSubmit { commit() }
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.accessibilityLabel("Commit \(label.lowercased())")
|
||||||
|
}
|
||||||
|
.onChange(of: focused) { previous, _ in
|
||||||
|
// Focus leaving *this* field is this field's commit — the inline editors' exit, applied
|
||||||
|
// to a form where Tab moves between two of them.
|
||||||
|
guard previous == tag else { return }
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes both fields, and only when one of them differs from what the file says — an unchanged
|
||||||
|
/// value must not rewrite `.git/config` every time the popover closes.
|
||||||
|
private func commit() {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
guard name != git.identityName || email != git.identityEmail else { return }
|
||||||
|
Task { await git.writeIdentity(name: name, email: email) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,7 +250,7 @@ struct BoardInfoView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Git")
|
sectionHeader("Git")
|
||||||
if let git {
|
if let git {
|
||||||
BoardGitBranchLine(git: git)
|
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(inset)
|
.padding(inset)
|
||||||
@@ -368,8 +368,10 @@ enum BoardGitSection: Equatable, CaseIterable {
|
|||||||
/// Pro, repo-nested: the honest explanation, no action.
|
/// Pro, repo-nested: the honest explanation, no action.
|
||||||
case repoNested
|
case repoNested
|
||||||
|
|
||||||
/// Pro, git mode: the read-only branch/source line. Branch switching and creation, the commit
|
/// Pro, git mode: the branch/source line with switching and creation, the abnormal-state
|
||||||
/// identity fields and the remote controls are later cards — nothing here is a control.
|
/// explanation when the surface is held, and the commit-identity fields (`BoardGitControls`).
|
||||||
|
/// The remote half — tracking, Pull/Push, push-on-commit, authentication — is 07-sync-collab.md's
|
||||||
|
/// own card and joins this same posture.
|
||||||
case branch
|
case branch
|
||||||
|
|
||||||
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
||||||
@@ -437,31 +439,6 @@ private struct BoardGitNestedNote: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The branch/source display** (03-board-ui.md ▸ Board popover) — read-only, and deliberately the
|
|
||||||
/// whole of the git-mode section for now: switching, creation, identity and remotes are each their
|
|
||||||
/// own card, and a control shown before it works is worse than one that isn't there yet.
|
|
||||||
///
|
|
||||||
/// The name is read when the popover appears rather than at session composition, so the open path
|
|
||||||
/// never waits on libgit2 (`HistoryStore.branch`).
|
|
||||||
private struct BoardGitBranchLine: View {
|
|
||||||
|
|
||||||
let git: HistoryStore
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "arrow.triangle.branch")
|
|
||||||
.imageScale(.small)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(git.branch ?? "…")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(git.branch == nil ? .secondary : .primary)
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: .combine)
|
|
||||||
.accessibilityLabel(git.branch.map { "Branch \($0)" } ?? "Reading branch")
|
|
||||||
.task { await git.refreshBranch() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
|
/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
|
||||||
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
|
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
|
||||||
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
|
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -800,10 +800,13 @@ struct GitUndoSessionTests {
|
|||||||
"the session's saves committed nothing while it stood")
|
"the session's saves committed nothing while it stood")
|
||||||
|
|
||||||
provider.settleSessions = { _ in
|
provider.settleSessions = { _ in
|
||||||
// What the gate's Discard branch does: the window reverts its buffer and ends its
|
// What the gate's Discard branch does — **exactly as production does it**: the window
|
||||||
// session, and the folder is handed to the plan to reconcile against the working tree.
|
// reverts its buffer and ends its session, and the card's *folder name* (its id, which is
|
||||||
|
// what `AppModel`'s gate hands over) goes to the plan to reconcile against the working
|
||||||
|
// tree. Passing the `<lane>/<card>` path here instead is what once made this test pass
|
||||||
|
// over a rule that did not work at all — see `discardReconcilesACardIdentifiedByName`.
|
||||||
committer.endEditSession(token)
|
committer.endEditSession(token)
|
||||||
provider.noteDiscarded(cardFolderPath: "\(Ident.lane1)/\(Ident.card1)")
|
provider.noteDiscarded(cardFolderName: Ident.card1)
|
||||||
return .proceed
|
return .proceed
|
||||||
}
|
}
|
||||||
await provider.cross(.undo)
|
await provider.cross(.undo)
|
||||||
@@ -812,6 +815,60 @@ struct GitUndoSessionTests {
|
|||||||
"the discarded save is gone and the restore landed, in one pass")
|
"the discarded save is gone and the restore landed, in one pass")
|
||||||
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "on a settled tree")
|
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "on a settled tree")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The regression.** `AppModel`'s settle gate reports a discarded session by
|
||||||
|
/// `CardWindowRef.cardID` — a folder *name* — and the plan matched it as a board-root-relative
|
||||||
|
/// path prefix. Every live card is `<lane>/<card>`, so the match never fired on any board: Discard
|
||||||
|
/// reverted the in-memory buffer and left the uncommitted on-disk saves exactly where they were,
|
||||||
|
/// which then rode into the next commit — the one outcome 06 ▸ Rules ▸ Undo restore vs open Edit
|
||||||
|
/// sessions singles out ("a surviving dirty buffer's next debounced save would write pre-undo text
|
||||||
|
/// over the restored card — a ⌘Z that visibly doesn't happen").
|
||||||
|
///
|
||||||
|
/// The fix is one shared resolver (`GitRestoreOperation.folderPaths(named:at:)`), so this asserts
|
||||||
|
/// the property the resolver exists for: a card nested under a lane, named only by its id.
|
||||||
|
///
|
||||||
|
/// **What it takes to see the bug.** The restore's own diff already rewrites the session card's
|
||||||
|
/// `index.md` — that is why the gate appeared at all — so a test that only checks the body is
|
||||||
|
/// green either way. What reconciliation alone can reach is the session's uncommitted state the
|
||||||
|
/// diff *cannot* name: a file the session created, present in neither HEAD nor the target, which
|
||||||
|
/// the plan can only learn about by walking the folder on disk. That is what this leaves behind
|
||||||
|
/// and then looks for.
|
||||||
|
@Test("Discard reconciles a card identified by folder name alone, however deep it is nested")
|
||||||
|
func discardReconcilesACardIdentifiedByName() async throws {
|
||||||
|
let (fixture, git, _) = try await makeGitBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let committer = try quickCommitter(git)
|
||||||
|
let provider = await makeProvider(fixture, git, committer: committer)
|
||||||
|
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
||||||
|
committer.noteReloadLanded(sawForeignChange: true)
|
||||||
|
await commitAndSettle(committer, provider)
|
||||||
|
|
||||||
|
// The open session's uncommitted work, staged around and committed by nothing: a crash-safe
|
||||||
|
// body save, and a file the session added beside it (05-card-window.md's attachments land in
|
||||||
|
// the card's own folder) — which no commit anywhere has ever seen.
|
||||||
|
let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
let token = UUID()
|
||||||
|
committer.beginEditSession(token) { cardFolder }
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed", body: "Half-typed."))
|
||||||
|
let stray = "\(Ident.lane1)/\(Ident.card1)/attachments/sketch.txt"
|
||||||
|
try fixture.file(stray, Data("dropped mid-session".utf8))
|
||||||
|
|
||||||
|
provider.settleSessions = { _ in
|
||||||
|
committer.endEditSession(token)
|
||||||
|
// The bare id — never the path. This is the whole regression.
|
||||||
|
provider.noteDiscarded(cardFolderName: Ident.card1)
|
||||||
|
return .proceed
|
||||||
|
}
|
||||||
|
await provider.cross(.undo)
|
||||||
|
|
||||||
|
#expect(!fixture.exists(stray),
|
||||||
|
"the discarded session's uncommitted file is gone from disk, not merely from a buffer")
|
||||||
|
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First",
|
||||||
|
"and the restore landed over the body")
|
||||||
|
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty,
|
||||||
|
"on a settled tree — nothing is left for the next flush to sweep into a commit")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The provider binding
|
// MARK: - The provider binding
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it.
|
- **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it.
|
||||||
|
|
||||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board shows its current branch, read-only. The read-only lock disables the surface without closing it.
|
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it.
|
||||||
|
|
||||||
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
||||||
|
|
||||||
@@ -59,12 +59,13 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
||||||
|
|
||||||
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **Most of that is built** — see "Git integration", "Auto-commit" and "Undo as forward commits" below — and branches, remotes and push/pull are pro-m1 and pro-m2's remaining work; the free tier keeps the native undo stack over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
|
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **Most of that is built** — see "Git integration", "Auto-commit", "Undo as forward commits" and "Branches and commit identity" below — and remotes and push/pull are pro-m2's remaining work; the free tier keeps the native undo stack over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
|
||||||
|
|
||||||
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins.
|
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins.
|
||||||
|
|
||||||
- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External <[email protected]>` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: branch switching, `.gitignore` seeding, and remotes.
|
- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External <[email protected]>` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: `.gitignore` seeding and remotes.
|
||||||
- **Undo as forward commits (Lanework Pro)** — on a git board ⌘Z and ⇧⌘Z stop being an in-memory stack and become the commit trail itself. There is no stored stack anywhere: **the stack *is* HEAD's first-parent ancestry**, re-read from the repository, so it survives relaunch for free and nothing beside the repo can ever drift from it. **A restore is a new commit, never a rewind** — no reset, no force, no rewritten history: ⌘Z materializes the earlier state and commits it as "Undo: Move card 'Fix login' to Doing", ⇧⌘Z as "Redo: …", and every commit you have ever made stays exactly where it was, inspectable in any git client. Only the *difference* is written, so a card you happen to be editing that the change never touched is simply left alone. Because the app is not the only writer, the stack re-reads HEAD before every keystroke: an agent that committed its own work in the last twenty minutes becomes the top of the stack, so ⌘Z steps back exactly one commit and can never silently swallow somebody else's session — and any commit arriving from anywhere clears redo, the classic rule. The app's own repairs are **transparent**: a heal commit is never a step and is never reverted by one, so a ⌘Z run walks past it instead of fighting the healer. Anything still pending commits *before* the restore does, so both versions of what you undid exist in the trail. When the change would land on a card you have open in Edit or Raw Source, the restore stops and asks — **Save All**, **Discard**, or **Cancel** — rather than silently committing text you hadn't saved or quietly writing it back a moment later; a raw buffer that won't validate cancels the whole thing and puts you in front of the window that refused. Undo is board-local, disabled while an outside-the-app merge or rebase has the repository paused, and absent altogether on boards the app manages no git for — where the Edit menu's rows and the toolbar's twins simply dim. Adding git to an open board turns it on there and then.
|
- **Undo as forward commits (Lanework Pro)** — on a git board ⌘Z and ⇧⌘Z stop being an in-memory stack and become the commit trail itself. There is no stored stack anywhere: **the stack *is* HEAD's first-parent ancestry**, re-read from the repository, so it survives relaunch for free and nothing beside the repo can ever drift from it. **A restore is a new commit, never a rewind** — no reset, no force, no rewritten history: ⌘Z materializes the earlier state and commits it as "Undo: Move card 'Fix login' to Doing", ⇧⌘Z as "Redo: …", and every commit you have ever made stays exactly where it was, inspectable in any git client. Only the *difference* is written, so a card you happen to be editing that the change never touched is simply left alone. Because the app is not the only writer, the stack re-reads HEAD before every keystroke: an agent that committed its own work in the last twenty minutes becomes the top of the stack, so ⌘Z steps back exactly one commit and can never silently swallow somebody else's session — and any commit arriving from anywhere clears redo, the classic rule. The app's own repairs are **transparent**: a heal commit is never a step and is never reverted by one, so a ⌘Z run walks past it instead of fighting the healer. Anything still pending commits *before* the restore does, so both versions of what you undid exist in the trail. When the change would land on a card you have open in Edit or Raw Source, the restore stops and asks — **Save All**, **Discard**, or **Cancel** — rather than silently committing text you hadn't saved or quietly writing it back a moment later; a raw buffer that won't validate cancels the whole thing and puts you in front of the window that refused. Undo is board-local, disabled while an outside-the-app merge or rebase has the repository paused, and absent altogether on boards the app manages no git for — where the Edit menu's rows and the toolbar's twins simply dim. Adding git to an open board turns it on there and then.
|
||||||
|
- **Branches and commit identity (Lanework Pro)** — the board popover's git section is where a git board's branches live: the current branch is itself the picker, and beside the local branches it offers create-and-switch, which starts the new branch at the commit you are on. **Switching is never silent.** If any card window is holding unsaved keystrokes, an open Edit session whose crash-safe saves no commit has yet, or an open raw-source buffer, the switch stops and asks — **Save All**, **Discard**, or **Cancel** — for *every* open window, not just the ones the checkout would touch, because an unapplied raw buffer would otherwise write the whole pre-switch file onto the new branch's card later on. Save All ends each session with its normal commit and applies each raw buffer; a buffer that won't validate cancels the whole switch and puts you in front of the window that refused, nothing half-switched. Discard puts both the buffers and their uncommitted on-disk saves back to the last commit. With the sessions settled the pending auto-commit flushes onto the branch you are **leaving**, so the checkout runs on a genuinely settled tree and cannot fail dirty — and the checkout itself is git's *safe* one, never a force: work the app somehow didn't settle refuses the switch rather than being overwritten. The switch is bracketed like every wholesale operation — the watcher suspended, one full reload at the end, an in-progress row saying "Switching to 'main'…" — and if that final reload fails, the board locks read-only until a reload succeeds rather than letting you edit a snapshot of the branch you just left. Undo and redo do not survive the switch: the stack is discarded and reseeded from the new branch's own history, with redo empty, because replaying a restore from the old branch onto the new one would be wrong. When another program holds the repository's index, the switch waits and retries quietly, the row changing to say it is waiting for another writer's git lock and eventually naming the lock file — never a dialog, and the lock is never removed, because it isn't the app's. **An interrupted switch cleans up after itself**: before touching the repository the app writes down what it is about to do — in its own state, never in your board and never inside `.git` — so a crash or an unplugged volume mid-switch is recognized at the next open, rolled back to the state you were in, and announced ("A branch switch was interrupted — the previous state is restored"); an unfinished merge or rebase that *isn't* the app's is still left strictly alone. Beneath the branches sit the **commit-identity** fields: what you type there is written to the repository's own `.git/config`, so the setting is the file — portable to any git client, per board, and what the next commit is signed with. Leave a field empty and it shows the derived default (your account name, and `you@yourmac`) as a placeholder rather than filling it in, because a value the app wrote there would quietly outrank your own global git config for that board. A config edited from outside while the popover is open refreshes the fields you aren't typing in and leaves the one you are alone.
|
||||||
- **A card's History (Lanework Pro)** — the card window's sidebar gains a read-only **History** section on git boards: every commit that touched that card, newest first, each row its own semantic subject over a relative date and the author who made it — so an agent's work and your own read as one story ("Move card 'Fix login' to Doing · 2 days ago · Claude"). It follows the card by identity rather than by path, so moving between lanes — or into the trash and back — keeps one continuous trail. Read-only in this version: restoring a single old version stays a git-client job. The section is simply absent on boards without app-managed git and throughout the free tier — no placeholder, no greyed-out promise.
|
- **A card's History (Lanework Pro)** — the card window's sidebar gains a read-only **History** section on git boards: every commit that touched that card, newest first, each row its own semantic subject over a relative date and the author who made it — so an agent's work and your own read as one story ("Move card 'Fix login' to Doing · 2 days ago · Claude"). It follows the card by identity rather than by path, so moving between lanes — or into the trash and back — keeps one continuous trail. Read-only in this version: restoring a single old version stays a git-client job. The section is simply absent on boards without app-managed git and throughout the free tier — no placeholder, no greyed-out promise.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
Reference in New Issue
Block a user