Files
lanework/KanbanTests/BranchSwitchTests.swift
T
rzen 1f7d84bf64 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
2026-07-31 16:41:14 -04:00

1108 lines
48 KiB
Swift

import Foundation
import SwiftGitX
import Testing
@testable import Kanban
/// **Branch switching, create-and-switch, and the popover's git surface** (06-history-undo.md ▸ Branch
/// switching; ▸ Rules ▸ Abnormal repo states; ▸ Interaction with external writers; 03-board-ui.md
/// ▸ Board popover).
///
/// Every repository here is a **real** one, built through the app's own add-git over bundled libgit2,
/// and every claim about HEAD, the trail, or the working tree is read back through libgit2 or off
/// disk rather than through the code that made it. Nothing shells out to `git` (`AutoCommitTests`'
/// rule, kept).
// MARK: - Fixtures
private func plain(order: String, title: String, body: String = "Body.") -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
---
\(body)
"""
}
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, plain(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First"))
return fixture
}
/// A board with a repository and a root commit — the state a board is in a moment after add-git.
@MainActor
private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore) {
let fixture = try makeBoard()
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger()))
#expect(await git.addGit())
await git.refreshBranch()
return (fixture, git)
}
@MainActor
private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter {
let committer = try #require(git.committer)
committer.debounceInterval = .milliseconds(20)
committer.lockRetryDelay = .milliseconds(5)
committer.holdRecheckInterval = .milliseconds(20)
return committer
}
/// A switcher wired the way `AppModel.wireBranchSwitching` wires one, minus the seams that need
/// windows, a store, or a banner strip — each of those gets its own suite below.
@MainActor
private func makeSwitcher(_ git: HistoryStore) throws -> GitBranchSwitcher {
let switcher = try #require(git.switcher)
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() }
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
switcher.lockRetryDelay = .milliseconds(1)
switcher.lockWaitInterval = .milliseconds(5)
switcher.lockPathNamingDelay = .milliseconds(10)
switcher.lockWaitLimit = .milliseconds(60)
return switcher
}
/// A settleable session a test drives by hand — `GitUndoTests`' own, which this suite needs for the
/// other caller of the same gate.
@MainActor
private final class FakeSession {
var isSettled = false
var saveSucceeds = true
var saves = 0
var discards = 0
func descriptor(id: String, folder: String) -> SettleableSession {
SettleableSession(
id: id,
cardFolderName: folder,
needsSettling: { [self] in !isSettled },
saveAll: { [self] in
saves += 1
guard saveSucceeds else { return false }
isSettled = true
return true
},
discard: { [self] in
discards += 1
isSettled = true
}
)
}
}
// MARK: Reading the repository back
private func headOID(at boardRoot: URL) -> String? {
GitHistoryWalk.headOID(at: boardRoot)
}
private func subjects(at boardRoot: URL, limit: Int = 32) throws -> [String] {
let repository = try Repository.open(at: boardRoot)
guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
var found: [String] = []
var current: Commit? = tip
while let commit = current, found.count < limit {
found.append(commit.summary)
current = (try? commit.parents)?.first
}
return found
}
/// The tip of a named branch, without checking it out — how "the flush landed on the branch it was
/// made on" is asserted from the other branch.
private func branchTipSubject(_ branch: String, at boardRoot: URL) throws -> String? {
let repository = try Repository.open(at: boardRoot)
guard let reference = try? repository.branch.get(named: branch) else { return nil }
return (reference.target as? Commit)?.summary
}
// MARK: - The repository half
@Suite("Branch operations ▸ the repository half")
struct GitBranchOperationTests {
@Test("A fresh repository lists exactly the branch its root commit landed on")
@MainActor
func listsLocalBranches() async throws {
let (fixture, _) = try await makeGitBoard()
defer { fixture.tearDown() }
#expect(GitBranchOperation.localBranches(at: fixture.root) == ["main"])
}
@Test("Create-and-switch adds a branch at HEAD and moves HEAD onto it")
@MainActor
func createsAndSwitches() async throws {
let (fixture, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let before = headOID(at: fixture.root)
let outcome = GitBranchOperation.createAndSwitch("redesign", at: fixture.root)
#expect(outcome == .switched("redesign"))
#expect(GitRepository.branchName(at: fixture.root) == "redesign")
#expect(GitBranchOperation.localBranches(at: fixture.root) == ["main", "redesign"])
// Created *at* HEAD: the two branches are the same commit, which is why the tree cannot have
// changed and why the checkout that follows it is a no-op.
#expect(headOID(at: fixture.root) == before)
}
@Test("Switching materializes the branch's tree — a card added on one branch is gone on the other")
@MainActor
func checkoutMaterializesTheTree() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
await committer.flushNow()
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
#expect(GitRepository.branchName(at: fixture.root) == "main")
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "the card belongs to the other branch")
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
// And back again: nothing was lost, the branch still has it.
#expect(GitBranchOperation.checkout("redesign", at: fixture.root) == .switched("redesign"))
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
}
/// The safe strategy's whole point, and the reason nothing in `GitBranchOperation` passes
/// `GIT_CHECKOUT_FORCE`: a working tree carrying changes the checkout would overwrite refuses the
/// checkout wholesale rather than losing them.
@Test("A conflicting uncommitted change refuses the checkout and leaves every byte where it was")
@MainActor
func safeCheckoutRefusesRatherThanOverwrite() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
// Two branches that disagree about one file.
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed on redesign"))
await committer.flushNow()
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
// An uncommitted edit to exactly that file — the state the settle-and-flush steps exist to
// make impossible, reproduced here to prove what happens if it ever is not.
let dirty = plain(order: "1024", title: "Typed but never saved to history")
try fixture.item("\(Ident.lane1)/\(Ident.card1)", dirty)
let outcome = GitBranchOperation.checkout("redesign", at: fixture.root)
guard case .failed = outcome else {
Issue.record("a conflicting checkout must refuse, not overwrite: \(outcome)")
return
}
#expect(GitRepository.branchName(at: fixture.root) == "main", "the tree is left as it was")
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == dirty)
}
@Test("A held index.lock reads as contention, never as a failure — and names the lock's path")
@MainActor
func aHeldIndexIsContention() async throws {
let (fixture, _) = try await makeGitBoard()
defer { fixture.tearDown() }
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
try fixture.file(".git/index.lock", Data())
let outcome = GitBranchOperation.checkout("main", at: fixture.root)
guard case let .locked(path) = outcome else {
Issue.record("a held index must read as .locked: \(outcome)")
return
}
#expect(path.hasSuffix("index.lock"))
#expect(GitRepository.branchName(at: fixture.root) == "redesign", "nothing moved")
}
@Test("A paused repository holds the switch — the app never writes in a state it didn't create")
@MainActor
func aPausedRepositoryHolds() async throws {
let (fixture, _) = try await makeGitBoard()
defer { fixture.tearDown() }
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
let head = try #require(headOID(at: fixture.root))
try fixture.file(".git/MERGE_HEAD", Data("\(head)\n".utf8))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .held(.merge))
#expect(GitRepository.branchName(at: fixture.root) == "redesign")
}
@Test("An invalid name, a duplicate name, and a branch that isn't there each fail cleanly")
@MainActor
func namesAreValidated() async throws {
let (fixture, _) = try await makeGitBoard()
defer { fixture.tearDown() }
#expect(!GitBranchOperation.isValidBranchName("has spaces"))
#expect(!GitBranchOperation.isValidBranchName(""))
#expect(GitBranchOperation.isValidBranchName("feature/redesign"))
for name in ["has spaces", "main"] {
guard case .failed = GitBranchOperation.createAndSwitch(name, at: fixture.root) else {
Issue.record("'\(name)' must be refused")
return
}
}
guard case .failed = GitBranchOperation.checkout("nonexistent", at: fixture.root) else {
Issue.record("a branch that does not exist must fail cleanly")
return
}
#expect(GitRepository.branchName(at: fixture.root) == "main")
}
@Test("Create-and-switch on an unborn HEAD moves the symbolic ref and creates nothing")
func createOnAnUnbornHead() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// `git init` with no commit: exactly what add-git leaves on an empty folder, and what a
// cloned-but-empty repository is.
_ = try Repository.create(at: fixture.root)
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
#expect(GitBranchOperation.createAndSwitch("trunk", at: fixture.root) == .switched("trunk"))
#expect(GitRepository.branchName(at: fixture.root) == "trunk")
#expect(GitBranchOperation.localBranches(at: fixture.root).isEmpty, "an unborn HEAD has no branch yet")
}
}
// MARK: - The sequence
@Suite("Branch switching ▸ the sequence")
struct BranchSwitchSequenceTests {
/// The card's first done-when criterion: "Switching with an open unsaved Edit session presents the
/// save-or-discard step and never proceeds silently."
@Test("An open unsaved session is always asked about, and Cancel keeps the branch and the session")
@MainActor
func cancelKeepsEverything() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
let session = FakeSession()
var asked = 0
switcher.settleSessions = {
let gate = SessionSettleGate(
sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
ask: { asked += 1; return .cancel }
)
return await gate.settleAll()
}
#expect(await switcher.switchTo("redesign") == false)
#expect(asked == 1, "the step is presented — never a silent commit, never a silent abandon")
#expect(GitRepository.branchName(at: fixture.root) == "main")
#expect(session.saves == 0)
#expect(session.discards == 0)
}
@Test("Save All ends the sessions and the switch proceeds")
@MainActor
func saveAllProceeds() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
let session = FakeSession()
switcher.settleSessions = {
let gate = SessionSettleGate(
sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
ask: { .saveAll }
)
return await gate.settleAll()
}
#expect(await switcher.switchTo("redesign"))
#expect(session.saves == 1)
#expect(GitRepository.branchName(at: fixture.root) == "redesign")
}
/// "Since Apply validates, a buffer that fails validation cancels the whole switch with focus on
/// the offending window, nothing half-switched."
@Test("A raw buffer that will not validate cancels the whole switch, focused on the offender")
@MainActor
func aFailingRawBufferCancelsTheSwitch() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
let bad = FakeSession()
bad.saveSucceeds = false
var focused: [String] = []
switcher.settleSessions = {
let gate = SessionSettleGate(
sessions: { [bad.descriptor(id: "card-b", folder: "card-b")] },
ask: { .saveAll },
focus: { focused.append($0) }
)
return await gate.settleAll()
}
#expect(await switcher.switchTo("redesign") == false)
#expect(focused == ["card-b"])
#expect(GitRepository.branchName(at: fixture.root) == "main", "nothing half-switched")
}
/// The gate is over *every* open session, not the ones a diff reaches — the branch-switch
/// narrowing that the undo restore deliberately does not share.
@Test("Every open session is asked about, even one the checkout's diff never touches")
@MainActor
func theGateIsNotNarrowedByADiff() async {
let untouched = FakeSession()
var asked = 0
let gate = SessionSettleGate(
sessions: { [untouched.descriptor(id: "card-z", folder: "card-z")] },
ask: { asked += 1; return .cancel }
)
// The restore's gate, handed paths that miss this card, never asks.
#expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed)
#expect(asked == 0)
// The switch's gate asks about it anyway.
#expect(await gate.settleAll() == .cancelled)
#expect(asked == 1)
}
@Test("A board with nothing settleable is never asked at all")
@MainActor
func nothingToSettleAsksNothing() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
let settled = FakeSession()
settled.isSettled = true
var asked = 0
switcher.settleSessions = {
let gate = SessionSettleGate(
sessions: { [settled.descriptor(id: "card-a", folder: "card-a")] },
ask: { asked += 1; return .cancel }
)
return await gate.settleAll()
}
#expect(await switcher.switchTo("redesign"))
#expect(asked == 0)
}
/// "With sessions settled, the pending auto-commit flushes (flush-before-overwrite) and checkout
/// runs on a truly settled tree" — and the flush lands on the branch the work was done on.
@Test("The pending auto-commit flushes onto the branch being left, before the checkout")
@MainActor
func theFlushLandsOnTheOldBranch() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
// Work on main that no commit has yet.
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Edited on main"))
#expect(try subjects(at: fixture.root) == [GitRepository.initialCommitSubject])
#expect(await switcher.switchTo("redesign"))
#expect(GitRepository.branchName(at: fixture.root) == "redesign")
let mainTip = try branchTipSubject("main", at: fixture.root)
#expect(mainTip != GitRepository.initialCommitSubject, "the pending work committed before the switch")
#expect(mainTip?.contains("Edited on main") == true || mainTip?.isEmpty == false)
// And the switch really moved the tree: redesign never saw that edit.
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("Edited on main") == false)
_ = committer
}
/// The card's second done-when criterion, first half: "A successful switch reseeds undo/redo from
/// the new branch's HEAD."
@Test("The undo stack is reseeded from the new branch's ancestry, with redo empty")
@MainActor
func theStackIsReseeded() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let switcher = try makeSwitcher(git)
// main: one commit. redesign: two.
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
await committer.flushNow()
let provider = GitHistoryProvider(boardRoot: fixture.root)
await provider.reseed()
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
#expect(provider.ancestry.count == 2)
#expect(await switcher.switchTo("main"))
#expect(provider.ancestry.count == 1, "the stack is the new HEAD's first-parent ancestry")
#expect(provider.ancestry.first?.subject == GitRepository.initialCommitSubject)
#expect(provider.redoCommits.isEmpty, "redo starts empty")
}
/// The card's second done-when criterion, second half: "leaves the watcher fully resumed after one
/// reload."
@Test("The switch runs inside exactly one bracket, balanced")
@MainActor
func theSwitchIsBracketedOnce() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
var begins = 0
var ends = 0
var announcements: [String] = []
switcher.runBracketed = { announcement, work in
begins += 1
announcements.append(announcement)
await work()
ends += 1
}
#expect(await switcher.switchTo("redesign"))
#expect(begins == 1)
#expect(ends == 1)
#expect(announcements == ["Switched to branch 'redesign'"])
}
/// "**Discard** reverts buffers and uncommitted saves to HEAD" — the *saves* half, which the
/// branch switch owns because it materializes nothing of its own.
@Test("Discard puts the session's uncommitted on-disk saves back to HEAD before anything commits")
@MainActor
func discardRevertsUncommittedSaves() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
_ = try quickCommitter(git)
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
let committed = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
// The ~700 ms crash-safe save of an open Edit session: on disk, deliberately uncommitted.
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Half-typed"))
let session = FakeSession()
switcher.settleSessions = { [weak switcher] in
let gate = SessionSettleGate(
sessions: {
[SettleableSession(
id: Ident.card1,
cardFolderName: Ident.card1,
needsSettling: { !session.isSettled },
saveAll: { session.saves += 1; return true },
discard: {
// What the card window does: the buffer, and nothing on disk.
session.discards += 1
session.isSettled = true
switcher?.noteDiscarded(cardFolderName: Ident.card1)
}
)]
},
ask: { .discard }
)
return await gate.settleAll()
}
#expect(await switcher.switchTo("redesign"))
#expect(session.discards == 1)
#expect(GitRepository.branchName(at: fixture.root) == "redesign")
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == committed, "the save was reverted")
// And it was reverted *before* the flush, so nothing recorded the text the user discarded.
let everySubject = try subjects(at: fixture.root)
+ (try branchTipSubject("main", at: fixture.root).map { [$0] } ?? [])
#expect(everySubject.allSatisfy { !$0.contains("Half-typed") })
}
/// Create-and-switch keeps the whole sequence — the judgment call `createAndSwitch(to:)` records.
@Test("Create-and-switch runs the same settle step, and lands the new branch at HEAD")
@MainActor
func createAndSwitchKeepsTheSequence() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
let session = FakeSession()
var asked = 0
switcher.settleSessions = {
let gate = SessionSettleGate(
sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
ask: { asked += 1; return .saveAll }
)
return await gate.settleAll()
}
let before = headOID(at: fixture.root)
#expect(await switcher.createAndSwitch(to: "feature/inbox"))
#expect(asked == 1, "the sequence is uniform — a dirty session is settled either way")
#expect(GitRepository.branchName(at: fixture.root) == "feature/inbox")
#expect(headOID(at: fixture.root) == before)
#expect(switcher.branches.contains("feature/inbox"))
}
@Test("A switch refuses while the git surface is held, without asking anybody anything")
@MainActor
func aHeldSurfaceRefusesBeforeTheModal() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
var asked = 0
switcher.settleSessions = { asked += 1; return .proceed }
switcher.isHeld = { true }
#expect(await switcher.switchTo("redesign") == false)
#expect(asked == 0)
#expect(GitRepository.branchName(at: fixture.root) == "main")
}
/// "Contention outlasting the brief retry surfaces as a *waiting* state in the operation's
/// in-progress banner row … a wait that persists implausibly long names the lock path — never an
/// error dialog, never a hammer."
@Test("A persistent lock becomes a waiting state, then names the lock path, then fails cleanly")
@MainActor
func theLockWaitingState() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
try fixture.file(".git/index.lock", Data())
var labels: [String] = []
var ended = 0
switcher.beginProgress = { label in
labels.append(label)
return UUID()
}
switcher.updateProgress = { _, label in labels.append(label) }
switcher.endProgress = { _ in ended += 1 }
var failures: [GitOperationFailure] = []
switcher.reportFailure = { failures.append($0) }
#expect(await switcher.switchTo("redesign") == false)
#expect(labels.first == "Switching to 'redesign'…")
#expect(labels.contains(GitBranchSwitcher.waitingLabel), "the waiting state is the same row, relabelled")
#expect(labels.contains { $0.hasPrefix(GitBranchSwitcher.waitingLabel + " (") },
"an implausibly long wait names the lock path")
#expect(ended == 1, "the row is cleared however the operation ends")
#expect(failures.count == 1)
#expect(failures.first?.message.contains("index.lock") == true)
#expect(GitRepository.branchName(at: fixture.root) == "main", "the tree is left as it was")
}
@Test("A switch to the branch already checked out does nothing")
@MainActor
func switchingToTheCurrentBranch() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
var asked = 0
switcher.settleSessions = { asked += 1; return .proceed }
// The picker never offers it (`BoardGitControls.otherBranches`), and the operation is a no-op
// if one ever arrives: same branch, same tree, nothing to announce.
#expect(await switcher.switchTo("main"))
#expect(GitRepository.branchName(at: fixture.root) == "main")
#expect(asked == 1, "the settle step still runs — the sequence has one shape")
}
}
// MARK: - The own-leftovers stamp
@Suite("Branch switching ▸ the own-leftovers stamp")
struct GitOperationStampTests {
@Test("The recovery decision needs both a stamp and a pause")
func theDecisionMatrix() {
let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: "abc")
#expect(GitOperationRecovery.decide(stamp: nil, pause: nil) == .nothingToDo)
// A pause with no stamp is somebody else's operation — the pause-and-defer stance, unchanged.
#expect(GitOperationRecovery.decide(stamp: nil, pause: .merge) == .nothingToDo)
// A stamp with no pause is the app's own *finished* work.
#expect(GitOperationRecovery.decide(stamp: stamp, pause: nil) == .clearStamp)
#expect(GitOperationRecovery.decide(stamp: stamp, pause: .detachedHead) == .abort(stamp))
}
@Test("The stamp is written before the repository is touched and cleared when the switch is over")
@MainActor
func theStampBracketsTheOperation() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
var stamp: GitOperationStamp?
var writes: [GitOperationStamp?] = []
var stampDuringCheckout: GitOperationStamp?
switcher.readStamp = { stamp }
switcher.writeStamp = { value in
stamp = value
writes.append(value)
}
switcher.runBracketed = { _, work in
stampDuringCheckout = stamp
await work()
}
#expect(await switcher.switchTo("redesign"))
#expect(stampDuringCheckout?.fromBranch == "main")
#expect(stampDuringCheckout?.toBranch == "redesign")
#expect(stampDuringCheckout?.headOID != nil)
#expect(writes.count == 2, "written once, cleared once")
#expect(writes.last ?? nil == nil, "a finished operation leaves nothing for a later open to abort")
#expect(stamp == nil)
}
/// The mechanism itself: a pause state *with* a matching stamp is the app's own leftover, aborted
/// back to the pre-operation state and announced.
@Test("An interrupted switch is aborted at the next open, the previous state restored, the stamp cleared")
@MainActor
func anInterruptedSwitchIsRecovered() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let switcher = try makeSwitcher(git)
// A second branch whose tree differs, so "the previous state is restored" is a claim about
// files rather than only about a ref.
#expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
await committer.flushNow()
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
let mainHead = try #require(headOID(at: fixture.root))
// The shape a crash mid-checkout leaves: a detached HEAD the app is holding for.
let redesignHead = try #require(try {
let repository = try Repository.open(at: fixture.root)
return (try repository.branch.get(named: "redesign").target as? Commit)?.id.hex
}())
try fixture.file(".git/HEAD", Data("\(redesignHead)\n".utf8))
#expect(GitCommitOperation.reading(at: fixture.root).pause == .detachedHead)
var stamp: GitOperationStamp? = GitOperationStamp(
fromBranch: "main",
toBranch: "redesign",
headOID: mainHead
)
switcher.readStamp = { stamp }
switcher.writeStamp = { stamp = $0 }
var recoveries: [String] = []
switcher.reportRecovery = { recoveries.append($0) }
await switcher.recoverInterruptedOperation()
#expect(GitRepository.branchName(at: fixture.root) == "main", "HEAD is back where it started")
#expect(headOID(at: fixture.root) == mainHead)
#expect(stamp == nil, "the stamp is cleared")
#expect(recoveries == ["A branch switch was interrupted — the previous state is restored."])
#expect(GitCommitOperation.reading(at: fixture.root).pause == nil, "the surface is live again")
}
@Test("A pause with no stamp is left strictly alone — the pause-and-defer stance is unchanged")
@MainActor
func aForeignPauseIsNotTouched() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
let head = try #require(headOID(at: fixture.root))
try fixture.file(".git/HEAD", Data("\(head)\n".utf8))
#expect(GitCommitOperation.reading(at: fixture.root).pause == .detachedHead)
var recoveries: [String] = []
switcher.readStamp = { nil }
switcher.writeStamp = { _ in Issue.record("nothing may be written without a stamp to match") }
switcher.reportRecovery = { recoveries.append($0) }
await switcher.recoverInterruptedOperation()
#expect(recoveries.isEmpty)
#expect(GitCommitOperation.reading(at: fixture.root).pause == .detachedHead, "still detached")
}
@Test("A stale stamp over a healthy repository is dropped silently")
@MainActor
func aStaleStampIsDropped() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let switcher = try makeSwitcher(git)
var stamp: GitOperationStamp? = GitOperationStamp(
fromBranch: "main",
toBranch: "redesign",
headOID: headOID(at: fixture.root)
)
switcher.readStamp = { stamp }
switcher.writeStamp = { stamp = $0 }
var recoveries: [String] = []
switcher.reportRecovery = { recoveries.append($0) }
await switcher.recoverInterruptedOperation()
#expect(stamp == nil)
#expect(recoveries.isEmpty, "nothing happened that anybody needs telling about")
}
@Test("The stamp round-trips through the per-board registry file")
@MainActor
func theStampSurvivesTheRegistryFile() throws {
let home = FileManager.default.temporaryDirectory
.appendingPathComponent("BranchStamp-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: home) }
let storage = home.appendingPathComponent("registry.json")
let fixture = try makeBoard()
defer { fixture.tearDown() }
let registry = BoardRegistry(storageURL: storage)
let id = registry.recordOpen(of: fixture.root)
#expect(registry.gitOperationStamp(id: id) == nil)
let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: "abc123")
registry.setGitOperationStamp(id: id, stamp)
// A new registry over the same file is the next launch — which is the only reader that
// matters, since the stamp exists for the case where this process did not survive.
let reloaded = BoardRegistry(storageURL: storage)
#expect(reloaded.record(id: id)?.gitOperationStamp == stamp)
reloaded.setGitOperationStamp(id: id, nil)
#expect(BoardRegistry(storageURL: storage).record(id: id)?.gitOperationStamp == nil)
}
}
// MARK: - The bracket and the lock
@Suite("Branch switching ▸ the bracket and the read-only lock")
struct BranchSwitchBracketTests {
/// The card's third done-when criterion: "A failed post-switch reload locks the board read-only
/// until a successful reload." The lock itself is 02's and already exists; this proves the branch
/// switch reaches it.
@Test("A switch onto a branch this app cannot load locks the board, and a repair clears it")
@MainActor
func aFailedFinalReloadLocksTheBoard() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let switcher = try makeSwitcher(git)
// A branch whose tree carries an `index.md` the loader refuses.
#expect(GitBranchOperation.createAndSwitch("broken", at: fixture.root) == .switched("broken"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", "---\nschema: 1\ntitle: [unclosed\n---\n")
await committer.flushNow()
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
committer.stop()
let store = try BoardStore(rootURL: fixture.root)
var begins = 0
var ends = 0
store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 })
switcher.runBracketed = { [weak store] announcement, work in
guard let store else { return await work() }
try? await store.performWholesale(announcing: announcement) { await work() }
}
let lastGood = store.snapshot
#expect(await switcher.switchTo("broken"))
#expect(begins == 1)
#expect(ends == 1, "the watcher is resumed whichever way the operation went")
// The bracket's owed reload lands and fails: the snapshot on screen describes the branch that
// is no longer checked out.
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
#expect(store.readOnlyLock == .bracketedReloadFailed)
#expect(store.isReadOnly)
#expect(store.snapshot == lastGood)
// A repair — here, switching back — and the next successful reload clears both.
#expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main"))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.readOnlyLock == nil)
#expect(store.reloadFailure == nil)
}
@Test("The in-progress row is relabelled in place, keeping its id")
@MainActor
func theInProgressRowIsRelabelledNotReplaced() {
let center = BannerCenter()
let id = center.beginOperation(label: "Switching to 'main'…")
center.updateOperation(id, label: GitBranchSwitcher.waitingLabel)
#expect(center.operations.count == 1, "the operation has not restarted — it is explaining itself")
#expect(center.operations.first?.id == id)
#expect(center.operations.first?.label == GitBranchSwitcher.waitingLabel)
center.updateOperation(UUID(), label: "nobody")
#expect(center.operations.first?.label == GitBranchSwitcher.waitingLabel, "an unknown id is a no-op")
center.endOperation(id)
#expect(center.operations.isEmpty)
}
}
// MARK: - The popover's git section
@Suite("Board popover ▸ the git-mode surface")
struct BoardGitBranchSurfaceTests {
@Test("A live repository shows its branch and accepts the controls")
func aLiveSurface() {
let surface = BoardGitBranchSurface.resolve(
branch: "main",
pause: nil,
isSwitching: false,
isWritable: true
)
#expect(surface.branchLabel == "main")
#expect(!surface.isReadingBranch)
#expect(surface.pauseExplanation == nil)
#expect(surface.controlsEnabled)
#expect(surface.accessibilityLabel == "Branch main")
}
@Test("A held repository names the state plainly and disables the branch controls")
func aPausedSurface() {
for pause in GitRepositoryPause.allCases {
let surface = BoardGitBranchSurface.resolve(
branch: "main",
pause: pause,
isSwitching: false,
isWritable: true
)
#expect(surface.pauseExplanation == pause.explanation)
#expect(!surface.controlsEnabled)
}
// The two sentences the design asks for: what the repository is doing, and whose job it is.
#expect(GitRepositoryPause.detachedHead.explanation
== "HEAD is detached — commits would belong to no branch")
#expect(GitRepositoryPause.merge.explanation == "a merge is in progress")
#expect(BoardGitBranchSurface.pauseCaption.contains("leaves the repository untouched"))
}
@Test("A detached HEAD's label is the short hash the branch reader already answers with")
func aDetachedSurfaceShowsTheHash() {
let surface = BoardGitBranchSurface.resolve(
branch: "a1b2c3d",
pause: .detachedHead,
isSwitching: false,
isWritable: true
)
#expect(surface.branchLabel == "a1b2c3d")
#expect(!surface.controlsEnabled)
}
@Test("The read-only lock and a switch in flight each disable the controls")
func lockedAndBusySurfaces() {
#expect(!BoardGitBranchSurface.resolve(
branch: "main", pause: nil, isSwitching: false, isWritable: false
).controlsEnabled)
#expect(!BoardGitBranchSurface.resolve(
branch: "main", pause: nil, isSwitching: true, isWritable: true
).controlsEnabled)
}
@Test("Before the first read the line is a placeholder, not a guess at a branch name")
func theReadingSurface() {
let surface = BoardGitBranchSurface.resolve(
branch: nil,
pause: nil,
isSwitching: false,
isWritable: true
)
#expect(surface.branchLabel == BoardGitBranchSurface.placeholder)
#expect(surface.isReadingBranch)
#expect(surface.accessibilityLabel == "Reading branch")
}
/// The section's posture matrix is `BoardGitSectionTests`' (unchanged by this card); what is new
/// is that git mode now carries controls rather than a read-only line.
@Test("Git mode still resolves to the branch section — now the one with controls in it")
func gitModeResolvesToTheBranchSection() {
#expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch)
}
}
// MARK: - Commit identity
@Suite("Board popover ▸ the commit-identity fields")
struct GitIdentityWriteTests {
@Test("Writing into an empty config creates the section, name before email")
func writesIntoAnEmptyConfig() {
let written = GitConfigFile.applying(name: "Ada Lovelace", email: "[email protected]", to: "")
#expect(written == "[user]\n\tname = Ada Lovelace\n\temail = [email protected]\n")
let read = GitConfigFile.identity(inConfigText: written)
#expect(read.name == "Ada Lovelace")
#expect(read.email == "[email protected]")
}
@Test("An existing value is replaced in place and everything else survives verbatim")
func replacesInPlace() {
let original = """
[core]
\trepositoryformatversion = 0
\tbare = false
[user]
\tname = Old Name
\tsigningkey = ABC123
[remote "origin"]
\turl = [email protected]:board.git
"""
let written = GitConfigFile.applying(name: "New Name", email: "[email protected]", to: original)
#expect(written.contains("\tname = New Name"))
#expect(written.contains("\temail = [email protected]"))
#expect(!written.contains("Old Name"))
#expect(written.contains("\tsigningkey = ABC123"), "a key this app has no opinion about survives")
#expect(written.contains("[remote \"origin\"]"))
#expect(written.contains("\turl = [email protected]:board.git"))
#expect(written.contains("\tbare = false"))
}
@Test("An empty field clears its key, and clearing both removes the section entirely")
func emptyClearsTheKey() {
let both = "[user]\n\tname = Ada\n\temail = [email protected]\n"
let nameOnly = GitConfigFile.applying(name: "Ada", email: "", to: both)
#expect(nameOnly.contains("\tname = Ada"))
#expect(!nameOnly.contains("email"))
#expect(GitConfigFile.identity(inConfigText: nameOnly).email == nil)
let cleared = GitConfigFile.applying(name: "", email: nil, to: both)
#expect(!cleared.contains("[user]"), "a config indistinguishable from one nobody edited")
#expect(GitConfigFile.identity(inConfigText: cleared) == (nil, nil))
// And with company: the neighbours stay.
let withCore = GitConfigFile.applying(
name: "",
email: "",
to: "[core]\n\tbare = false\n[user]\n\tname = Ada\n"
)
#expect(withCore.contains("\tbare = false"))
#expect(!withCore.contains("[user]"))
}
@Test("A subsectioned [user \"work\"] is somebody else's scope and is never edited")
func subsectionsAreLeftAlone() {
let original = "[user \"work\"]\n\tname = Work Ada\n\temail = [email protected]\n"
let written = GitConfigFile.applying(name: "Home Ada", email: "[email protected]", to: original)
#expect(written.contains("[user \"work\"]"))
#expect(written.contains("\tname = Work Ada"), "the subsection's own keys are untouched")
#expect(written.contains("\temail = [email protected]"))
#expect(written.contains("[user]\n\tname = Home Ada"), "the plain section is what the fields write")
// The plain section lands last, which is what keeps the writer and the take-the-last-value
// reader agreeing about a file this app has written.
#expect(GitConfigFile.identity(inConfigText: written).name == "Home Ada")
#expect(GitConfigFile.identity(inConfigText: written).email == "[email protected]")
}
/// The card's fourth done-when criterion: "the identity fields persist to repo-local config" —
/// asserted where it matters, in the authorship of the next commit.
@Test("The fields write repo-local config, and the next commit is authored by them")
@MainActor
func theFieldsReachTheTrail() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
await git.refreshIdentity()
#expect(git.identityName.isEmpty, "a repository the app created carries no identity of ours")
#expect(git.identityEmail.isEmpty)
#expect(git.derivedIdentity != nil, "the placeholder has something to show")
await git.writeIdentity(name: "Ada Lovelace", email: "[email protected]")
#expect(git.identityFailure == nil)
#expect(git.identityName == "Ada Lovelace")
#expect(git.identityEmail == "[email protected]")
// The setting *is* the file: read back off disk, not out of memory.
let config = try String(contentsOf: fixture.root.appendingPathComponent(".git/config"), encoding: .utf8)
#expect(config.contains("[email protected]"))
#expect(GitCommitOperation.userIdentity(at: fixture.root)
== GitIdentity(name: "Ada Lovelace", email: "[email protected]"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
await committer.flushNow()
// **The committer field, not the author.** The change above was made behind the app's back
// — a test writing bytes is a foreign writer — so 06's attribution rules author it
// `Lanework External` and record *this machine's user* as the committer, which is exactly the
// field the popover's fields feed ("The committer field is always the user's identity").
let head = try #require(GitRepository.headCommit(at: fixture.root))
#expect(head.authorName == CommitAttribution.externalIdentity.name)
let recorded = try #require(try {
let repository = try Repository.open(at: fixture.root)
return (try repository.HEAD.target as? Commit)?.committer
}())
#expect(recorded.name == "Ada Lovelace")
#expect(recorded.email == "[email protected]")
}
@Test("Clearing the fields returns the board to the derived default")
@MainActor
func clearingReturnsToTheDerivedDefault() async throws {
let (fixture, git) = try await makeGitBoard()
defer { fixture.tearDown() }
await git.writeIdentity(name: "Ada Lovelace", email: "[email protected]")
await git.writeIdentity(name: "", email: "")
#expect(git.identityName.isEmpty)
#expect(git.identityEmail.isEmpty)
let derived = try #require(git.derivedIdentity)
#expect(GitCommitOperation.userIdentity(at: fixture.root) == derived)
#expect(GitCommitOperation.repoLocalIdentity(at: fixture.root) == (nil, nil))
}
}