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:
@@ -800,10 +800,13 @@ struct GitUndoSessionTests {
|
||||
"the session's saves committed nothing while it stood")
|
||||
|
||||
provider.settleSessions = { _ in
|
||||
// What the gate's Discard branch does: the window reverts its buffer and ends its
|
||||
// session, and the folder is handed to the plan to reconcile against the working tree.
|
||||
// What the gate's Discard branch does — **exactly as production does it**: the window
|
||||
// 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)
|
||||
provider.noteDiscarded(cardFolderPath: "\(Ident.lane1)/\(Ident.card1)")
|
||||
provider.noteDiscarded(cardFolderName: Ident.card1)
|
||||
return .proceed
|
||||
}
|
||||
await provider.cross(.undo)
|
||||
@@ -812,6 +815,60 @@ struct GitUndoSessionTests {
|
||||
"the discarded save is gone and the restore landed, in one pass")
|
||||
#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
|
||||
|
||||
Reference in New Issue
Block a user