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: "ada@example.com", to: "") #expect(written == "[user]\n\tname = Ada Lovelace\n\temail = ada@example.com\n") let read = GitConfigFile.identity(inConfigText: written) #expect(read.name == "Ada Lovelace") #expect(read.email == "ada@example.com") } @Test("A new value appends a fresh section that wins on read — existing lines survive verbatim") func appendsAndWins() { let original = """ [core] \trepositoryformatversion = 0 \tbare = false [user] \tname = Old Name \tsigningkey = ABC123 [remote "origin"] \turl = git@example.com:board.git """ let written = GitConfigFile.applying(name: "New Name", email: "new@example.com", to: original) #expect(written.contains("\tname = New Name")) #expect(written.contains("\temail = new@example.com")) #expect(written.contains("\tname = Old Name"), "sets never edit existing sections — 06's append-only rule") #expect(written.contains("\tsigningkey = ABC123"), "a key this app has no opinion about survives") #expect(written.contains("[remote \"origin\"]")) #expect(written.contains("\turl = git@example.com:board.git")) #expect(written.contains("\tbare = false")) let read = GitConfigFile.identity(inConfigText: written) #expect(read.name == "New Name", "the appended section is last, and reads take the last") #expect(read.email == "new@example.com") } @Test("An empty field clears its key, and clearing both removes the section entirely") func emptyClearsTheKey() { let both = "[user]\n\tname = Ada\n\temail = ada@example.com\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 = ada@work.example\n" let written = GitConfigFile.applying(name: "Home Ada", email: "ada@home.example", to: original) #expect(written.contains("[user \"work\"]")) #expect(written.contains("\tname = Work Ada"), "the subsection's own keys are untouched") #expect(written.contains("\temail = ada@work.example")) #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 == "ada@home.example") } /// 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: "ada@example.com") #expect(git.identityFailure == nil) #expect(git.identityName == "Ada Lovelace") #expect(git.identityEmail == "ada@example.com") // 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("ada@example.com")) #expect(GitCommitOperation.userIdentity(at: fixture.root) == GitIdentity(name: "Ada Lovelace", email: "ada@example.com")) 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 == "ada@example.com") } @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: "ada@example.com") 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)) } }