From 1d97a2931c567e28f68e00f2b0fcd68dd19d00b5 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 8 Aug 2026 10:52:57 -0400 Subject: [PATCH] =?UTF-8?q?The=20git=20surfaces=20leave=20the=20glass=20?= =?UTF-8?q?=E2=80=94=20tab,=20trail,=20branch=20line,=20and=20the=20remote?= =?UTF-8?q?=20pair,=20peeled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of strategy/01-git-excision.md: the popover strip is Info/Theme/Sync, the titlebar widget says the name alone, the card window's History section and its slot go, Board ▸ Pull/Push comes out with the RemoteCommands scaffold, and View ▸ History re-tags from the commit trail to the deferred foreign-change journal. The Sync placeholder re-annotates to the future ops-based sync service. Four git test suites leave with the surfaces they pinned (BoardGitSetup, BoardInfoPopover, BranchSwitch, GitUndo). The git engine still compiles underneath, unreferenced by UI. 2,890 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy --- Kanban/App/BoardWindowHost.swift | 31 +- Kanban/App/CardWindowHost.swift | 39 +- Kanban/App/FutureCommands.swift | 26 +- Kanban/KanbanApp.swift | 16 +- Kanban/UI/Board/BoardGitControls.swift | 390 ----- Kanban/UI/Board/BoardGitSetup.swift | 229 --- Kanban/UI/Board/BoardGitTabView.swift | 348 ----- Kanban/UI/Board/BoardInfoPopover.swift | 219 +-- Kanban/UI/Board/BoardSyncTabView.swift | 20 +- Kanban/UI/Card/CardHistorySection.swift | 191 --- Kanban/UI/Card/CardWindowView.swift | 49 +- KanbanTests/BoardGitSetupTests.swift | 155 -- KanbanTests/BoardInfoPopoverTests.swift | 170 --- KanbanTests/BranchSwitchTests.swift | 1230 --------------- KanbanTests/GitUndoTests.swift | 1517 ------------------- KanbanUITests/AccessibilityAuditTests.swift | 14 +- 16 files changed, 119 insertions(+), 4525 deletions(-) delete mode 100644 Kanban/UI/Board/BoardGitControls.swift delete mode 100644 Kanban/UI/Board/BoardGitSetup.swift delete mode 100644 Kanban/UI/Board/BoardGitTabView.swift delete mode 100644 Kanban/UI/Card/CardHistorySection.swift delete mode 100644 KanbanTests/BoardGitSetupTests.swift delete mode 100644 KanbanTests/BoardInfoPopoverTests.swift delete mode 100644 KanbanTests/BranchSwitchTests.swift delete mode 100644 KanbanTests/GitUndoTests.swift diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 8a37848..fd7cfc7 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -718,38 +718,19 @@ struct BoardWindowHost: View { // card windows share that machinery and have no board to describe. It goes in after the // load rather than at attach because it carries the store; the controller installs it once, // whichever of the two arrives second. - // - // The git state comes from the **session**, which `start()` began a moment ago, rather than - // from the entitlement or the disk: a board's popover must describe the board as it opened - // (12-editions.md ▸ The entitlement, "an open board finishes with the provider it composed"; - // 06-history-undo.md ▸ Rules, mode is an open-time fact). A `nil` session cannot happen on - // this path — `beginSession` precedes `configureWindow` — and reads as a board with no - // repository, which is the harmless direction. - // - // **The tier is no longer passed down** (12 ▸ PIVOT 2026-08-07): git is tier-independent, so - // every one of these surfaces reads the board's mode and nothing else. `BoardSession.tier` - // still exists and is still recorded — it just has no git-facing consumer here. - // - // The settings sheet used to adopt the same fact here, so its two doors could validate on it - // (2026-07-31 → 2026-08-07). The sheet is retired and the popover is the one configuration - // home, so the git state is handed to exactly one surface again — this widget — and the - // mid-session transition 06 sanctions (add-git flipping the mode) re-resolves the Git tab's - // postures under it live, with no second reader to disagree. - let session = appModel.session(for: ref) windowController.installTitlebarAccessory( boardInfoTitlebarAccessory( store: store, recents: appModel.styleRecents, - git: session?.git, presentation: boardInfo ) ) - // The widget above now says the board's name (and, on a git-mode board, its branch) - // itself, so the system title display would only repeat it — the card-window seam - // (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for - // the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched — - // `window.title` keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the - // title bar's own rendering of that string is suppressed. + // The widget above now says the board's name itself, so the system title display would only + // repeat it — the card-window seam (`CardWindowHost.configureWindow`, + // `HostedWindowController.hideTitle`), applied here for the same reason. + // `.navigationTitle(windowTitle)` a few lines up in `body` is untouched — `window.title` + // keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the title bar's own + // rendering of that string is suppressed. // // **After the load, and only after it**, which is why it is not in the loading half above: // this line and the widget it defers to are one exchange, and a loading window that hid its diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 4f5fc20..e2ec4bf 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -257,7 +257,7 @@ final class CardWindowSession: CardSessionFlushing { /// /// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The /// two-column composition inside it is `CardWindowView`'s, and what fills those columns — the title -/// field, Preview/Edit, the sidebar's five sections — arrives card by card underneath a composition +/// field, Preview/Edit, the sidebar's four sections — arrives card by card underneath a composition /// that does not move. The *window-scoped* state those surfaces need lives here, because a window is /// what it is scoped to: the body column's mode (`CardBodyPresentation`) and the raw-source outlet /// (`CardRawSourceSession`), both published through the focus system so the View menu's rows can @@ -288,9 +288,6 @@ struct CardWindowHost: View { /// snapshot the store applies — a cache that died with the view would regenerate every thumbnail /// on every reload (`AttachmentThumbnailCache`). @State private var thumbnails = AttachmentThumbnailCache() - /// This card's commit trail (05-card-window.md ▸ History). Held here for `thumbnails`' reason — - /// it must survive every snapshot — and surfaced to the view only in git mode (`cardHistory`). - @State private var history = CardHistory() /// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not /// flush; cleared by the resolution that lets the close resume. @State private var isClosePending = false @@ -417,31 +414,6 @@ struct CardWindowHost: View { .onDisappear { finish() } } - /// **This card's commit trail, or nothing at all** (05-card-window.md ▸ History). - /// - /// `nil` is the section's absence rule, read from the board's own git state rather than from a - /// flag: a mode other than `git` means a board the app manages no history for, on any tier. The - /// question stopped having a tier half at 12-editions.md ▸ PIVOT 2026-08-07 — every session - /// composes a git state now, so what decides the section is whether *this board's* history is - /// git-backed. The object is held by this host so it survives every snapshot, `thumbnails`' - /// reason exactly. - private var cardHistory: CardHistory? { - guard appModel.session(for: ref.board)?.gitMode == .git else { return nil } - return history - } - - /// What a trail re-read depends on: this card, and the number of commits the board has landed. - /// - /// The count is the committer's own (`GitAutoCommitter.commitCount`), which advances for every - /// commit the app makes — the debounced ones, the launch catch-up, and a restore's. A foreign - /// commit an agent made *itself* moves HEAD without touching it; the trail then refreshes at the - /// next commit or the next open, which is the same freshness bound the popover's branch line has - /// and a great deal cheaper than polling HEAD from a sidebar. - private func historyReloadKey(store: BoardStore) -> String { - let commits = appModel.session(for: ref.board)?.git?.committer?.commitCount ?? 0 - return "\(ref.cardID)#\(commits)" - } - /// **The minimum grows only while the comments pane is beside the body** (05-card-window.md ▸ /// Composition) — which is the whole reason the stacked mount exists, so a narrow display keeps /// the minimum it always had. @@ -475,20 +447,11 @@ struct CardWindowHost: View { comments: session.comments, thumbnails: thumbnails, undo: session.undo, - history: cardHistory, fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id), onToggleTask: { offset, checked in store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked) } ) - // **The trail, re-read when a commit lands** (05 ▸ History). The id is the pair of facts - // the answer depends on: which card this is, and how many commits this board has made — - // so the section refreshes after the app's own commits, after an agent's that the watcher - // committed, and after a ⌘Z's restore, with nothing here knowing what a committer is. - .task(id: historyReloadKey(store: store)) { - guard let cardHistory else { return } - await cardHistory.load(boardRoot: store.rootURL, cardFolderName: ref.cardID) - } // **The listing is the snapshot's, republished** — `Card.attachments`, which the loader // fills from `attachments/`'s top-level files in Finder order. Every write in the // section is bracketed, so the reload that refreshes this arrives by itself and the diff --git a/Kanban/App/FutureCommands.swift b/Kanban/App/FutureCommands.swift index 435875d..668b02c 100644 --- a/Kanban/App/FutureCommands.swift +++ b/Kanban/App/FutureCommands.swift @@ -98,10 +98,12 @@ struct FindSteppingCommands: View { /// did not move, the validation and the action filled in — and the pair also carries the clause that /// joins them, "Edit Body disables while Raw Source is active" (05-card-window.md). /// -// m7-git: History is a plain command that focuses the sidebar's History section, and disables -// outright on mode `none` / repo-nested boards once that section exists (05-card-window.md, -// 07-sync-collab.md). It remains unconditionally disabled here — the sidebar reserves the section's -// place (`CardWindowView.historySlot`) but draws nothing, so there is still no surface to focus. +// View ▸ History used to anticipate the sidebar's git commit-trail section (`CardWindowView`), +// which left with app-managed git (strategy/01-git-excision.md, 2026-08-08). The row is re-tagged +// rather than removed: it now anticipates the foreign-change journal successor (01-git-excision.md +// ▸ Successors — deferred, its design and timing not yet ruled). It stays unconditionally disabled +// here — there is no journal yet to focus — and the sidebar no longer reserves a place for it; the +// row itself is the only surviving reservation of the slot. /// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments /// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits, /// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`). @@ -114,19 +116,3 @@ struct CardViewCommands: View { FutureCommand(title: "History") } } - -// MARK: - Board ▸ Pull / Push - -/// Board ▸ Pull / Push — no default chord, remote-backed boards only (11-command-nexus.md; -/// 07-sync-collab.md: "also Board-menu items"). -/// -// m7-git: menu-bar twins of the board popover's own Pull/Push buttons (07-sync-collab.md). -// Validation will be remote-mode plus 06's abnormal-state pause ("disabled during 06's -// abnormal-state pause ... and on an unresolvable remote" — 11-command-nexus.md). Unconditionally -// disabled here: there is no remote model, no popover twin, and no git mode to validate against yet. -struct RemoteCommands: View { - var body: some View { - FutureCommand(title: "Pull") - FutureCommand(title: "Push") - } -} diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 1f16f23..f1f6c96 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -265,15 +265,17 @@ struct KanbanApp: App { } // The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open - // Card, Rename, Style…, the card moves, the lane moves, the width pair, then the remote - // pair. Its items act on the frontmost board window, which they reach through the focus - // system rather than through the app model — see `BoardCommands.swift`, which also owns - // their validation. + // Card, Rename, Style…, the card moves, the lane moves, the width pair. Its items act on + // the frontmost board window, which they reach through the focus system rather than through + // the app model — see `BoardCommands.swift`, which also owns their validation. // // **Board Settings… came out 2026-08-07** with the sheet it opened (03 ▸ Board settings // sheet, marked retired; the 2026-07-31 popover/sheet split reversed): a board is configured // in its popover, whose keyboard door is File ▸ Board Info ⌘I, so this menu's last divider - // went with the row rather than being left to separate the remote pair from nothing. + // went with the row. + // + // **The Board ▸ Pull/Push row (`RemoteCommands`) came out 2026-08-08** with app-managed git + // itself (strategy/01-git-excision.md): the width pair is now the menu's last row. CommandMenu("Board") { OpenCardCommand() BoardRenameCommand() @@ -287,10 +289,6 @@ struct KanbanApp: App { Divider() LaneWidthCommands() - - Divider() - - RemoteCommands() } CommandGroup(after: .windowList) { diff --git a/Kanban/UI/Board/BoardGitControls.swift b/Kanban/UI/Board/BoardGitControls.swift deleted file mode 100644 index 552593d..0000000 --- a/Kanban/UI/Board/BoardGitControls.swift +++ /dev/null @@ -1,390 +0,0 @@ -import SwiftUI - -// MARK: - The git-mode section's state - -/// **What the popover's git section shows on a git-mode board** — a pure function of four facts, so -/// the surface 06-history-undo.md describes is assertable without a popover on screen. -/// -/// It exists for the same reason `BoardGitSection.resolve` does one level up: the *posture* is the -/// part worth pinning, and the SwiftUI that renders it is not. Three rules live here — -/// -/// - **A paused repository names its state and disables the controls** (06 ▸ Rules ▸ Abnormal repo -/// states: "Undo/Redo and the branch controls disable … the popover's git section names the state -/// plainly … and says resolving it belongs to the tool that created it"). -/// - **A read-only board disables them too** (02-architecture.md ▸ The lock's scope, which names "the -/// popover's git controls" outright). -/// - **A switch in flight disables them**, so a second click cannot start a second checkout. -/// - **An unreadable repository reads as broken, never as still loading** (06 ▸ Rules, the -/// corrupt-`.git` loud failure, ruled 2026-07-31: "Never a silent placeholder discovered only in -/// the popover"). -struct BoardGitBranchSurface: Equatable { - - /// What the branch line reads — the branch name, the short hash on a detached HEAD - /// (`GitRepository.branchName` decides which), the placeholder while the first read is in - /// flight, or `unavailableLabel` when there is no readable repository for it to name. - let branchLabel: String - - /// Whether that label is the placeholder rather than an answer. - let isReadingBranch: Bool - - /// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live. - let pauseExplanation: String? - - /// **Whether the pause is the unopenable repository** — the one pause whose surface is not the - /// pause note: nothing is in progress and no tool is coming to finish it, so the section says - /// its own sentence instead (`unreadableNote`), and the branch line has no answer to wait for. - let isRepositoryUnreadable: Bool - - /// Whether the branch controls accept a click — the switch picker, and the New Branch… reveal - /// behind its divider (`BoardGitControls`), so that a paused repository, a read-only board and a - /// switch in flight close the whole branch affordance by one rule. (The settings sheet's standing - /// Create field resolved this same surface between 2026-07-31 and the 2026-08-07 reversal; the - /// field came back into the menu, the rule never moved.) - let controlsEnabled: Bool - - /// The line the branch display is read as by VoiceOver. - /// - /// The broken case is spelled out rather than left to fall through "Branch \(label)": the label - /// is a *state* there, not a name, and "Branch Unavailable" would read as a branch somebody - /// called Unavailable. - var accessibilityLabel: String { - if isRepositoryUnreadable { return "Branch unavailable" } - return isReadingBranch ? "Reading branch" : "Branch \(branchLabel)" - } - - static let placeholder = "…" - - /// **What the branch line reads when the repository will not open** — an answer, not a - /// placeholder, which is the whole of the ruling's "fails loudly" at this one control: the - /// placeholder means "still reading" and would go on meaning it forever here. - static let unavailableLabel = "Unavailable" - - /// The second half of the paused sentence — 06's "says resolving it belongs to the tool that - /// created it", said in the app's own voice and paired with the promise that makes it safe to - /// wait: the app is not going to touch the repository behind the user's back. - static let pauseCaption = - "Finishing it belongs to the tool that started it; Lanework leaves the repository untouched." - - /// **The popover's own sentence for an unreadable repository** (06 ▸ Rules, the corrupt-`.git` - /// loud failure, ruled 2026-07-31: "with the whole git surface paused … and the popover's git - /// section naming the state"). - /// - /// A sibling of the nested and unverifiable notes (`BoardGitTabView`) and written in their - /// register — one sentence, the state first and the consequence after — rather than the pause - /// note's two lines, because both of *those* lines would be wrong here: nothing is "in - /// progress", and there is no tool whose job it is to finish it. What it keeps from the pause - /// note is the promise that matters most on a repository the app cannot read, in the ruling's - /// own words. - /// - /// It is deliberately **not** the banner's sentence (`BannerCenter.repositoryUnreadableMessage`) - /// re-used: the strip announces a condition to somebody who has not asked, and this answers - /// somebody looking straight at the git section — the register the neighbouring notes set. - static let unreadableNote = - "Lanework can't read this board's git repository, so history is paused; the repository is left untouched." - - static func resolve( - branch: String?, - pause: GitRepositoryPause?, - isSwitching: Bool, - isWritable: Bool - ) -> BoardGitBranchSurface { - // Derived from the pause rather than passed in beside it: the pause *is* how this state is - // carried everywhere else (`GitRepositoryPause.unreadable`, seeded by the detection-time - // probe and refreshed by every later read), so a second parameter would be a second answer - // to one question — and a caller could hold them apart. - let unreadable = pause == .unreadable - return BoardGitBranchSurface( - branchLabel: unreadable ? unavailableLabel : (branch ?? placeholder), - // Never "reading" on an unreadable repository: there is nothing in flight, and the line - // the ruling forbids is exactly the one that says otherwise forever. - isReadingBranch: !unreadable && branch == nil, - pauseExplanation: pause?.explanation, - isRepositoryUnreadable: unreadable, - controlsEnabled: pause == nil && isWritable && !isSwitching - ) - } -} - -// MARK: - The git-mode section - -/// **The popover's git section on a board that has a repository** (03-board-ui.md ▸ Board popover; -/// 06-history-undo.md ▸ Branch switching). -/// -/// **The whole branch affordance**: the branch display with its **switch** picker, the *New Branch…* -/// entry behind that menu's divider and the inline field it reveals, and the pause explanation when -/// the surface is held. -/// -/// **Creation left and came back.** The 2026-07-31 popover/sheet split moved it to the board settings -/// sheet as a standing Create field; the **2026-08-07 reversal** retired that sheet and restored the -/// shape the sheet's own doc comment had described as "the right shape *there*" — an entry inside the -/// switch menu that reveals an inline field. The commit-identity fields came back the same day, to -/// the Git tab a level up (`BoardGitSetup.swift`), which is why they are not here: they are the -/// board's setup, and this is the branch. -/// -/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push and the status badges are -/// 07-sync-collab.md's own cards, and this section is arranged so they join as one more block under -/// the branch controls — nothing here is nested inside anything they would have to be pulled out of, -/// and nothing about the branch controls assumes there is no upstream to show beside them. -struct BoardGitControls: View { - - let git: HistoryStore - - /// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses writes - /// refuses a checkout most of all — it rewrites the tree the lock exists to stop describing. - let isEnabled: Bool - - /// Whether **New Branch…** has been picked and its field is standing open — the reveal, which is - /// the menu entry's whole behaviour (03-board-ui.md ▸ Board popover ▸ Git tab, 2026-08-07). It - /// starts closed on every popover open, because the popover is rebuilt fresh each time - /// (`BoardInfoWidget`), which is exactly the transience the reveal shape is for. - @State private var isCreatingBranch = false - - /// The name being typed, uncommitted. Cleared by a create and by Escape's first press. - @State private var draft = "" - - /// Focus lands in the field the moment it appears, unlike the popover's rename field: this one is - /// opened by a gesture that means "name a branch now", which is the inline editors' case rather - /// than the configuration-surface case (11-command-nexus.md's class **C** distinguishes the two). - @FocusState private var isFieldFocused: Bool - - private var surface: BoardGitBranchSurface { - BoardGitBranchSurface.resolve( - branch: git.branch, - pause: git.committer?.pause, - isSwitching: git.switcher?.isSwitching ?? false, - isWritable: isEnabled - ) - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - branchRow - - if isCreatingBranch { - creationField - } - - // **The unreadable repository names itself in its own sentence** (06 ▸ Rules, the - // corrupt-`.git` loud failure) — checked before the pause note because it *is* a pause, - // and the pause note's second line ("finishing it belongs to the tool that started it") - // would be advice about an operation nobody started. - if surface.isRepositoryUnreadable { - caption(BoardGitBranchSurface.unreadableNote, tone: .primary) - } else if let explanation = surface.pauseExplanation { - pauseNote(explanation) - } - - if let failure = git.switcher?.lastFailure { - caption(failure.message, tone: .red) - } - } - // Every read the section needs, taken when it appears rather than held live: the popover is - // built fresh on each open (`BoardInfoWidget`), and none of these is a fact the board's - // watcher could deliver — `.git` is filtered out of the watch by design. - .task { - await git.refreshBranch() - await git.committer?.refreshPause() - await git.switcher?.refreshBranches() - } - } - - // MARK: The branch line - - /// The branch display and the switch, as one control: the line *is* the picker, which is what - /// makes "branch/source display and switching" one affordance rather than a label with a button - /// beside it. - /// - /// **Two halves under a divider** since the 2026-08-07 reversal: the switch targets above, and - /// **New Branch…** below, which reveals the inline field under this row rather than acting. That - /// is the shape the 2026-07-31 split took creation *out* of — into the settings sheet's standing - /// Create field — and the shape the reversal restored when the sheet retired; the sheet's own doc - /// comment had called it "the right shape *there*", meaning here. - /// - /// **The menu is therefore never empty**, which is what the divider guarantees rather than the - /// content: New Branch… always applies to a board that has a repository. **A single-branch board - /// still opens onto a disabled explanatory row** in the upper half (03-board-ui.md ▸ Board popover - /// ▸ Git tab, ruled 2026-08-06 and built with the tab): an unexplained gap above the divider would - /// read as a menu that lost something, not as a board with one branch. - private var branchRow: some View { - // Resolved once and handed to both halves of the menu builder: the emptiness *is* the - // condition being rendered, so asking twice would be asking the same question of a store - // that could answer differently between the two reads. - let targets = otherBranches - - return HStack(spacing: 6) { - Image(systemName: "arrow.triangle.branch") - .imageScale(.small) - .foregroundStyle(.secondary) - - Menu { - if targets.isEmpty { - // **The single-branch board's row** (03-board-ui.md ▸ Board popover ▸ Git tab, - // ruled 2026-08-06). It teaches the one thing an empty upper half leaves a user - // to guess at: *why* there is nothing to pick — this half holds only the - // **other** local branches, and there are none. (It carried a second teaching - // while creation lived in the settings sheet, standing where "New Branch…" used - // to; the 2026-08-07 reversal put that entry back one line below, so the row's - // second job is done and the sentence is now exactly what it says.) - // - // A bare `Text` inside a `Menu` is AppKit's standard disabled item: greyed, - // unclickable, and read by VoiceOver as disabled text rather than as an - // actionable row — which is exactly the register a sentence explaining an absence - // wants (06 ▸ Rules: teach, never look broken; never a disabled button pretending - // to be a control). - Text("No other branches") - } else { - ForEach(targets, id: \.self) { name in - Button(name) { - Task { await git.switcher?.switchTo(name) } - } - } - } - - Divider() - - // **Creation, revealed rather than performed** — the entry opens the field below the - // row and nothing else, so the act of naming a branch happens in the surface the user - // can see and correct rather than inside a menu that has already closed. - Button("New Branch…") { - isCreatingBranch = true - } - } label: { - Text(surface.branchLabel) - .font(.callout) - .foregroundStyle(surface.isReadingBranch ? .secondary : .primary) - } - .menuStyle(.borderlessButton) - .fixedSize() - .disabled(!surface.controlsEnabled) - .accessibilityLabel(surface.accessibilityLabel) - .accessibilityHint("Switch branches") - - if git.switcher?.isSwitching == true { - ProgressView() - .controlSize(.small) - .accessibilityLabel("Switching branches") - } - } - } - - /// This board's switch targets, off the live switcher — see `switchTargets(branches:current:)` - /// for the rule itself. - private var otherBranches: [String] { - Self.switchTargets(branches: git.switcher?.branches ?? [], current: git.branch) - } - - /// **Every local branch except the one already checked out** — a picker offering the current - /// branch would be offering a no-op, and the switch refuses one anyway - /// (`BranchSwitchSequenceTests`' "a switch to the branch already checked out does nothing"). - /// - /// A pure static seam rather than a computed property alone, for `BoardGitBranchSurface.resolve`'s - /// reason one level up: the *rule* is the part worth pinning (`BranchSwitchTargetTests`) and the - /// `Menu` it fills is SwiftUI. It is also the predicate the disabled "No other branches" row hangs - /// on (03-board-ui.md ▸ Board popover ▸ Git tab, 2026-08-06), which makes "when is that row - /// shown" a question with one testable answer rather than a shape buried in a view builder. - /// - /// A `nil` `current` — the moment before the first branch read answers — passes every branch - /// through rather than none: the list is a set of candidates, and the switch's own gate is what - /// decides whether one can be taken. `nonisolated` because nothing here touches the view: a pure - /// filter over plain values, reachable from a test with no actor to hop to. - /// - /// The repository's own ordering survives untouched — `GitBranchOperation.localBranches` is what - /// libgit2 listed, and a picker that re-sorted it would be inventing an order the repository - /// never had. - nonisolated static func switchTargets(branches: [String], current: String?) -> [String] { - branches.filter { $0 != current } - } - - // MARK: The creation reveal - - /// **What New Branch… reveals** (03-board-ui.md ▸ Board popover ▸ Git tab, restored 2026-08-07): - /// a name field and a Create button, under the branch row, for exactly as long as the user is - /// naming something. It is a detour off the daily face and it looks like one — which is the - /// argument the 2026-07-31 split made *against* it in a sheet, and for it here. - /// - /// **The sequence is not this view's.** `GitBranchSwitcher.createAndSwitch(to:)` runs the - /// identical settle → flush → stamp → switch sequence the picker's switch does — "no at-HEAD fast - /// path" (06-history-undo.md ▸ Branch switching, blessed 2026-07-31) — and `create()` calls - /// exactly that method. - /// - /// **Failures answer where they were asked**: the section's existing switcher caption below is - /// this control's answer too (06 ▸ Rules, the form-anchored rule as the 2026-08-06 create-and- - /// switch overlap settled it — "inline while the asking surface is up, banner once it is gone"). - /// The popover *is* the asking surface now that the sheet is retired, so there is one caption for - /// both halves of the branch affordance and no second slot to keep in sync. - /// - /// No `ProgressView` of its own, for the same reason: `branchRow` already shows the in-flight - /// spinner, and a switch has one state whichever control started it. - private var creationField: some View { - HStack(spacing: 6) { - TextField("New branch name", text: $draft) - .textFieldStyle(.roundedBorder) - .lineLimit(1) - .focused($isFieldFocused) - .onAppear { isFieldFocused = true } - .onSubmit { create() } - // **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar, the - // rename field's rule and the retired sheet section's before it): a dirty field - // abandons its draft and the reveal stays open; an empty one closes the reveal, which - // is the layer above it; a further press then reaches the popover's own dismissal. - .onKeyPress(.escape) { - if !draft.isEmpty { - draft = "" - return .handled - } - isCreatingBranch = false - return .handled - } - .accessibilityLabel("New branch name") - - Button("Create", action: create) - .disabled(trimmedDraft.isEmpty) - } - // The branch controls' one rule, applied to the reveal as it is to the picker: a paused - // repository, a read-only board and a switch in flight close it (`BoardGitBranchSurface`). - .disabled(!surface.controlsEnabled) - } - - private var trimmedDraft: String { - draft.trimmingCharacters(in: .whitespacesAndNewlines) - } - - /// Creates the named branch and switches to it, then puts the reveal away — a create is the end - /// of the detour, and a field left standing over a branch that now exists would be inviting a - /// second one nobody asked for. - private func create() { - let name = trimmedDraft - guard !name.isEmpty, surface.controlsEnabled else { return } - draft = "" - isCreatingBranch = false - Task { await git.switcher?.createAndSwitch(to: name) } - } - - // MARK: The pause - - /// **The abnormal-state surface** (06 ▸ Rules ▸ Abnormal repo states) — deferred here from the - /// auto-commit card, which built the hold this explains. - /// - /// Two sentences, both of them the design's: what the repository is doing, and whose job it is to - /// finish. Never a Repair button — "the app never mutates repo state it didn't create". - private func pauseNote(_ explanation: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(explanation) - .font(.caption) - .foregroundStyle(.primary) - .fixedSize(horizontal: false, vertical: true) - Text(BoardGitBranchSurface.pauseCaption) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .accessibilityElement(children: .combine) - } - - private func caption(_ text: String, tone: Color) -> some View { - Text(text) - .font(.caption) - .foregroundStyle(tone) - .fixedSize(horizontal: false, vertical: true) - } -} diff --git a/Kanban/UI/Board/BoardGitSetup.swift b/Kanban/UI/Board/BoardGitSetup.swift deleted file mode 100644 index 82a09f6..0000000 --- a/Kanban/UI/Board/BoardGitSetup.swift +++ /dev/null @@ -1,229 +0,0 @@ -import SwiftUI - -/// **The Git tab's two setup controls** — add-git and the commit-identity fields (03-board-ui.md ▸ -/// Board popover ▸ Git tab; 06-history-undo.md ▸ Rules ▸ Opt-in init and ▸ Interaction with external -/// writers). -/// -/// ### One configuration home again (ruled 2026-08-07) -/// -/// These two lived in the popover, moved to the board settings sheet with the **2026-07-31 -/// popover/sheet split**, and came back with the **2026-08-07 reversal** that retired that sheet: the -/// popover is the board's one configuration surface, and its Git tab is where a board's repository is -/// both operated and set up. Nothing about either control's *substance* moved in either direction — -/// what moved is the container, and every rule stated below is the same rule the sheet carried, -/// re-pointed at the tab. -/// -/// The one thing the reversal does change is what "the form is visible" means: it is the **popover's** -/// visibility now, and a popover is transient where a sheet was not. That is fine for both rules that -/// depend on it — add-git's inline-answer window and the identity fields' poll are both scoped to "the -/// surface the user asked from is still under their eye", and a popover dismissed by a stray click -/// ends that window exactly as Done ended the sheet's (06 ▸ Rules: "inline while the asking surface is -/// up, banner once it is gone"). -/// -/// They live in their own file rather than in `BoardGitTabView.swift` because they are the *setup* -/// half — the tab's postures and its daily branch face are that file's and `BoardGitControls.swift`'s -/// — and because a pro-m2 card adding the remote and credential surfaces adds them beside these, -/// under the same rules, rather than into the posture switch. - -// MARK: - Geometry - -/// The setup form's one figure, **derived from the body font** like every other surface's -/// (10-accessibility.md ▸ Text scaling: "relative text styles everywhere, no fixed point sizes"). -/// -/// It is what survives `BoardSettingsSheetLayout`, which retired with the sheet 2026-08-07: the -/// sheet's width and section inset were a *window's* geometry and the popover supplies both itself -/// (`BoardInfoView`), but the identity form's label column is the form's own and travelled with it. -enum BoardGitSetupLayout { - - /// The identity form's label column. 3.4 em: 44pt at the standard body, which is what those - /// fields have always drawn. - static func labelColumn(bodyPointSize: CGFloat) -> CGFloat { - BoardMetrics.em(3.4, bodyPointSize: bodyPointSize) - } -} - -// MARK: - Add git - -/// **The add-git action** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the one place in the app that -/// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than -/// a promise: there is no other caller of `HistoryStore.addGit`. -/// -/// The caption states what pressing it does, in the order it happens, because it is not undoable in -/// the ordinary sense: a repository appears in the board's folder and its current state becomes the -/// first commit. -/// -/// **It renders inline in the Git tab's no-repository posture** since the 2026-08-07 reversal — under -/// the note that states the fact, where the Board Settings… door stood between 2026-07-31 and that -/// day. The posture is unchanged in every other respect: the fact, then the offer. -struct BoardGitAddAction: View { - - let git: HistoryStore - - /// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses - /// writes refuses this one too — initializing a repository is a write, and a commit is several. - /// The popover **stays open** under the lock and disables in place, which is the style popover's - /// settled precedent (03 ▸ Board popover). - let isEnabled: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Button("Add Git") { - Task { await git.addGit() } - } - .disabled(!isEnabled || git.isAddingGit) - - Text("Creates a git repository in this board's folder and commits its current state.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - if let failure = git.lastFailure { - Text(failure.message) - .font(.caption) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - // **The form add-git answers at** (06 ▸ Interaction with external writers, ruled 2026-07-31 — - // "Form-anchored operations answer at the form first"): inline while this control is on - // screen, the banner once it is gone. Appearing claims the inline surface; disappearing gives - // it up, which both dismisses the stale error and sends any answer still in flight to the - // banner instead of to nobody. - // - // The control's visibility *is* the Git tab's, and the tab's is the popover's — a narrower - // window than the sheet's was until 2026-08-07, and the right one: a popover dismissed by a - // click outside is precisely the user leaving the form. Two other disappearances are not - // dismissals and both are correct: switching to Info or Theme, which puts the question away - // as surely as closing the popover, and a *successful* add-git flipping the mode out from - // under this posture — the failure slot empties because there is nothing left to fail. - .onAppear { git.noteFormVisible(true) } - .onDisappear { git.noteFormVisible(false) } - } -} - -// MARK: - Commit identity - -/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction -/// with external writers: "the identity section … exposes name/email fields that write that repo-local -/// config — the setting *is* the file, portable to any git client, per-board by nature"). -/// -/// The fields moved to the sheet with the 2026-07-31 split and back to the popover's Git tab with the -/// 2026-08-07 reversal, poll included — 06 says the visibility-scoped re-read "rides with the fields", -/// so hosting the view here *is* the re-point: the `.task` below now lives and dies with the tab. -/// -/// ### The placeholder is the whole of the identity rule made visible -/// -/// An empty field shows the **derived default** — the macOS account's full name and -/// `shortname@hostname` — as a placeholder, never as a value. That is the difference between "this -/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says -/// this", and the file is where the difference lives: 06 forbids the app writing its own derived -/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own -/// terminal commits in that board. A field pre-filled with the derived value would write it on the -/// first focus loss. -/// -/// ### The dirty-buffer courtesy, copied from `BoardRenameField` -/// -/// A foreign config edit landing while the tab is open updates an *unfocused* field and never a -/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md ▸ Board popover). The -/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher` -/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and -/// the alternative to a small periodic read is a field that is stale for as long as the surface stays -/// open. -struct BoardGitIdentityFields: View { - - let git: HistoryStore - let isEnabled: Bool - - @State private var name = "" - @State private var email = "" - @FocusState private var focused: Field? - - private enum Field: Hashable { - case name - case email - } - - /// **The fields re-read the config at 2 s while they are visible** (06 ▸ Interaction with - /// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event - /// can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and - /// a visibility-scoped poll is the 15 s paused-state re-read's shape at form cadence (a focused - /// field keeps its keystrokes; dismissing the surface stops the poll)." The surface the ruling - /// named was the sheet; since 2026-08-07 it is the popover's Git tab, which is a *shorter* life - /// than the sheet's and therefore a strictly smaller poll. - private static let pollInterval: Duration = .seconds(2) - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name) - field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email) - - if let failure = git.identityFailure { - Text(failure.message) - .font(.caption) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - .task { - // The first read, then the courtesy poll. Cancellation is the view's disappearance, which - // is the popover closing or the tab strip moving off Git. - while !Task.isCancelled { - await git.refreshIdentity() - try? await Task.sleep(for: Self.pollInterval) - } - } - .onAppear { - name = git.identityName - email = git.identityEmail - } - .onChange(of: git.identityName) { _, value in - guard focused != .name else { return } - name = value - } - .onChange(of: git.identityEmail) { _, value in - guard focused != .email else { return } - email = value - } - // A dismissal is a commit like any other click-away — `BoardRenameField`'s rule, and the same - // idempotence makes the overlap harmless. - .onDisappear { commit() } - } - - private func field( - _ label: String, - text: Binding, - placeholder: String, - tag: Field - ) -> some View { - HStack(spacing: 6) { - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - .frame( - width: BoardGitSetupLayout.labelColumn(bodyPointSize: BoardMetrics.bodyPointSize), - alignment: .leading - ) - TextField(placeholder, text: text) - .textFieldStyle(.roundedBorder) - .lineLimit(1) - .focused($focused, equals: tag) - .onSubmit { commit() } - .disabled(!isEnabled) - .accessibilityLabel("Commit \(label.lowercased())") - } - .onChange(of: focused) { previous, _ in - // Focus leaving *this* field is this field's commit — the inline editors' exit, applied - // to a form where Tab moves between two of them. - guard previous == tag else { return } - commit() - } - } - - /// Writes both fields, and only when one of them differs from what the file says — an unchanged - /// value must not rewrite `.git/config` every time the popover closes. - private func commit() { - guard isEnabled else { return } - guard name != git.identityName || email != git.identityEmail else { return } - Task { await git.writeIdentity(name: name, email: email) } - } -} diff --git a/Kanban/UI/Board/BoardGitTabView.swift b/Kanban/UI/Board/BoardGitTabView.swift deleted file mode 100644 index fd3ade5..0000000 --- a/Kanban/UI/Board/BoardGitTabView.swift +++ /dev/null @@ -1,348 +0,0 @@ -import Foundation -import SwiftUI - -/// **The board popover's Git tab** (03-board-ui.md § Board popover ▸ Git tab, settled 2026-08-07) — -/// the pre-tab closing git section rehomed *whole*, and then, later the same day, **the board -/// settings sheet's contents rehomed into it too**. A repository-facts dossier in the Info tab's -/// register — commit counts, last-commit dates — was considered in the Git session and declined: the -/// popover's git surface is for *operating* and *setting up*, and per-item history is the card -/// window's History section (05-card-window.md). -/// -/// ### Operating and setup, one surface again (ruled 2026-08-07) -/// -/// The tab settled as "the daily face, and only that" — the 2026-07-31 popover/sheet split's half — -/// with a **Board Settings…** row pointing at the sheet that held add-git, branch creation and the -/// commit identity. That split is **reversed**: the sheet retires, the row with it, and the two setup -/// controls render inline in the postures they belong to (`BoardGitSetup.swift`). Branch creation -/// went back where it came from, the switch menu (`BoardGitControls.branchRow`). What is left is one -/// configuration home per board — the popover — reached by the widget or Board Info ⌘I, and no -/// surface that has to be validated into existence before a door can point at it. -/// -/// ### No "Git" header anywhere in this tab -/// -/// The tab's own label does the naming work the section's header used to (the design's phrasing), so -/// every posture below renders its content bare where the pre-tab section stacked it under a -/// `sectionHeader("Git")` and a `Divider()`. The tabs already sit under the popover header's own -/// divider; a second rule and a second "Git" would be the surface naming itself twice. -/// -/// ### Every board has this tab (pivot 2026-08-07) -/// -/// 12-editions.md ▸ PIVOT 2026-08-07 took git off the paywall, and 03's Git-tab note records what -/// that does to this surface: with no free-only postures left, every board resolves one of the four -/// mode postures below and **the tab is always in the strip**. The membership rule the Git session -/// ruled (the strip asks the posture) stands structurally in `BoardInfoTab` — it simply never hears -/// "absent" any more, because there is no longer a posture that says nothing. - -// MARK: - The posture - -/// **What the Git tab shows, for one board** (03-board-ui.md ▸ Board popover ▸ Git tab; -/// 06-history-undo.md ▸ Rules) — a pure function of the board's **mode**, so the posture matrix is -/// provable without a popover on screen. -/// -/// The four cases are the mode, one to one — and the mode-`none` and repo-nested pair is where the -/// design is most insistent: a repo-nested board gets **prose, not a disabled button**. "The option -/// is absent because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸ -/// Rules). `unverifiable` (the git-detection axis) is structurally identical to `repoNested` but -/// worded as its own honest prose — a denial is not a nesting. -/// -/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either, and the -/// 2026-08-07 reversal filled them back in.** Setup left the popover for the board settings sheet, -/// so mode `none` rendered no action here at all for a week (the case was called `.addGit` when it -/// did, and was renamed `.noRepository` when the control left) and the git-mode case lost branch -/// creation and the identity fields; the reversal retired that sheet and brought all three back -/// inline. The case name stays `.noRepository` — it describes the board, which is what a posture is -/// for, and it survived the round trip precisely because it never named a control. What each case -/// *is* is a posture, which is why the matrix and its test survived both moves unchanged. -/// -/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** — the same cases, now -/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's -/// single pane. -/// -/// **The 2026-08-07 pivot took the tier axis out of it** (12-editions.md ▸ PIVOT 2026-08-07, the -/// same day). The two free-tier cases — `.absent` on an ordinary board, `.proPointer` on a board -/// carrying an inert `.git`, both settled 2026-07-27 — described a gate that no longer exists: git -/// is tier-independent, a `.git` at a board root is live in every tier, and detection runs at every -/// board open. So the free-tier branch, the `hasGitDirectory` input it asked for, and both cases are -/// **retired**, and what is left is the mode — which is what the postures were always about. -enum BoardGitSection: Equatable, CaseIterable { - - /// Mode `none`: a board that could have a history and has none. There is no daily surface for - /// that — the tab is one caption stating the fact, and **add-git directly under it** since the - /// 2026-08-07 reversal (`BoardGitAddAction`), where the Board Settings… door stood for the week - /// the sheet existed. The fact-then-offer posture blessed 2026-08-06, restated for a surface - /// whose header is the tab label and whose offer is the control itself rather than a door to it. - /// - /// **Every tier's posture since the pivot** (03 ▸ Git tab, pivot note 2026-08-07), and git stays - /// **opt-in per board**: the button is an offer, never an auto-init. - case noRepository - - /// Repo-nested: the honest explanation, no action — and nothing setup-shaped either, since - /// nothing setup-shaped can apply (`BoardGitSetupSection.resolve`, empty here). - case repoNested - - /// Unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository - /// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested` - /// (no action, no setup — `BoardGitSetupSection.resolve` empty), but its own case so the view - /// renders its own honest prose rather than the nested sentence — "unverifiable" is not - /// "nested". - case unverifiable - - /// Git mode: the branch/source line with the **switch** picker (which carries New Branch… again - /// since 2026-08-07), the abnormal-state explanation when the surface is held, and the commit - /// identity block. The remote half — tracking, Pull/Push, the status badges — is - /// 07-sync-collab.md's own card and joins this same posture. - case branch - - static func resolve(mode: BoardGitMode) -> BoardGitSection { - switch mode { - case .none: return .noRepository - case .git: return .branch - case .repoNested: return .repoNested - case .unverifiable: return .unverifiable - } - } -} - -// MARK: - The setup inventory - -/// **Which setup controls this tab hosts for one board** — a pure function of the mode and of whether -/// the repository opens, so the rehomed inventory is provable without a popover on screen. -/// -/// It is `BoardSettingsSection.resolve`'s successor, and deliberately its same shape: that enum was -/// the *sheet's* inventory (2026-07-31 → 2026-08-07) and it retired with the sheet, but the rule it -/// carried is about the **board**, not about the container, so it survives the reversal re-pointed at -/// the tab. Branch creation is not a case here for the same reason it was one there and is not now: -/// it went back into the switch menu (`BoardGitControls.branchRow`), which is a daily control with an -/// inline reveal rather than a standing form. pro-m2's remote and credential cards each add a case -/// here and a block in `BoardGitTabView.posture` — nothing else. -/// -/// Ordered as the tab lays them out, and the order is trivially the postures' own: no board is ever -/// in both modes, so the array is one element or none. It stays an array rather than an `Optional` -/// because the pro-m2 cards land in the git-mode posture beside `.commitIdentity`. -enum BoardGitSetupSection: String, Equatable, CaseIterable, Identifiable { - - /// Mode `none`: **add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the offer, on every tier - /// since 12-editions.md ▸ PIVOT 2026-08-07, and still never an auto-init. - case addGit - - /// Mode `git`: the **commit identity** name/email that repo-local `.git/config` carries - /// (06 ▸ Interaction with external writers). - case commitIdentity - - var id: String { rawValue } - - /// The block's header — a heading VoiceOver navigates by (10-accessibility.md's navigable-header - /// rule, carried over from the sheet's sections). `.addGit` has none: it renders directly under - /// the no-repository note, which already states what the posture is, and a "Git" header inside - /// the Git tab would be the surface naming itself twice (this file's opening note). - var title: String? { - switch self { - case .addGit: nil - case .commitIdentity: "Commit Identity" - } - } - - /// - Parameter isRepositoryUnreadable: whether the board's `.git` exists and will not open - /// (`HistoryStore.isRepositoryUnreadable`). Defaulted, because it can only ever be true in mode - /// `git` — every other mode has no repository for the probe to have failed on, and a caller - /// that has no git state to ask is describing one of those boards. - /// - /// `nonisolated` for `BoardGitControls.switchTargets`' reason: a pure answer over plain values, - /// reachable from a test with no actor to hop to. - nonisolated static func resolve( - mode: BoardGitMode, - isRepositoryUnreadable: Bool = false - ) -> [BoardGitSetupSection] { - switch mode { - case .none: - return [.addGit] - case .git: - // **An unreadable repository hosts no setup** (06-history-undo.md ▸ Rules, the - // corrupt-`.git` loud failure, ruled 2026-07-31: "the whole git surface paused … - // Lanework leaves the repository untouched"), and it lands on repo-nested's emptiness by - // repo-nested's own reasoning, one step further along: writing an identity is a *write* - // into the repository's own config, and there is no repository the app can open to write - // it into. The posture is not empty, though — that is the difference the sheet could not - // express and the tab can: `BoardGitControls` still renders, holding, with its own - // sentence explaining the state (`BoardGitBranchSurface.unreadableNote`). What the - // unreadable board loses is the setup block alone. - // - // The mode stays `.git` throughout — this is emptiness *within* git mode, never a fall - // to mode none, which is what would let add-git be offered against an existing `.git`. - return isRepositoryUnreadable ? [] : [.commitIdentity] - case .repoNested: - // **Nothing setup-shaped can apply** (06 ▸ Rules): the board lives inside a repository - // Lanework leaves alone, so there is no add-git (the design is insistent that the option - // is *absent*, "prose, not a disabled button") and no repo-local config of ours to write. - // The posture's whole content is its explanation. - return [] - case .unverifiable: - // **Structurally the same emptiness as `.repoNested`, for the same reason** (06 ▸ Rules ▸ - // Detection, "Denial is not absence"): a denied ancestor check can never be told apart - // from a repository actually being there, so add-git stays unreachable. Only the - // posture's *prose* tells the two apart — this inventory does not, because there is - // nothing to set up on either. - return [] - } - } -} - -// MARK: - The notes - -/// **The no-repository caption** (03-board-ui.md ▸ Board popover ▸ Git tab) — the fact, stated, above -/// the add-git button that offers to change it. -/// -/// One sentence in its siblings' register, and deliberately not a header: the tab label already says -/// "Git", so what is left to say is what this board's git story currently *is*. "Yet" is the whole -/// posture in a word — the button directly below it is where a user says otherwise. (It read "above -/// the Board Settings… door" between 2026-07-31 and the 2026-08-07 reversal; the sentence never -/// changed, only what stands under it.) -private struct BoardGitNoRepositoryNote: View { - - var body: some View { - Text("This board has no git history yet.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } -} - -/// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it: -/// short prose in place of an action, never a hidden or greyed-out add-git. -private struct BoardGitNestedNote: View { - - var body: some View { - Text("This board lives inside a repository; Lanework leaves it to that repository.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } -} - -/// **The unverifiable explanation** (06-history-undo.md ▸ Rules ▸ Detection, "Denial is not -/// absence", ruled 2026-07-31), worded as its own honest sentence rather than borrowing -/// `BoardGitNestedNote`'s — a denied ancestor check is not a found repository, and telling a user -/// their board is nested when the truth is "couldn't check" would be a lie dressed as caution. -private struct BoardGitUnverifiableNote: View { - - var body: some View { - Text("Lanework couldn't verify whether this board sits inside a repository, so it isn't offering to add one here.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } -} - -// MARK: - The tab - -/// The Git tab's surface: whichever of the four postures this board is in, with the setup controls -/// that posture hosts rendered inline in it (`BoardGitSetupSection`). -struct BoardGitTabView: View { - - let store: BoardStore - - /// The git state this board's **session** composed with, handed down from `BoardInfoView` rather - /// than re-derived — 12-editions.md ▸ The entitlement ("a lapse never interrupts an open - /// session"). `git` is `@Observable`, so a branch switch or a pause landing while the tab is open - /// repaints it. - /// - /// Optional because a caller with no session in hand (previews, the accessory-installation tests) - /// has none to hand over; a `nil` reads as mode `none`, which is the honest description of a - /// board nothing has detected anything about yet. Since the 2026-08-07 pivot it is no longer a - /// *tier* signal — every session composes one (12 ▸ PIVOT 2026-08-07). - let git: HistoryStore? - - /// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and - /// `BoardThemeTabView`'s own parameter — the tab pads by this amount instead of restating the - /// derivation. - let inset: CGFloat - - var body: some View { - posture - .padding(inset) - .frame(maxWidth: .infinity, alignment: .leading) - } - - /// The tab's content, posture by posture — see `BoardGitSection`. No section header and no - /// dividers: the tab label names this surface, and the tabs already sit under the popover - /// header's rule. - @ViewBuilder - private var posture: some View { - switch BoardGitSection.resolve(mode: git?.mode ?? .none) { - case .noRepository: - // Nothing daily to show on a board with no repository — so the tab is the fact and the - // offer. Add-git spent a week behind the settings sheet's door (2026-07-31 → the - // 2026-08-07 reversal) and is back inline under the note that says why it is there. A - // `nil` git — previews, the accessory-installation tests — renders the note alone: the - // fact is the honest thing to say about a board nothing has been detected about, and - // there is nothing to add a repository *to*. - VStack(alignment: .leading, spacing: 6) { - BoardGitNoRepositoryNote() - if let git, setup.contains(.addGit) { - BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations) - } - } - - case .repoNested: - BoardGitNestedNote() - - case .unverifiable: - BoardGitUnverifiableNote() - - case .branch: - // The daily face first — the branch line with its switch menu, which carries New Branch… - // again since the 2026-08-07 reversal — then the one setup block this posture hosts. - // The identity block is gated on the setup inventory rather than on a condition spelled - // out here, which is what keeps "an unreadable repository hosts no setup" one rule with - // one test (`BoardGitSetupSection.resolve`); `BoardGitControls`' own unreadable sentence - // stands alone under it. - // - // `inset` as the gap rather than the 6pt row rhythm: the identity block is a *section* - // under its own heading, and the popover's section spacing is the figure the retired - // sheet used between its sections for the same reason (`BoardInfoView.inset`). - VStack(alignment: .leading, spacing: inset) { - if let git { - BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations) - } - if let git, setup.contains(.commitIdentity) { - setupBlock(.commitIdentity) { - BoardGitIdentityFields(git: git, isEnabled: store.acceptsBoardMutations) - } - } - } - } - } - - /// What this board's setup half holds — the sheet's inventory rule, re-pointed at the tab it - /// rehomed into (`BoardGitSetupSection`). Read once per posture branch rather than re-derived - /// beside each block. - private var setup: [BoardGitSetupSection] { - BoardGitSetupSection.resolve( - mode: git?.mode ?? .none, - isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false - ) - } - - /// A setup block under its own header, where the section carries one. - /// - /// **The header is an accessibility structure, not decoration** (10-accessibility.md's - /// navigable-header rule, which the retired sheet's sections carried and which came along with - /// them): the rotor jumps between headings rather than walking one flat run of controls. This is - /// the tab's *only* header — the no-repository posture's control has none, and the tab label - /// still does the naming for the surface as a whole (this file's opening note). - @ViewBuilder - private func setupBlock( - _ section: BoardGitSetupSection, - @ViewBuilder content: () -> some View - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - if let title = section.title { - Text(title) - .font(.subheadline.weight(.semibold)) - .accessibilityAddTraits(.isHeader) - } - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index fe04c18..b3d5fc4 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -4,21 +4,17 @@ import SwiftUI /// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the /// widget in the window's titlebar that opens it. /// -/// **Tabbed since 2026-08-07 — the restructure is complete, all three tab sessions settled.** The -/// symbol/name header stays at the top; below it sit the tabs — **Info**, **Theme**, **Git** — each -/// the settings surface for one aspect of board configuration, each settled in its own dedicated -/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color / -/// Pattern picker (the Background tab's original name, before the same session widened it past the -/// generated-only picker and folded manual styling back out to Style… ⌥⌘S); and `BoardGitTabView`, -/// which the pre-tab body's mode-aware git section rehomed into whole — postures and notes, none of -/// them re-ruled by the move. That tab carried a **Board Settings…** row to a separate sheet until -/// later the same day, when the 2026-07-31 popover/sheet split was reversed and the sheet's contents -/// rehomed into the tab (see below). +/// **Tabbed since 2026-08-07.** The symbol/name header stays at the top; below it sit the tabs — +/// **Info**, **Theme**, **Sync** — each the settings surface for one aspect of board configuration, +/// each settled in its own dedicated design session: `BoardInfoTabView`, the metrics dossier; +/// `BoardThemeTabView`, the Solid color / Pattern picker (the Background tab's original name, before +/// the same session widened it past the generated-only picker and folded manual styling back out to +/// Style… ⌥⌘S); and `BoardSyncTabView`, a standing placeholder for the future ops-based sync service. +/// The Git tab that once sat here left with the git excision (strategy/01-git-excision.md, +/// 2026-08-08). /// -/// **Tab membership is the git posture's**, and **selection resets to Info on every open** — both -/// the Git session's rulings. Since 12-editions.md ▸ PIVOT 2026-08-07 the first of those is a -/// structural rule with nothing left to exclude: every board carries all three tabs (03 ▸ Board -/// popover, the same-day pivot note) — see `BoardInfoTab`. +/// **Selection resets to Info on every open** — the tab session's ruling, unaffected by the git +/// excision: see `BoardInfoTab`. /// /// ### One home, deliberately /// @@ -29,11 +25,9 @@ import SwiftUI /// /// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31 /// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and -/// a row here pointing at it. The sheet is retired, its add-git and commit-identity controls render -/// inline in the Git tab's postures (`BoardGitSetup.swift`), branch creation went back into the -/// switch menu (`BoardGitControls`), and Board ▸ Board Settings… left the menu bar with them. So -/// "one home per control" — the split's own promise — is now satisfied by there being one surface, -/// and ⌘I is the door to all of it. +/// a row here pointing at it. The sheet is retired and Board ▸ Board Settings… left the menu bar with +/// it. So "one home per control" — the split's own promise — is now satisfied by there being one +/// surface, and ⌘I is the door to all of it. // MARK: - Presentation state @@ -74,17 +68,13 @@ extension FocusedValues { // MARK: - The window-title widget -/// The titlebar widget: the board's glyph beside a two-line identity block — its name, and on a -/// git-mode board its branch under the name — with a trailing disclosure chevron, whose one job is -/// this popover. +/// The titlebar widget: the board's glyph beside its name, with a trailing disclosure chevron, whose +/// one job is this popover. /// -/// **A two-line stack since 2026-08-07** (03-board-ui.md ▸ Board popover, the window-title widget -/// passage). It was one line reading `glyph Title — branch ⌄`, and the em-dash was the tell: a -/// separator doing a *hierarchy's* job, with the branch competing for the same width as the name it -/// qualifies. So the branch moved under the title in its own smaller, secondary line, the em-dash -/// retired, and the glyph grew to span both lines — an icon sized to the block it labels rather than -/// to whichever line it happened to sit on. A board with no branch is the single title line, vertically -/// centred beside the same glyph, which is the same block with one row. +/// **Was a two-line stack** (03-board-ui.md ▸ Board popover, the window-title widget passage): a +/// git-mode board's branch sat under the name in its own smaller, secondary line. The branch line +/// left with app-managed git (strategy/01-git-excision.md, 2026-08-08); the widget is back to the +/// single title line, vertically centred beside the glyph. /// /// **The popover is anchored to the widget itself** — it hangs from the button rather than from the /// window or the board — which is what makes the affordance and the surface read as one thing. A @@ -93,37 +83,25 @@ extension FocusedValues { /// only the *placement* (`boardInfoTitlebarAccessory`). /// /// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron -/// button to the full name/branch/chevron button): the title and branch strings sit inside the same -/// `Button`, so a click anywhere across the board's name — or its branch, when shown — opens the -/// popover exactly as a click on the chevron always has. +/// button to the full name/chevron button): the title string sits inside the same `Button`, so a +/// click anywhere across the board's name opens the popover exactly as a click on the chevron +/// always has. struct BoardInfoWidget: View { let store: BoardStore let recents: StyleRecents - /// The git state this board's **session** composed with — read once, at the moment the widget is - /// installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse never interrupts - /// an open session"). `git` is a reference type and `@Observable`, so add-git flipping the mode, - /// or a branch switch, redraws the widget without anything here being re-created. - /// - /// It stopped being a tier signal at 12 ▸ PIVOT 2026-08-07 — every session composes a git state - /// now, whatever the tier — so a `nil` here means only "this caller had no session to hand over" - /// (previews, the accessory-installation tests), which reads as mode `none`. - let git: HistoryStore? - @Bindable var presentation: BoardInfoPresentation - /// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere. - /// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once** - /// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and - /// never see a later rename or branch switch. `store.snapshot` and `git.branch` are both - /// `@Observable`, so reading them here is what makes the title and branch live. + /// The widget's title string, computed fresh on every body evaluation rather than cached + /// anywhere. That matters here specifically: `boardInfoTitlebarAccessory` builds this view + /// exactly **once** at install, so a value read anywhere but inside `body` would freeze at the + /// widget's birth and never see a later rename. `store.snapshot` is `@Observable`, so reading it + /// here is what makes the title live. private var summary: BoardInfoTitlebarSummary { BoardInfoTitlebarSummary( snapshotTitle: store.snapshot.title.value, - rootURL: store.rootURL, - mode: git?.mode ?? .none, - branch: git?.branch + rootURL: store.rootURL ) } @@ -151,33 +129,17 @@ struct BoardInfoWidget: View { // everything VoiceOver needs (`accessibilityLabel` below). .accessibilityHidden(true) - // The identity block: the name, and the branch beneath it on a git-mode board. On - // any other board this is the single title line and the `HStack`'s own centring puts - // it level with the glyph — the stack is the same shape with one row, never a - // special case. - VStack(alignment: .leading, spacing: 1) { - Text(summary.title) - // Styled like a titlebar title, because that is what it now stands in for - // (`BoardWindowHost` hides the system title display in favor of this widget). - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - - if let branch = summary.branch { - // Smaller and secondary — the qualifier under the name it qualifies. The - // em-dash that separated the two on one line retired with the stack: a - // hierarchy that a layout can state does not need punctuation to state it. - Text(branch) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - // Yields space to the chevron first when the block doesn't fit inside the width cap - // below — the board's identity is the more load-bearing half of the pair, and both - // its lines truncate rather than the disclosure disappearing. + // The identity block: the board's name. The `HStack`'s own centring puts it level + // with the glyph. + Text(summary.title) + // Styled like a titlebar title, because that is what it now stands in for + // (`BoardWindowHost` hides the system title display in favor of this widget). + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + // Yields space to the chevron first when the title doesn't fit inside the width cap + // below — the board's identity is the more load-bearing half of the pair. .layoutPriority(1) Image(systemName: "chevron.down") @@ -186,11 +148,9 @@ struct BoardInfoWidget: View { .foregroundStyle(.secondary) } .font(.system(size: 13)) - // A long board name (or branch) must not swallow the whole titlebar — capped rather - // than left to grow, with the truncation above doing the rest. The height is the - // two-line block's (2026-08-07; it was the original chevron's 18 while the widget was - // one line), which is what keeps the accessory titlebar-appropriate: tall enough for - // name-over-branch, and no taller than a standard title bar carries. + // A long board name must not swallow the whole titlebar — capped rather than left to + // grow, with the truncation above doing the rest. The height keeps the accessory + // titlebar-appropriate: no taller than a standard title bar carries. .frame(maxWidth: 400, alignment: .leading) .frame(height: 32) .contentShape(Rectangle()) @@ -199,24 +159,16 @@ struct BoardInfoWidget: View { .help("Board Info") .accessibilityLabel(accessibilityLabel) .accessibilityHint("Shows board info") - // Populates the branch line the moment a git-mode board's window opens, rather than waiting - // on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover - // has actually been opened once). The widget is on screen from the start, so it is the - // earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this - // costs nothing on the other four postures. - .task { await git?.refreshBranch() } .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) { - BoardInfoView(store: store, recents: recents, git: git) + BoardInfoView(store: store, recents: recents) } } - /// What VoiceOver reads for the button, now that it says more than "Board Info": the board's - /// name, plus the branch when the widget is showing one — `.help` keeps the shorter "Board Info" - /// wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names what - /// the button does. + /// What VoiceOver reads for the button: the board's name — `.help` keeps the shorter "Board + /// Info" wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names + /// what the button does. private var accessibilityLabel: String { - guard let branch = summary.branch else { return summary.title } - return "\(summary.title), branch \(branch)" + summary.title } /// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary @@ -231,10 +183,9 @@ struct BoardInfoWidget: View { // MARK: - The widget's strings -/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its -/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility -/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same -/// reason `BoardGitSection.resolve` exists over in `BoardGitTabView.swift`. +/// **The window-title widget's title string, as one pure function** of the board's on-disk title and +/// its folder — pulled out so the fallback rule is assertable without a widget on screen +/// (`BoardInfoTitlebarSummaryTests`). /// /// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title /// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window @@ -245,26 +196,19 @@ struct BoardInfoWidget: View { /// with plain values and no fixture board on disk — the one duplication this card leaves behind /// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites. /// -/// **Branch.** Shown only when the board is actually git-mode — `mode == .git` with a non-`nil` -/// branch — the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause -/// is gone** (12-editions.md ▸ PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a -/// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode == -/// .git` until that day is recorded here rather than restated in code. A board with no repository or -/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a -/// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until -/// `refreshBranch()` answers, which the widget's own `.task` kicks off at open). +/// **Branch.** The widget once carried a second line — the board's git branch, shown on a git-mode +/// board — as a `branch` field here. That line left with app-managed git +/// (strategy/01-git-excision.md, 2026-08-08); the summary is the title alone now. struct BoardInfoTitlebarSummary: Equatable { let title: String - let branch: String? - init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) { + init(snapshotTitle: String?, rootURL: URL) { if let snapshotTitle, !snapshotTitle.isEmpty { self.title = snapshotTitle } else { self.title = rootURL.deletingPathExtension().lastPathComponent } - self.branch = mode == .git ? branch : nil } } @@ -276,21 +220,15 @@ struct BoardInfoTitlebarSummary: Equatable { /// window, removed on detach — for the same reason it owns the delegate proxying: the window is /// SwiftUI's, and anything hung on it has to be taken back off. @MainActor -/// `git` defaults to no session, so that a caller with none in hand (the accessory-installation -/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather -/// than by accident: a Git tab in its no-repository posture, and a widget with no branch line. The -/// app's own call site passes the session's value explicitly. func boardInfoTitlebarAccessory( store: BoardStore, recents: StyleRecents, - git: HistoryStore? = nil, presentation: BoardInfoPresentation ) -> NSTitlebarAccessoryViewController { let hosting = NSHostingView( rootView: BoardInfoWidget( store: store, recents: recents, - git: git, presentation: presentation ) ) @@ -314,31 +252,22 @@ func boardInfoTitlebarAccessory( // MARK: - Tabs /// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab -/// restructure — all three original sessions settled): **Info** (`BoardInfoTabView`), **Theme** -/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`) — and **Sync** (`BoardSyncTabView`), added -/// 2026-08-07 as a standing placeholder: the strip claims the position now, the surface says -/// honestly that nothing lives there yet, and 07-sync-collab.md's cards are where its contents get -/// ruled. The raw values are the segmented control's own labels, so the strip needs no separate -/// label function. +/// restructure): **Info** (`BoardInfoTabView`), **Theme** (`BoardThemeTabView`) — and **Sync** +/// (`BoardSyncTabView`), added 2026-08-07 as a standing placeholder: the strip claims the position +/// now, the surface says honestly that nothing lives there yet, and the future sync-service +/// workstream is where its contents get ruled. A fourth tab, Git, sat here until the git excision +/// (strategy/01-git-excision.md, 2026-08-08) removed it. The raw values are the segmented control's +/// own labels, so the strip needs no separate label function. enum BoardInfoTab: String, CaseIterable, Identifiable { case info = "Info" case theme = "Theme" - case git = "Git" case sync = "Sync" var id: Self { self } - // **Membership is the git posture's, and the posture never says "absent" any more.** The Git - // session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has - // something true to say, which then meant dropping it on a free board with no `.git` — the - // "absent, no placeholder" rule carried up to the strip. **12-editions.md ▸ PIVOT 2026-08-07**, - // the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and - // `.proPointer` died with it, and 03-board-ui.md ▸ Board popover records the consequence — "the - // absent posture is unreachable and every board carries all three tabs. The membership rule - // stands structurally — the strip still asks the posture — it just never hears 'absent' - // anymore." So there is no `available(…)` filter here to ask it with: membership is `allCases`, - // in `allCases`' own order (Info, Theme, Git, Sync), which is what the filter answered anyway. + // Membership is simply `allCases`, in `allCases`' own order (Info, Theme, Sync) — every board + // carries the whole strip, unconditionally. } // MARK: - The popover's content @@ -348,18 +277,16 @@ enum BoardInfoTab: String, CaseIterable, Identifiable { /// /// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit /// beside a card — kept through the restructure so the popover's footprint didn't wander while the -/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface +/// tabs filled in; every settled tab (Info, Theme, Sync) kept it, so whether the tabbed surface /// ever wants its own width remains open, but nothing has needed one yet. struct BoardInfoView: View { let store: BoardStore let recents: StyleRecents - let git: HistoryStore? - /// The selected tab, and **it resets to Info on every open** — a ruling, not an accident (the - /// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover - /// is transient and Info is the board's face, and a remembered tab could strand selection on a - /// tab the next board's posture doesn't offer. `@State` on the popover's content, which + /// The selected tab, and **it resets to Info on every open** — a ruling, not an accident: the + /// popover is transient and Info is the board's face, and a remembered tab could strand + /// selection on a tab the next board doesn't offer. `@State` on the popover's content, which /// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more. @State private var tab: BoardInfoTab = .info @@ -373,12 +300,10 @@ struct BoardInfoView: View { init( store: BoardStore, - recents: StyleRecents, - git: HistoryStore? = nil + recents: StyleRecents ) { self.store = store self.recents = recents - self.git = git } var body: some View { @@ -425,10 +350,8 @@ struct BoardInfoView: View { // The tab bar: a segmented control rather than a `TabView`, because the popover is a // compact settings surface and the segmented idiom is the macOS shape for switching // between a handful of peer panes inside one. The label is hidden visually but stays - // the control's accessibility name. It iterates `allCases` — the strip's membership is - // still the board's git posture in principle (the Git session's ruling), and since - // 12-editions.md ▸ PIVOT 2026-08-07 that posture is never "absent", so every board - // carries the whole strip (03-board-ui.md ▸ Board popover, the same-day pivot note). + // the control's accessibility name. It iterates `allCases` — every board carries the + // whole strip, unconditionally (03-board-ui.md ▸ Board popover). Picker("Board configuration", selection: $tab) { ForEach(BoardInfoTab.allCases) { tab in Text(tab.rawValue) @@ -439,7 +362,7 @@ struct BoardInfoView: View { .padding(.horizontal, inset) .padding(.top, inset) - // The selected tab's surface — the first three settled 2026-08-07, each in its own + // The selected tab's surface — Info and Theme settled 2026-08-07, each in its own // dedicated session and its own file; Sync is that day's standing placeholder. Each pads // itself by `inset`, so the switch adds nothing. switch tab { @@ -447,8 +370,6 @@ struct BoardInfoView: View { BoardInfoTabView(store: store, inset: inset) case .theme: BoardThemeTabView(store: store, inset: inset) - case .git: - BoardGitTabView(store: store, git: git, inset: inset) case .sync: BoardSyncTabView(inset: inset) } diff --git a/Kanban/UI/Board/BoardSyncTabView.swift b/Kanban/UI/Board/BoardSyncTabView.swift index 3b52bd4..272bef4 100644 --- a/Kanban/UI/Board/BoardSyncTabView.swift +++ b/Kanban/UI/Board/BoardSyncTabView.swift @@ -4,17 +4,17 @@ import SwiftUI /// added 2026-08-07, the same night the settings sheet retired). /// /// The tab exists ahead of its contents, deliberately: the strip claims the position where the -/// remote half of the git story will live — tracking, Pull/Push, the status badges, and whatever -/// home the setup surfaces (remote, credentials, SSH) are ruled into — so that when -/// 07-sync-collab.md's cards land they land *in* a surface rather than re-arguing the strip. None -/// of that is ruled by this file: the open question is the Redesign board's -/// ("Rule a home for 07's remote and credential setup surfaces"), and a placeholder that decided it -/// by accident would be the worst way to answer it. +/// future **ops-based sync service** will live — semantic ops, a server-side worktree API, with the +/// phone and (eventually) the web as worktree clients speaking that protocol +/// (strategy/01-git-excision.md ▸ Successors) — so that when the sync-service workstream's cards +/// land they land *in* a surface rather than re-arguing the strip. **Git is gone** +/// (strategy/01-git-excision.md, 2026-08-08): this tab was never the remote half of the git story +/// and now carries no trace of one. None of the service's shape is ruled by this file: that is its +/// own workstream's, with its own docs, and a placeholder that decided it by accident would be the +/// worst way to answer it. /// -/// What renders meanwhile is one honest sentence in the posture notes' register -/// (`BoardGitTabView`'s siblings): the fact, stated plainly — never a mock control, never a -/// greyed-out preview of features that do not exist (06-history-undo.md ▸ Rules: teach, never look -/// broken). +/// What renders meanwhile is one honest sentence: the fact, stated plainly — never a mock control, +/// never a greyed-out preview of features that do not exist (teach, never look broken). struct BoardSyncTabView: View { /// The popover's own padding figure (`BoardInfoView.inset`), matching every other tab's diff --git a/Kanban/UI/Card/CardHistorySection.swift b/Kanban/UI/Card/CardHistorySection.swift deleted file mode 100644 index 446157c..0000000 --- a/Kanban/UI/Card/CardHistorySection.swift +++ /dev/null @@ -1,191 +0,0 @@ -import Observation -import SwiftUI - -// MARK: - One row - -/// One **History** row: a commit that touched this card's folder (05-card-window.md ▸ History). -/// -/// A value rather than the `GitCommitRecord` itself, so the view renders strings a test has already -/// checked and never formats a date in a `body`. -struct CardHistoryRow: Identifiable, Equatable, Sendable { - - /// The commit's oid — the identity, and nothing the row shows. - let id: String - - /// The commit's subject, exactly as the message engine wrote it. - let subject: String - - /// "2 days ago · Claude" — the row's second line. - let attribution: String -} - -// MARK: - The seam - -/// What the History section shows, as a pure function of commits and a clock -/// (05-card-window.md ▸ History: "newest first — semantic subject, relative date, author"). -enum CardHistoryRows { - - /// The rows for one card's commits, newest first — which is the order the walk already answers - /// in, so nothing here re-sorts and nothing can disagree with git about what "newest" means. - nonisolated static func rows( - for commits: [GitCommitRecord], - now: Date = Date(), - locale: Locale = .autoupdatingCurrent - ) -> [CardHistoryRow] { - commits.map { commit in - CardHistoryRow( - id: commit.oid, - subject: commit.subject, - attribution: attribution(of: commit, now: now, locale: locale) - ) - } - } - - /// "⟨relative date⟩ · ⟨author⟩", with the author dropped when there is none to name. - /// - /// The author is the commit's, which is where origin lives (06-history-undo.md ▸ Interaction with - /// external writers) — so a foreign commit reads `Lanework External` and a `modified-by` agent - /// reads its own name, with no rendering rule of this section's own. That is 05's claim that the - /// trail "reads as a story, agent and hand edits included" arriving for free. - nonisolated static func attribution( - of commit: GitCommitRecord, - now: Date = Date(), - locale: Locale = .autoupdatingCurrent - ) -> String { - let when = relativeDate(commit.date, now: now, locale: locale) - let author = commit.authorName.trimmingCharacters(in: .whitespacesAndNewlines) - return author.isEmpty ? when : "\(when) · \(author)" - } - - /// A relative date in the system's own words ("2 days ago"), with **"just now"** for anything - /// inside a minute. - /// - /// The floor is a judgment call, recorded: `RelativeFormatStyle` renders a five-second-old commit - /// as "in 0 seconds" whenever the clock rounds the wrong way, and a trail whose newest row reads - /// as the future is worse than one that rounds down. Everything past a minute is the platform's - /// answer verbatim, localized and abbreviated to suit a 26-character-wide sidebar. - nonisolated static func relativeDate( - _ date: Date, - now: Date = Date(), - locale: Locale = .autoupdatingCurrent - ) -> String { - guard now.timeIntervalSince(date) >= 60 else { return "just now" } - var style = Date.RelativeFormatStyle(presentation: .named, unitsStyle: .wide) - style.locale = locale - return date.formatted(style.locale(locale)) - } -} - -// MARK: - The loader - -/// **One card window's commit trail** — the object the sidebar renders and the host refreshes. -/// -/// ### Its existence is the section's visibility rule -/// -/// "The section is **absent** on boards without app-managed git (mode none, repo-nested) — same -/// honesty rule as the popover's git section" (05 ▸ History). So the host builds one of these only -/// where the board's history is genuinely git-backed — its `HistoryStore` in mode `git` — and `nil` -/// is the whole of the absence: no placeholder, no empty header, nothing to explain. -/// -/// **Per board, never per tier** (12-editions.md ▸ PIVOT 2026-08-07). 12's old tier matrix listed -/// the card History sidebar as a Pro row, so the section's absence used to have two causes at once — -/// a gitless board, or a free-tier session with no git state to ask. Git is tier-independent now: -/// the one question left is whether *this board* has a repository the app manages, which is the -/// question 05 was always asking. -/// -/// ### It re-reads rather than subscribes -/// -/// The trail changes when a commit lands, which is exactly what `GitAutoCommitter.commitCount` -/// counts. The host re-asks on that number and on the card's own folder, so a trail refreshes after -/// every commit — the app's own, an agent's the watcher committed, and a restore's — without this -/// object learning what a committer is. -@MainActor -@Observable -final class CardHistory { - - /// The rows, newest first. Empty until the first load answers, which is also the honest answer for - /// a card whose folder no commit has touched yet. - private(set) var rows: [CardHistoryRow] = [] - - /// Whether a load is in flight — what keeps the section from flashing "no history yet" during the - /// first walk of a large repository. - private(set) var isLoading = false - - init() {} - - /// Reads the commits that touched `cardFolderName` under `boardRoot`, off the main actor. - func load(boardRoot: URL, cardFolderName: String) async { - isLoading = true - defer { isLoading = false } - let commits = await Task.detached(priority: .utility) { - GitHistoryWalk.commitsTouching(folderNamed: cardFolderName, at: boardRoot) - }.value - rows = CardHistoryRows.rows(for: commits) - } -} - -// MARK: - The section - -/// The sidebar's **History** section: the card's commit trail, read-only, newest first -/// (05-card-window.md ▸ History). -/// -/// ### No actions, deliberately -/// -/// "Rows are focusable (arrows), but carry **no actions in v1** — restoring an old version stays a -/// git-client task for now; a per-row forward-restore and lane history are wishlist items, -/// deliberately." So the rows are text: selectable, copyable, and nothing else. The one restore this -/// milestone ships is board-level ⌘Z, which is a different gesture with a different target. -/// -/// ### Empty says so, rather than disappearing -/// -/// Contrast Details beside it, which vanishes when a card has no unknown keys. The distinction is -/// what an empty state would *imply*: an absent Details section implies nothing (most cards have no -/// unknown keys), while an absent History section on a git board would imply the board has no -/// history — the exact claim 05 reserves for boards that genuinely have none. A card whose folder is -/// newer than its last commit is a real and temporary state, and one quiet line is the honest way to -/// say so. -struct CardHistorySection: View { - - let history: CardHistory - - private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } - - var body: some View { - VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { - CardSidebarSectionHeader(title: "History") - - if history.rows.isEmpty { - Text(history.isLoading ? "Reading history…" : "No commits yet") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else { - ForEach(history.rows) { row in - self.row(row) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - /// Subject over attribution — `CardDetailsSection.row`'s shape, inverted: there the quiet line is - /// the key and the loud one the value; here the *subject* is what a reader scans for and the date - /// and author are the qualifier. Same two fonts, same wrap-rather-than-truncate rule, so the two - /// sections read as one column at any text size. - private func row(_ row: CardHistoryRow) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text(row.subject) - .font(.callout) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - Text(row.attribution) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(row.subject), \(row.attribution)") - } -} diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index fcddaa6..d88ab41 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -28,9 +28,7 @@ import UniformTypeIdentifiers /// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that /// reads or writes beyond that is later work and is marked where it lands: /// -/// - the title as an editable field (commit on Return / focus loss, Escape abandons), -/// - the sidebar's History section, whose place in the stack is reserved and whose content waits on -/// a git mode to be honest about (`historySlot`). +/// - the title as an editable field (commit on Return / focus loss, Escape abandons). /// /// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are /// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in @@ -96,10 +94,6 @@ struct CardWindowView: View { /// issued in this window registers on this window's stack. It lives on the window's session so /// the close can fold it, which is why it arrives here rather than being made here. let undo: CardWindowUndo - /// **This card's commit trail** (05 ▸ History), or `nil` on every board with no app-managed git — - /// mode none, repo-nested, unverifiable. The `nil` *is* the section's absence rule; see - /// `historySlot`. A per-board question on every tier since 12-editions.md ▸ PIVOT 2026-08-07. - let history: CardHistory? /// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole /// window**"). `nil` only where a caller has no store to import through. let fileDrop: CardWindowDropDelegate? @@ -284,8 +278,8 @@ struct CardWindowView: View { /// (05 ▸ Composition) — the whole line disappears when the card carries none of the three. /// /// The "by" segment renders only with the self-reported provenance stamp present - /// (01-storage-format.md), which is the point of showing it at all: provenance made visible where - /// git history may not exist. + /// (01-storage-format.md), which is the point of showing it at all: provenance made visible with + /// no commit trail to read it from. private var dateLine: String? { var parts: [String] = [] if let created = card.created.value { @@ -306,14 +300,16 @@ struct CardWindowView: View { // MARK: - Attributes sidebar - /// The sidebar's sections, **in 05's settled order**: Attachments, Style, Details, History, - /// Actions. + /// The sidebar's sections, **in 05's settled order**: Attachments, Style, Details, Actions. /// - /// Two of the five are conditional, and both conditions are the section's own rather than a rule + /// One of the four is conditional, and the condition is the section's own rather than a rule /// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys - /// ("shown only when any exist"), and **History** is absent on boards without app-managed git. - /// Everything else in the stack is unconditional, so the composition a user learns on one card is - /// the composition they get on the next. + /// ("shown only when any exist"). Everything else in the stack is unconditional, so the + /// composition a user learns on one card is the composition they get on the next. + /// + /// The History section that once sat between Details and Actions left with app-managed git + /// (strategy/01-git-excision.md, 2026-08-08); View ▸ History (`FutureCommands.swift`) is the only + /// surviving reservation of that slot, and it anticipates the foreign-change journal successor. private var sidebar: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { @@ -325,35 +321,12 @@ struct CardWindowView: View { // keys and their order included, and `Card` has carried it since (`BoardModel`). CardDetailsSection(rows: CardDetails.rows(of: card.document)) - historySlot - CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder) } .frame(maxWidth: .infinity, alignment: .leading) .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) } } - - /// **The History section** — between Details and Actions, 05's order (05 ▸ History: "the card's - /// commit trail, read-only … newest first — semantic subject, relative date, author"). - /// - /// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without - /// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section". - /// The host builds a `CardHistory` only where the board's history is git-backed (mode `git`), so - /// there is no placeholder here to decide about: what the slot reserves is the **position**, and - /// on every other board that position is empty. - /// - /// **The tier is not one of the inputs** (12-editions.md ▸ PIVOT 2026-08-07 — git left the - /// paywall, retiring 12's tier matrix row that made this a Pro surface): a git board shows its - /// trail on any tier, a gitless one shows nothing on any tier. - /// - // A later card: View ▸ History, which focuses this section (11-command-nexus.md). - @ViewBuilder - private var historySlot: some View { - if let history { - CardHistorySection(history: history) - } - } } // MARK: - The window-wide drop diff --git a/KanbanTests/BoardGitSetupTests.swift b/KanbanTests/BoardGitSetupTests.swift deleted file mode 100644 index 546c969..0000000 --- a/KanbanTests/BoardGitSetupTests.swift +++ /dev/null @@ -1,155 +0,0 @@ -import Foundation -import Testing -@testable import Kanban - -/// **What the popover's Git tab sets up, board by board** (03-board-ui.md ▸ Board popover ▸ Git tab; -/// 06-history-undo.md ▸ Rules) — `BoardGitSetupSection.resolve`, the tab's setup inventory, pinned -/// for `BoardGitSection.resolve`'s reason one seam over: the *contents* are the decision worth -/// asserting and the SwiftUI that renders them is not. Every case below is a plain value — no board -/// on disk, no window, no session. -/// -/// ### This suite is the settings sheet's, inherited -/// -/// It was `BoardSettingsSectionTests` and `BoardSettingsAvailabilityTests` against the sheet's own -/// inventory (2026-07-31 → 2026-08-07). The **2026-08-07 reversal** retired that sheet and rehomed -/// its controls into the tab, so the *subject* moved and most of the rules did not: an unreadable -/// repository still hosts no setup, repo-nested and unverifiable still host none, and mode `none` -/// still means add-git and nothing else. -/// -/// Two things did change, and both are asserted below rather than described: -/// -/// - **Branch creation is not in this inventory.** It went back into the switch menu as New Branch… -/// with an inline reveal (`BoardGitControls`), which is a daily control rather than a standing -/// form — so the git-mode posture's setup half is the commit identity alone. -/// - **Emptiness no longer means "no surface".** The sheet's `BoardSettingsAvailability` existed to -/// answer "does this surface exist at all", because a sheet with no sections had no honest empty -/// state and two doors had to validate before opening it. The tab always exists (every board -/// carries all three tabs since 12-editions.md ▸ PIVOT 2026-08-07) and always has a posture to -/// render, so an empty inventory now means only "this posture hosts no setup block" — there is no -/// door left to disable, and the availability seam retired with the doors. -@Suite("Board popover ▸ Git tab ▸ the setup inventory") -struct BoardGitSetupSectionTests { - - @Test("Mode none: add-git and nothing else") - func modeNoneHoldsAddGit() { - // "add-git (mode none; opt-in init — 06)", rendered under the no-repository note since the - // reversal put it back where the pre-split popover had it. Every tier's since - // 12-editions.md ▸ PIVOT 2026-08-07, and still an offer rather than an auto-init. - #expect(BoardGitSetupSection.resolve(mode: .none) == [.addGit]) - } - - @Test("Git mode: the commit identity — creation is the switch menu's again") - func gitModeHoldsTheIdentity() { - // "commit identity name/email (06 — the visibility-scoped 2 s config re-read rides with the - // fields)". Branch creation was this posture's other section while the sheet existed; the - // reversal moved it back into the menu, so it is deliberately absent here. - #expect(BoardGitSetupSection.resolve(mode: .git) == [.commitIdentity]) - } - - @Test("Every section is reachable from some posture, and no posture invents one") - func theInventoryIsTotal() { - let offered = Set(BoardGitMode.allCases.flatMap { BoardGitSetupSection.resolve(mode: $0) }) - - #expect(offered == Set(BoardGitSetupSection.allCases)) - } - - @Test("Repo-nested: nothing setup-shaped applies — the posture is its explanation") - func repoNestedHoldsNothing() { - // "not a hidden 'add git' but a short explanation … the option is absent because it *can't* - // apply" (06 ▸ Rules). The posture still renders — its note is the whole of it — but there - // is no setup block under it. - #expect(BoardGitSetupSection.resolve(mode: .repoNested) == []) - } - - @Test("Unverifiable: the same emptiness, for the denial-not-absence reason") - func unverifiableHoldsNothing() { - // "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check - // can never be told apart from a repository actually being there, so add-git stays as - // unreachable here as it is on a genuinely nested board. - #expect(BoardGitSetupSection.resolve(mode: .unverifiable) == []) - } - - @Test("Git mode with an unreadable repository: no setup block, but still the branch posture") - func anUnreadableRepositoryHoldsNoSetup() { - // **The corrupt-`.git` loud failure** (06 ▸ Rules, ruled 2026-07-31): writing an identity is - // a *write* into the repository's own config, and there is no repository the app can open to - // write it into. - #expect(BoardGitSetupSection.resolve(mode: .git, isRepositoryUnreadable: true) == []) - - // …and the mode is still `git` throughout: this is emptiness *within* git mode, never the - // fall to mode none that would let add-git be offered against an existing `.git`. - #expect(BoardGitSetupSection.resolve(mode: .git) == [.commitIdentity]) - - // The difference the tab can express and the sheet could not: the *posture* is unchanged, so - // the board still gets its branch surface, holding and explaining itself - // (`BoardGitBranchSurface.unreadableNote`). Only the setup block goes. - #expect(BoardGitSection.resolve(mode: .git) == .branch) - } - - @Test("Only the identity block carries a header") - func headersAreNamed() { - // 10-accessibility.md's navigable-header rule, carried over from the retired sheet's - // sections: a heading the VoiceOver rotor jumps to. Add-git has none — it renders directly - // under the note that states the posture, and a "Git" header inside the Git tab would be the - // surface naming itself twice. - #expect(BoardGitSetupSection.commitIdentity.title == "Commit Identity") - #expect(BoardGitSetupSection.addGit.title == nil) - } -} - -/// The same inventory asked about **a board on disk** — the two ends of the range a real -/// `HistoryStore` puts into it, so the seam's inputs are shown to be the ones a composed session -/// actually produces rather than values a test invented. -/// -/// `@MainActor` because composing a `HistoryStore` is (the inventory itself is `nonisolated`, which -/// is exactly what the suite above exercises with no actor in sight). -@MainActor -@Suite("Board popover ▸ Git tab ▸ the setup inventory, against a composed session") -struct BoardGitSetupCompositionTests { - - @Test("A board carrying an unopenable `.git` is a git board with no setup") - func anInertGitIsAnUnreadableGit() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - try fixture.item("", Item.board) - try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) - - // The retired posture, asserted where it used to bite: a `.git` at a board root was **inert** - // off Pro — never read, never written, the popover's one-line Pro pointer that board's whole - // story. Detection runs at every board open on every tier now (12-editions.md ▸ PIVOT - // 2026-08-07), so this composes in git mode with no tier asked for. - let git = HistoryStore.compose(boardRoot: fixture.root) - #expect(git.mode == .git) - - // And what these three bytes actually are is a repository libgit2 will not open, so the board - // takes **06's corrupt-`.git` posture, not the retired inert one** (06 ▸ Rules, ruled - // 2026-07-31): git mode throughout — never a fall to mode none that would offer add-git - // against an existing `.git` — with no setup block, because every control there would write - // into something the app cannot open. - #expect(git.isRepositoryUnreadable) - #expect( - BoardGitSetupSection.resolve( - mode: git.mode, - isRepositoryUnreadable: git.isRepositoryUnreadable - ) == [] - ) - } - - @Test("A fresh mode-none board resolves to the add-git offer") - func aFreshBoardOffersAddGit() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - try fixture.item("", Item.board) - - // Composed with no tier in sight: git is tier-independent since 12-editions.md ▸ PIVOT - // 2026-08-07, so this is every session's git state, not Pro's. - let git = HistoryStore.compose(boardRoot: fixture.root) - #expect(git.mode == .none) - #expect( - BoardGitSetupSection.resolve( - mode: git.mode, - isRepositoryUnreadable: git.isRepositoryUnreadable - ) == [.addGit] - ) - } -} diff --git a/KanbanTests/BoardInfoPopoverTests.swift b/KanbanTests/BoardInfoPopoverTests.swift deleted file mode 100644 index 796162e..0000000 --- a/KanbanTests/BoardInfoPopoverTests.swift +++ /dev/null @@ -1,170 +0,0 @@ -import Foundation -import Testing -@testable import Kanban - -/// **The popover's git slot, posture by posture** (03-board-ui.md ▸ Board popover ▸ Git tab; -/// 06-history-undo.md ▸ Rules). -/// -/// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the board's -/// **mode** precisely so the matrix is assertable — the views it selects are SwiftUI and stay -/// untested. -/// -/// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07** (03's Git-tab note records the -/// consequence): git left the paywall, so the two free-tier postures — `.absent` on an ordinary -/// board and the `.proPointer` on one carrying an inert `.git`, both settled 2026-07-27 — described -/// a gate that no longer exists and retired with it. With them went the `.git`-on-disk probe that -/// fed them (`BoardGitNote.hasGitDirectory(at:)`) and the tab-strip membership filter that read -/// `.absent` (`BoardInfoTab.available`, whose suite retired here the same day): detection now runs at -/// every board open on every tier, and the mode is the one input left. -@Suite("Board popover ▸ the git section's posture") -struct BoardGitSectionTests { - - @Test("Mode none has no repository to describe, git mode shows the branch") - func thePostureFollowsTheMode() { - #expect(BoardGitSection.resolve(mode: .none) == .noRepository) - #expect(BoardGitSection.resolve(mode: .git) == .branch) - } - - @Test("A repo-nested board explains itself — setup is absent, not disabled") - func repoNestedExplainsRatherThanDisables() { - let section = BoardGitSection.resolve(mode: .repoNested) - - // The design is insistent here: "not a hidden 'add git' but a short explanation … the option - // is absent because it *can't* apply, and the UI should teach that rather than look broken" - // (06 ▸ Rules). A posture that rendered a disabled setup control would satisfy neither half — - // and since the 2026-07-31 split the same sentence is what keeps the settings sheet itself - // out of reach on such a board (`BoardSettingsAvailabilityTests`). - #expect(section == .repoNested) - #expect(section != .noRepository) - } - - @Test("An unverifiable board gets its own posture — structurally like nested, never the same case") - func unverifiableIsItsOwnPostureNotRepoNested() { - // "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check - // is not a found repository, so the two must resolve to different cases even though both are - // action-less, prose-only sections. - let section = BoardGitSection.resolve(mode: .unverifiable) - - #expect(section == .unverifiable) - #expect(section != .repoNested, "a denial is not a nesting") - #expect(section != .noRepository) - } - - /// **A board whose repository will not open keeps the git section it has** (06 ▸ Rules, the - /// corrupt-`.git` loud failure, ruled 2026-07-31) — the *branch* section, held and explaining - /// itself, never the mode-none posture that would imply a board with no history to have. - /// - /// The section case is deliberately blind to readability: what changes on such a board is what - /// the branch surface inside it says (`BoardGitBranchSurface.resolve`, whose broken presentation - /// and own sentence `BoardGitBranchSurfaceTests` pins), not which section the popover shows. The - /// posture matrix stays a function of the mode alone. - @Test("An unreadable repository is still the branch section — never the no-repository posture") - func anUnreadableRepositoryKeepsTheBranchSection() { - let section = BoardGitSection.resolve(mode: .git) - - #expect(section == .branch) - #expect(section != .noRepository, "the board has a repository; it is unreadable, not absent") - } - - @Test("Every posture is reachable, and none of them is two postures") - func theMatrixIsTotal() { - let resolved = Set(BoardGitMode.allCases.map { BoardGitSection.resolve(mode: $0) }) - - // One posture per mode, and every posture spoken for — which is also what makes the tab - // strip's membership rule trivially true since the pivot: no mode resolves to nothing, so - // the Git tab is never dropped (03-board-ui.md ▸ Board popover, the pivot note). - #expect(resolved == Set(BoardGitSection.allCases)) - #expect(resolved.count == BoardGitMode.allCases.count, "no two modes share a posture") - } -} - -/// **The tab strip carries every tab, always** (03-board-ui.md ▸ Board popover, the tabbed- -/// popover paragraph as the 2026-08-07 pivot note leaves it): "the absent posture is unreachable and -/// every board carries all three tabs. The membership rule stands structurally — the strip still asks -/// the posture — it just never hears 'absent' anymore." (Three became four the same night: **Sync** -/// joined as a standing placeholder, membership still `allCases`.) -/// -/// So there is no `available(…)` seam left to pin; what is worth keeping is the strip's *order*, -/// which the popover's `Picker` takes from `allCases` and which the Git session fixed: Info first — -/// it is the board's face and the tab selection resets to it on every open — then Theme, then Git, -/// then the Sync placeholder at the end of the line, where a surface with nothing in it belongs. -@Suite("Board popover ▸ the tab strip") -struct BoardInfoTabStripTests { - - @Test("Info, Theme, Git, Sync — in that order, on every board") - func theStripIsTheWholeSet() { - #expect(BoardInfoTab.allCases == [.info, .theme, .git, .sync]) - } - - @Test("The raw values are the segmented control's own labels") - func labelsAreTheRawValues() { - // The strip needs no separate label function, which is the only reason this enum is - // `String`-backed at all. - #expect(BoardInfoTab.allCases.map(\.rawValue) == ["Info", "Theme", "Git", "Sync"]) - } -} - -/// **The window-title widget's two strings** (03-board-ui.md ▸ Board popover, the card that widened -/// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure -/// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its mode -/// input with. No disk I/O — the title half of this seam takes a raw `URL`, not a `BoardStore`, so a -/// plain `/tmp/...` path is enough. -/// -/// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07.** The branch rule read `tier == -/// .pro && mode == .git` until that day; git is tier-independent now, so a git-mode board shows its -/// branch and nothing else does. -@Suite("Board popover ▸ the widget's strings") -struct BoardInfoTitlebarSummaryTests { - - private let root = URL(fileURLWithPath: "/tmp/My Board.board") - - @Test("The title is the on-disk title, falling back to the folder name sans extension") - func titleFallsBackToTheFolderName() { - let named = BoardInfoTitlebarSummary( - snapshotTitle: "Sprint 12", rootURL: root, mode: .none, branch: nil - ) - #expect(named.title == "Sprint 12") - - let untitled = BoardInfoTitlebarSummary( - snapshotTitle: nil, rootURL: root, mode: .none, branch: nil - ) - #expect(untitled.title == "My Board") - - let emptyTitled = BoardInfoTitlebarSummary( - snapshotTitle: "", rootURL: root, mode: .none, branch: nil - ) - #expect( - emptyTitled.title == "My Board", - "an empty title reads the same as no title at all — the window-title rule this seam restates" - ) - } - - @Test("The branch shows in git mode, once it has been read") - func branchShowsInGitMode() { - let summary = BoardInfoTitlebarSummary( - snapshotTitle: nil, rootURL: root, mode: .git, branch: "main" - ) - #expect(summary.branch == "main") - } - - @Test("No other mode shows a branch, whatever the git state hands it") - func onlyGitModeShowsABranch() { - for mode in BoardGitMode.allCases where mode != .git { - let summary = BoardInfoTitlebarSummary( - snapshotTitle: nil, rootURL: root, mode: mode, branch: "main" - ) - #expect( - summary.branch == nil, - "mode \(mode): a board the app manages no repository for must never surface a stray branch value" - ) - } - } - - @Test("A git-mode board whose branch has not been read yet shows none, honestly") - func unreadBranchShowsNone() { - let summary = BoardInfoTitlebarSummary( - snapshotTitle: nil, rootURL: root, mode: .git, branch: nil - ) - #expect(summary.branch == nil) - } -} diff --git a/KanbanTests/BranchSwitchTests.swift b/KanbanTests/BranchSwitchTests.swift deleted file mode 100644 index 9730101..0000000 --- a/KanbanTests/BranchSwitchTests.swift +++ /dev/null @@ -1,1230 +0,0 @@ -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 = HistoryStore.compose(boardRoot: fixture.root, 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") - } - - /// The same refusal through the **real** pause rather than an injected one: an unreadable - /// repository is a `GitRepositoryPause` like any other, so the switch's existing gate closes on - /// it with nothing added here (06-history-undo.md ▸ Rules, the corrupt-`.git` loud failure, ruled - /// 2026-07-31: "the whole git surface paused"). - @Test("A switch refuses against a repository the app cannot open, and changes nothing") - @MainActor - func anUnreadableRepositoryRefusesTheSwitch() 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 } - - let head = fixture.root.appendingPathComponent(".git/HEAD") - let savedHead = try Data(contentsOf: head) - try FileManager.default.removeItem(at: head) - await git.committer?.refreshPause() - #expect(git.isRepositoryUnreadable) - - #expect(await switcher.switchTo("redesign") == false) - #expect(asked == 0, "no modal, no stamp, no checkout — nothing is attempted at all") - #expect(switcher.lastFailure == nil, "a pause is not a failure; the standing banner is the message") - - // The repository is exactly as it was found, which is the promise the row makes. - try savedHead.write(to: head) - #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.switchTargets`), 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) - } - - /// **The corrupt-`.git` loud failure at this one control** (06-history-undo.md ▸ Rules, ruled - /// 2026-07-31: "Never a silent placeholder discovered only in the popover"). The bug this pins - /// against is the shipped one: `GitRepository.branchName` answers `nil` on a repository it cannot - /// open, and the placeholder meant "still reading" — forever. - @Test("An unreadable repository reads as broken, never as still loading") - func anUnreadableSurface() { - let surface = BoardGitBranchSurface.resolve( - branch: nil, - pause: .unreadable, - isSwitching: false, - isWritable: true - ) - - #expect(surface.branchLabel == BoardGitBranchSurface.unavailableLabel) - #expect(surface.branchLabel != BoardGitBranchSurface.placeholder) - #expect(!surface.isReadingBranch, "there is nothing in flight to be waiting for") - #expect(surface.isRepositoryUnreadable) - #expect(!surface.controlsEnabled, "the whole git surface is paused") - #expect(surface.accessibilityLabel == "Branch unavailable") - - // The section's own sentence — a sibling of the popover's nested and unverifiable notes, and - // not the pause note, whose second line would be advice about an operation nobody started. - #expect(BoardGitBranchSurface.unreadableNote - == "Lanework can't read this board's git repository, so history is paused; the repository is left untouched.") - } - - @Test("A branch name read before the repository broke does not survive the pause") - func anUnreadableSurfaceDropsAStaleBranch() { - let surface = BoardGitBranchSurface.resolve( - branch: "main", - pause: .unreadable, - isSwitching: false, - isWritable: true - ) - - #expect(surface.branchLabel == BoardGitBranchSurface.unavailableLabel, - "the line must not go on naming a branch nothing can read") - } - - @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(mode: .git) == .branch) - } -} - -// MARK: - The switch picker's targets - -/// **What the branch menu offers, and what it says when that is nothing** (03-board-ui.md ▸ Board -/// popover ▸ Git tab). -/// -/// `BoardGitControls.switchTargets` is the filter as a pure function, pinned here for the reason -/// `BoardGitBranchSurface.resolve` is pinned above: the rule is the part worth asserting, and the -/// `Menu` it fills is SwiftUI. Its emptiness is load-bearing since 2026-08-06 — with branch -/// *creation* relocated to the settings sheet, an empty result is what puts the disabled "No other -/// branches" row on screen in place of a menu that would otherwise open onto nothing and read as -/// broken. -@Suite("Board popover ▸ the switch picker's targets") -struct BranchSwitchTargetTests { - - @Test("The branch already checked out is never offered — switching to it would be a no-op") - func theCurrentBranchIsExcluded() { - #expect( - BoardGitControls.switchTargets(branches: ["main", "redesign", "spike"], current: "main") - == ["redesign", "spike"] - ) - } - - @Test("A single-branch board has no targets at all — which is what the disabled row is for") - func aSingleBranchBoardHasNoTargets() { - #expect(BoardGitControls.switchTargets(branches: ["main"], current: "main").isEmpty) - // The shape a repository with an unborn HEAD is in: no branches listed at all. - #expect(BoardGitControls.switchTargets(branches: [], current: "main").isEmpty) - } - - @Test("Before the first branch read every branch is a candidate, rather than none") - func anUnreadCurrentPassesEverythingThrough() { - // `HistoryStore.branch` is `nil` until `refreshBranch()` answers, which is a moment the - // popover is on screen for. Filtering everything out there would be a menu that starts empty - // and fills in — the switch's own gate is what decides whether a target can be taken. - #expect( - BoardGitControls.switchTargets(branches: ["main", "redesign"], current: nil) - == ["main", "redesign"] - ) - } - - @Test("The repository's own order survives — the picker never re-sorts what libgit2 listed") - func orderIsPreserved() { - #expect( - BoardGitControls.switchTargets(branches: ["zeta", "alpha", "main", "beta"], current: "main") - == ["zeta", "alpha", "beta"] - ) - } -} - -// 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)) - } -} diff --git a/KanbanTests/GitUndoTests.swift b/KanbanTests/GitUndoTests.swift deleted file mode 100644 index 02b212f..0000000 --- a/KanbanTests/GitUndoTests.swift +++ /dev/null @@ -1,1517 +0,0 @@ -import Foundation -import SwiftGitX -import Testing -@testable import Kanban - -/// **Undo and redo as forward commits** (06-history-undo.md; 14-git-operations.md ▸ The forward-restore -/// model) — the stack that *is* HEAD's first-parent ancestry, the restores that only ever move history -/// forward, heal transparency, the save-or-discard gate, the provider binding, and the card window's -/// read-only trail. -/// -/// Every repository here is a **real** one, built through the app's own add-git over bundled libgit2, -/// and every claim about the trail is read back through libgit2 rather than through the code that made -/// it. Nothing shells out to `git` (`AutoCommitTests`' rule, kept). - -// MARK: - Fixtures - -/// A card's `index.md` — `AutoCommitTests`' helper, file-private there and here. -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, ledger: EchoLedger) { - let fixture = try makeBoard() - let ledger = EchoLedger() - let git = HistoryStore.compose(boardRoot: fixture.root, ledger: ledger) - #expect(await git.addGit()) - return (fixture, git, ledger) -} - -/// A synthetic native step that records its own crossing — `HistoryProviderTests`' fixture, file-private -/// there and here. What the binding suite needs it for is the *discard*: a step that would announce -/// itself loudly if the swap ever ran it. -@MainActor -private final class StepLog { - - private(set) var crossings: [String] = [] - - func step(_ name: String) -> HistoryStep { - HistoryStep( - name: name, - undo: { [weak self] _ in - self?.crossings.append("undo \(name)") - return .applied - }, - redo: { [weak self] _ in - self?.crossings.append("redo \(name)") - return .applied - } - ) - } -} - -@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 provider wired to a real committer the way `AppModel.wireGitUndo` wires one, minus the seams -/// that need windows (the settle gate) and a store (the bracket) — those get their own suites. -@MainActor -private func makeProvider( - _ fixture: WriterFixture, - _ git: HistoryStore, - committer: GitAutoCommitter -) async -> GitHistoryProvider { - let provider = GitHistoryProvider(boardRoot: fixture.root) - provider.flushPendingCommit = { [weak committer] in await committer?.flushNow() } - provider.isHeld = { [weak committer] in committer?.pause != nil } - provider.suspendCommitting = { [weak committer] in committer?.stop() } - provider.resumeCommitting = { [weak committer] in committer?.start() } - committer.reportLanded = { [weak provider] window in provider?.noteLanded(window) } - await provider.reseed() - return provider -} - -/// Commits the pending window and waits for the stack to have heard about it — production validates -/// a menu a turn later; a test asserts the instant the flush returns. -@MainActor -private func commitAndSettle(_ committer: GitAutoCommitter, _ provider: GitHistoryProvider) async { - await committer.flushNow() - await provider.settled() -} - -// MARK: Reading the trail back - -private struct TrailCommit: Equatable { - let oid: String - let subject: String - let parents: Int -} - -/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the provider. -private func trail(at boardRoot: URL, limit: Int = 64) throws -> [TrailCommit] { - let repository = try Repository.open(at: boardRoot) - guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] } - - var records: [TrailCommit] = [] - var current: Commit? = tip - while let commit = current, records.count < limit { - records.append(TrailCommit( - oid: commit.id.hex, - subject: commit.summary, - parents: (try? commit.parents)?.count ?? 0 - )) - current = (try? commit.parents)?.first - } - return records -} - -private func subjects(at boardRoot: URL) throws -> [String] { - try trail(at: boardRoot).map(\.subject) -} - -/// Every line of `.git/logs/HEAD`, as (old oid, new oid, message) — the trail's own record of how -/// the reference moved, which is where a reset or a force would be visible if one had happened. -private func reflog(at boardRoot: URL) throws -> [(old: String, new: String, message: String)] { - let url = boardRoot.appendingPathComponent(".git/logs/HEAD") - guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] } - return text.split(separator: "\n").compactMap { line in - let fields = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false) - let head = fields[0].split(separator: " ") - guard head.count >= 2 else { return nil } - return (String(head[0]), String(head[1]), fields.count > 1 ? String(fields[1]) : "") - } -} - -/// An `AppModel` over its own scratch registry — `HistoryStoreTests`' helper, file-private there. -@MainActor -private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { - let folder = FileManager.default.temporaryDirectory - .appendingPathComponent("GitUndoTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - let model = AppModel( - registryStorageURL: folder.appendingPathComponent("board-registry.json"), - clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) - ) - return (model, { try? FileManager.default.removeItem(at: folder) }) -} - -@MainActor -@discardableResult -private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef { - let ref = BoardWindowRef(url: url) - let recordID = model.boardRegistry.recordOpen(of: url) - let store = try model.storeRegistry.acquire(url) - model.boardRegistry.setOpenNow(id: recordID) - model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) - return ref -} - -private func title(ofCard folder: String, at boardRoot: URL) -> String? { - let url = boardRoot.appendingPathComponent(folder).appendingPathComponent("index.md") - guard let text = try? String(contentsOf: url, encoding: .utf8), - let document = try? FrontmatterDocument.parse(text) else { return nil } - return document.rawValue(for: "title")?.trimmingCharacters(in: .whitespacesAndNewlines) -} - -// MARK: - The stack is HEAD's ancestry - -@MainActor -@Suite("Git undo ▸ the stack is HEAD's first-parent ancestry") -struct GitUndoStackTests { - - @Test("A fresh board seeds from HEAD, with an empty redo") - func seedingReadsTheAncestry() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - let provider = await makeProvider(fixture, git, committer: committer) - - // One commit only — the root — which is deliberately not a step. - #expect(provider.ancestry.count == 1) - #expect(provider.canUndo == false, "the board's existence is not an undo step") - #expect(provider.canRedo == false, "redo starts empty") - - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - #expect(provider.ancestry.count == 2) - #expect(provider.canUndo, "the commit that just landed is the step") - #expect(provider.undoActionName == "Add card 'Second'") - } - - @Test("The menu label is the crossed commit's own subject, so labels never nest") - func theLabelIsTheCrossedSubject() 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - #expect(provider.undoActionName == "Add card 'Second'") - await provider.cross(.undo) - - // HEAD is now "Undo: Add card 'Second'" — but the label is the *next* crossable commit's, - // which is the root, and the root is not a step. Nothing nested (06 ▸ Commit messages). - #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'") - #expect(provider.undoActionName == nil) - #expect(provider.redoActionName == "Add card 'Second'") - } - - @Test("A commit landing from anywhere clears the redo stack") - func anArrivalClearsRedo() 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - await provider.cross(.undo) - #expect(provider.canRedo) - - // Something else commits — an agent's work arriving through the watcher. - try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Third")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - #expect(provider.canRedo == false, "classic behavior: an arrival clears redo") - #expect(provider.undoActionName == "Add card 'Third'", "and becomes the new top step") - } - - @Test("A self-commit made outside the app is learned by the pre-flight sync — ⌘Z steps back one") - func thePreflightSyncLearnsForeignCommits() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - let provider = await makeProvider(fixture, git, committer: committer) - - // An agent commits its own work: the tree moves and HEAD moves, with no signal to the app. - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - let identity = GitIdentity(name: "Agent", email: "agent@agents.lanework.invalid") - let landed = GitCommitOperation.perform( - at: fixture.root, - commits: [PlannedCommit( - paths: GitCommitOperation.changedPaths(at: fixture.root).map(\.path), - message: "Add card 'Second'", - author: identity, - committer: identity - )] - ) - guard case .committed = landed else { - Issue.record("the agent's own commit did not land") - return - } - #expect(provider.canUndo == false, "the cached stack has not heard about it yet") - - await provider.cross(.undo) - - // Exactly one step back: the agent's commit, not the whole session. - #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'") - #expect(try trail(at: fixture.root).count == 3, "root, the agent's commit, the restore") - } - - @Test("clear() drops the cache and leaves the repository untouched") - func clearingIsACacheDrop() 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - #expect(provider.canUndo) - - provider.clear() - #expect(provider.canUndo == false) - #expect(try trail(at: fixture.root).count == 2, "the trail is where it was") - - await provider.reseed() - #expect(provider.canUndo, "and reseeding finds it again") - } -} - -// MARK: - Forward commits, never a rewrite - -@MainActor -@Suite("Git undo ▸ restores are forward commits") -struct GitUndoForwardTests { - - @Test("Undo restores an earlier state as a new commit — refs only move forward") - func undoIsAForwardCommit() 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) - let before = try trail(at: fixture.root) - #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed") - - await provider.cross(.undo) - - let after = try trail(at: fixture.root) - // **The trail only grew.** Every commit that existed before the undo is still in HEAD's - // ancestry, in the same order, with the restore on top — which is precisely what a reset or - // a force could not produce. - #expect(after.count == before.count + 1) - #expect(Array(after.dropFirst()) == before) - #expect(after.first?.subject == "Undo: Rename card 'First' → 'Renamed'") - #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First") - } - - @Test("Redo restores forward again, as another new commit") - func redoIsAForwardCommitToo() 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) - - await provider.cross(.undo) - #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First") - - await provider.cross(.redo) - #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed") - - let subjects = try subjects(at: fixture.root) - #expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'") - #expect(subjects.count == 4, "root, rename, undo, redo — four commits, none rewritten") - #expect(provider.canRedo == false, "the redo list is spent") - #expect(provider.undoActionName == "Rename card 'First' → 'Renamed'", "and ⌘Z crosses it again") - } - - @Test("The reflog only ever appends — no reset, no force") - func theReflogOnlyAppends() 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) - await provider.cross(.undo) - await provider.cross(.redo) - - // **Trail inspection, as any git client would do it.** libgit2 writes one reflog entry per - // reference movement, naming the tip before and the tip after. Three facts together are the - // whole of "never reset, never force": - let entries = try reflog(at: fixture.root) - #expect(entries.count == 4, "root, rename, undo, redo — one entry per commit, nothing else") - - // 1. Every movement is a *commit*. A reset writes "reset: …", a checkout "checkout: …", a - // force-fetch "update by push" — none of which the app can produce, and none of which is - // here. - #expect(entries.allSatisfy { $0.message.hasPrefix("commit") }) - - // 2. The chain is unbroken: each entry's `old` is the previous entry's `new`, so the branch - // never jumped sideways or backwards. - for (index, entry) in entries.enumerated() where index > 0 { - #expect(entry.old == entries[index - 1].new) - } - - // 3. Every tip the reference ever held is still in HEAD's first-parent ancestry — old commits - // stay reachable, which is precisely what a rewrite would destroy. - let reachable = Set(try trail(at: fixture.root).map(\.oid)) - #expect(entries.dropFirst().allSatisfy { reachable.contains($0.old) }) - #expect(entries.allSatisfy { reachable.contains($0.new) }) - - let repository = try Repository.open(at: fixture.root) - #expect(try repository.HEAD.name == GitRepository.initialBranchName, "and the branch is the same one") - } - - @Test("A restore materializes only the diff — an untouched card's file is not rewritten") - func onlyTheDiffIsMaterialized() 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - // A third card, uncommitted and untouched by the restore's diff. - try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Bystander")) - let bystander = fixture.root - .appendingPathComponent("\(Ident.lane1)/\(Ident.card3)/index.md") - let before = try Data(contentsOf: bystander) - - let root = try #require(provider.ancestry.last?.oid) - let plan = try #require(GitRestoreOperation.plan(at: fixture.root, target: root)) - #expect(plan.paths.allSatisfy { $0.contains(Ident.card2) }, - "the plan names only what differs between the two commits") - #expect(try Data(contentsOf: bystander) == before, "and the bystander's bytes are its own") - } - - @Test("A card the undo removes takes its emptied folder with it") - func aRemovedCardLeavesNoEmptyFolder() 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - await provider.cross(.undo) - - let folder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card2)") - #expect(FileManager.default.fileExists(atPath: folder.path) == false) - #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, - "and the tree is clean — the restore committed everything it wrote") - } - - @Test("A pending auto-commit flushes before the restore, so both states are commits") - func theRestoreSettlesTheTreeFirst() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - let provider = await makeProvider(fixture, git, committer: committer) - - // A change that has *not* been committed when ⌘Z arrives. - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - - await provider.cross(.undo) - - let subjects = try subjects(at: fixture.root) - // The pending window became its own commit first — so the state ⌘Z stepped back *from* - // exists in the trail — and the restore sits on top of it. - #expect(subjects.first == "Undo: Add card 'Second'") - #expect(subjects.dropFirst().first == "Add card 'Second'") - } -} - -// MARK: - Restore subjects compose the inverse - -/// **"Subjects don't nest either — crossing a restore composes the inverse"** (06-history-undo.md ▸ -/// Commit messages, settled 2026-07-31). The composer is a pure function of the crossed subject and -/// the direction (`GitHistoryProvider.restoreSubject(_:crossing:)`), so most of this suite needs no -/// repository at all — and the one test that does is the case the rule exists for: the relaunch that -/// turns yesterday's restore commit into an ordinary step. -@MainActor -@Suite("Git undo ▸ restore subjects compose the inverse") -struct GitUndoRestoreSubjectTests { - - @Test("An ordinary subject takes one prefix, per direction") - func anOrdinarySubjectNestsOnce() { - #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Move card 'Fix login' to Doing") - == "Undo: Move card 'Fix login' to Doing") - #expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Move card 'Fix login' to Doing") - == "Redo: Move card 'Fix login' to Doing") - } - - @Test("Undoing across a restore emits the inverse label, not a second prefix") - func undoingARestoreInverts() { - // 06's own two examples: "crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields - // 'Undo: S'" — because an undo restores the crossed commit's *parent*, the state that - // commit took away. - #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Move card 'X'") - == "Redo: Move card 'X'") - #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Move card 'X'") - == "Undo: Move card 'X'") - } - - @Test("Redoing across a restore restates it — the mirror of the undo rule, not a copy of it") - func redoingARestoreRestates() { - // A redo restores the target commit *itself*, so the label the new commit carries is that - // commit's own reading: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out. - // Emitting "Redo: S" there — the label the ⌘Z that crossed it already used, for the opposite - // tree — would be the euphemism 06 rules out. - #expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Move card 'X'") - == "Undo: Move card 'X'") - #expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Redo: Move card 'X'") - == "Redo: Move card 'X'") - } - - @Test("A legacy double prefix reads as two flips, and comes out carrying one") - func theLegacyDoublePrefixReadsAsTwoFlips() { - // **The honest reading of a commit the shipped nesting build made.** "Undo: Undo: S" undid - // the commit that undid S, so its tree is the one where S is *in*. Undoing across it puts S - // back out — "Undo: S" — which is what 06's "the truer label, not a euphemism" asks for; - // "Redo: S" would claim the opposite tree, and "Redo: Undo: S" would keep the nesting the - // ruling caps at one ("it caps prefixes at one across any number of relaunches"). - #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Undo: Move card 'X'") - == "Undo: Move card 'X'") - #expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Undo: Move card 'X'") - == "Redo: Move card 'X'") - // The legacy redo's shape reads the same way: "Redo:" restates whatever follows it. - #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Undo: Move card 'X'") - == "Redo: Move card 'X'") - // And any depth caps at one, which is the property the ruling actually claims — the reading - // is the parity of the "Undo:"s (two here, so the tree has the move in it) and never the - // depth of the stack. - #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Redo: Undo: Move card 'X'") - == "Undo: Move card 'X'") - } - - @Test("A prefix with nothing after it is somebody's subject, not a label") - func aBarePrefixIsASubject() { - // The sniff is on the subject string (06), and a subject that is *only* a prefix has no base - // to talk about — stripping it would compose "Undo: " with nothing, naming no change at all. - #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: ") == "Undo: Undo: ") - // Foreign subjects that merely look like prefixes are unaffected — the match is exact. - #expect(GitHistoryProvider.restoreSubject(.undo, crossing: "undo: fix the build") - == "Undo: undo: fix the build") - } - - @Test("After a relaunch, ⌘Z over yesterday's restore commits the inverse — the trail never nests") - func theRelaunchCaseLandsTheInverse() 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) - - await provider.cross(.undo) - #expect(try subjects(at: fixture.root).first == "Undo: Rename card 'First' → 'Renamed'") - - // **The relaunch**, which is what `reseed()` is: the stack starts again at HEAD with an empty - // redo, so the restore commit above is now an ordinary step the pointer sits on. - await provider.reseed() - #expect(provider.undoActionName == "Undo: Rename card 'First' → 'Renamed'", - "the menu label is still the crossed commit's own subject — labels never nested") - - await provider.cross(.undo) - - let subjects = try subjects(at: fixture.root) - #expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'", - "the trail says what the restore did: the rename is back") - #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed") - } -} - -// MARK: - Heal transparency - -@MainActor -@Suite("Git undo ▸ heal commits are transparent") -struct GitUndoHealTransparencyTests { - - @Test("The pointer passes over a heal commit and crosses the step beneath it") - func thePointerPassesOverAHeal() async throws { - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - let provider = await makeProvider(fixture, git, committer: committer) - - // An ordinary step. - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - // A heal: the app's own repair, vouched for by a heal-marked receipt. - let healed = fixture.root.appendingPathComponent(AgentGuide.filename) - try "\nrepaired\n".write(to: healed, atomically: true, encoding: .utf8) - ledger.recordWrite(at: healed, data: try Data(contentsOf: healed)) - ledger.markHeal(at: healed) - committer.noteWriteBracketClosed() - await commitAndSettle(committer, provider) - - #expect(try trail(at: fixture.root).count == 3, "root, the add, the heal") - // The heal is on top of the trail, and the label names the *add* beneath it. - #expect(provider.undoActionName == "Add card 'Second'", "the heal is not a step") - - await provider.cross(.undo) - #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'") - } - - @Test("A restore excludes the paths a heal repaired — the repair survives the undo") - func aRestoreExcludesHealedPaths() async throws { - let (fixture, git, ledger) = 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - let guide = fixture.root.appendingPathComponent(AgentGuide.filename) - try "\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8) - ledger.recordWrite(at: guide, data: try Data(contentsOf: guide)) - ledger.markHeal(at: guide) - committer.noteWriteBracketClosed() - await commitAndSettle(committer, provider) - - // ⌘Z crosses the add — whose parent is the root commit, which predates the guide entirely. - // Without the exclusion the restore would delete the healer's file and re-arm it. - await provider.cross(.undo) - - #expect(FileManager.default.fileExists(atPath: guide.path), - "the repair is never reverted (06 ▸ Rules ▸ Heal commits are transparent)") - let text = try String(contentsOf: guide, encoding: .utf8) - #expect(text.contains("repaired")) - } - - @Test("A heal landing mid-run leaves the pointer and the redo list alone") - func aHealDoesNotTrapAnUndoRun() async throws { - let (fixture, git, ledger) = 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - await provider.cross(.undo) - #expect(provider.canRedo, "a step is on the redo list") - - // The healer runs. An ordinary arrival would clear redo and trap the run on a renewing top. - let guide = fixture.root.appendingPathComponent(AgentGuide.filename) - try "\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8) - ledger.recordWrite(at: guide, data: try Data(contentsOf: guide)) - ledger.markHeal(at: guide) - committer.noteWriteBracketClosed() - await commitAndSettle(committer, provider) - - #expect(provider.canRedo, "transparency dissolves the trap rather than suppressing the healer") - #expect(provider.redoActionName == "Add card 'Second'") - } - - @Test("A window's landed commits are reported with their classes") - func theCommitterReportsClasses() async throws { - let (fixture, git, ledger) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - var seen: [GitLandedWindow] = [] - committer.reportLanded = { seen.append($0) } - - // One window carrying a heal and a foreign change: two commits, two classes. - let guide = fixture.root.appendingPathComponent(AgentGuide.filename) - try "\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8) - ledger.recordWrite(at: guide, data: try Data(contentsOf: guide)) - ledger.markHeal(at: guide) - committer.noteWriteBracketClosed() - try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - let window = try #require(seen.first) - #expect(window.commits.count == 2) - #expect(window.healOIDs.count == 1) - #expect(window.healPaths.contains(AgentGuide.filename)) - #expect(window.isEntirelyHeal == false) - } -} - -// MARK: - The abnormal-state pause - -@MainActor -@Suite("Git undo ▸ the held repository disables the pair") -struct GitUndoHoldTests { - - @Test("A held repository disables Undo and Redo") - func aHeldRepositoryDisablesThePair() 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - #expect(provider.canUndo) - - // A merge left by outside-the-app git — 06 ▸ Rules ▸ Abnormal repo states: the *whole* git - // surface pauses, "Undo/Redo and the branch controls disable". - let marker = fixture.root.appendingPathComponent(".git/MERGE_HEAD") - try "0000000000000000000000000000000000000000\n".write(to: marker, atomically: true, encoding: .utf8) - try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Third")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - #expect(committer.pause == .merge) - - #expect(provider.canUndo == false) - #expect(provider.canRedo == false) - - let before = try trail(at: fixture.root) - await provider.cross(.undo) - #expect(try trail(at: fixture.root) == before, "and a crossing that started anyway writes nothing") - } -} - -// MARK: - The save-or-discard gate - -@MainActor -@Suite("Git undo ▸ the save-or-discard step") -struct SessionSettleGateTests { - - /// A settleable session a test drives by hand. - @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 - } - ) - } - } - - @Test("A diff that touches no session card never asks") - func anUntouchedSessionIsNeverAsked() async throws { - let session = FakeSession() - var asked = 0 - let gate = SessionSettleGate( - sessions: { [session.descriptor(id: "card-a", folder: "card-a")] }, - ask: { asked += 1; return .cancel } - ) - - let outcome = await gate.settle(touching: ["lane-1/card-b/index.md"]) - - #expect(outcome == .proceed) - #expect(asked == 0, "most undos never meet an editor at all") - #expect(session.saves == 0) - } - - @Test("A clean session in the diff's path is not asked about either") - func aSettledSessionIsNotAsked() async throws { - let session = FakeSession() - session.isSettled = true - var asked = 0 - let gate = SessionSettleGate( - sessions: { [session.descriptor(id: "card-a", folder: "card-a")] }, - ask: { asked += 1; return .cancel } - ) - - #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed) - #expect(asked == 0) - } - - @Test("Save All ends every touched session; Cancel keeps everything") - func theThreeAnswers() async throws { - let first = FakeSession() - let second = FakeSession() - let descriptors = { - [ - first.descriptor(id: "card-a", folder: "card-a"), - second.descriptor(id: "card-b", folder: "card-b") - ] - } - - var choice = SessionSettleChoice.cancel - let gate = SessionSettleGate(sessions: descriptors, ask: { choice }) - - #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .cancelled) - #expect(first.saves == 0) - #expect(first.discards == 0, "Cancel keeps everything") - - choice = .saveAll - #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed) - #expect(first.saves == 1) - #expect(second.saves == 0, "only the sessions the diff reaches") - - choice = .discard - #expect(await gate.settle(touching: ["lane-2/card-b/index.md"]) == .proceed) - #expect(second.discards == 1) - } - - @Test("A raw buffer that will not validate cancels the whole operation, focused on the offender") - func aFailingBufferCancelsEverything() async throws { - let good = FakeSession() - let bad = FakeSession() - bad.saveSucceeds = false - - var focused: [String] = [] - let gate = SessionSettleGate( - sessions: { - [ - good.descriptor(id: "card-a", folder: "card-a"), - bad.descriptor(id: "card-b", folder: "card-b") - ] - }, - ask: { .saveAll }, - focus: { focused.append($0) } - ) - - let outcome = await gate.settle(touching: [ - "lane-1/card-a/index.md", - "lane-1/card-b/index.md" - ]) - - #expect(outcome == .failed("card-b")) - #expect(focused == ["card-b"], "focus on the offending window") - #expect(bad.isSettled == false) - } - - @Test("Folder matching is component-exact — a prefix does not borrow another card's session") - func matchingIsComponentExact() throws { - let session = FakeSession() - let descriptors = [session.descriptor(id: "card-1", folder: "card-1")] - - #expect(SessionSettleGate.reached(by: ["lane/card-1/index.md"], among: descriptors).count == 1) - #expect(SessionSettleGate.reached(by: ["lane/card-10/index.md"], among: descriptors).isEmpty) - #expect(SessionSettleGate.reached(by: ["card-1"], among: descriptors).isEmpty, - "the folder itself is not a file inside it") - } -} - -// MARK: - The restore meeting a session - -@MainActor -@Suite("Git undo ▸ restores meet open sessions") -struct GitUndoSessionTests { - - @Test("A restore whose diff misses every session runs untouched") - func anUnrelatedSessionIsUndisturbed() 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.card2)", plain(order: "2048", title: "Second")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - var asked = 0 - provider.settleSessions = { paths in - let gate = SessionSettleGate( - sessions: { - [SettleableSession( - id: Ident.card1, - cardFolderName: Ident.card1, - needsSettling: { true }, - saveAll: { true }, - discard: {} - )] - }, - ask: { asked += 1; return .cancel } - ) - return await gate.settle(touching: paths) - } - - await provider.cross(.undo) - - #expect(asked == 0, "the diff names card 2 only") - #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'") - } - - @Test("Cancel at the step leaves the tree and the stack exactly as they were") - func cancelStopsTheRestore() 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) - let before = try trail(at: fixture.root) - - provider.settleSessions = { _ in .cancelled } - await provider.cross(.undo) - - #expect(try trail(at: fixture.root) == before, "nothing was written") - #expect(provider.canRedo == false, "and the stack did not move") - #expect(provider.undoActionName == "Rename card 'First' → 'Renamed'") - } - - @Test("Discard reconciles the session card against the working tree, not against HEAD") - func discardRevertsUncommittedSaves() 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) - - // **An open Edit session**, whose folder the committer stages around: its ~700 ms save lands - // on disk and is deliberately never committed. That is the exact state 06 says a restore - // would otherwise bury — and the reason the step exists at all. - let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)") - let token = UUID() - committer.beginCardSession(token) { cardFolder } - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Half-typed")) - committer.noteReloadLanded(sawForeignChange: false) - await commitAndSettle(committer, provider) - #expect(try subjects(at: fixture.root).first == "Rename card 'First' → 'Renamed'", - "the session's saves committed nothing while it stood") - - provider.settleSessions = { _ in - // 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 `/` path here instead is what once made this test pass - // over a rule that did not work at all — see `discardReconcilesACardIdentifiedByName`. - committer.endCardSession(token) - provider.noteDiscarded(cardFolderName: Ident.card1) - return .proceed - } - await provider.cross(.undo) - - #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First", - "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 `/`, 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.beginCardSession(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.endCardSession(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 - -/// **The provider follows the board — and since PIVOT 2026-08-07, the board alone** (re-ruled -/// 2026-07-31 — 12-editions.md ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md -/// ▸ Rules; then 12 ▸ PIVOT 2026-08-07, git leaves the paywall), stated as the matrix it is: a -/// **git-mode board binds the git provider** and **every other board binds the native stack** — -/// repo-nested and unverifiable included — in *every* tier. There is no third answer and no second -/// axis. -/// -/// ### What the pivot changed here, and what it did not -/// -/// The 2026-07-31 re-ruling had already taken the tier out of every row but one: it still decided -/// whether git was on the table at all, because `HistoryStore.compose` returned `nil` off Pro, so a -/// free session never detected a mode and could not tell a repo-nested board from a plain one. The -/// pivot removes that last row — "every tier composes the git stack on git-mode boards exactly as -/// Pro did" — so a free-tier session on a git board now binds the *git* provider, which is the -/// inversion `freeTierBindsGitOnAGitBoard` below pins. -/// -/// What survives untouched is the gitless side: a board nobody added git to detects `none` and binds -/// the native stack, for good, because git stays **opt-in per board** (06 ▸ Rules; 13's header read -/// through the pivot). And the repo-nested row keeps the 2026-07-31 reasoning that retired it as an -/// exception — "leave strictly alone concerns *git*, and this stack never touches git — memory-only, -/// journal-free, session-scoped" (13's header). -@MainActor -@Suite("Git undo ▸ which board gets a provider") -struct GitUndoBindingTests { - - @Test("Free tier on a git board binds the git provider — git left the paywall") - func freeTierBindsGitOnAGitBoard() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let seed = HistoryStore.compose(boardRoot: fixture.root) - #expect(await seed.addGit()) - - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .free } - - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - - // **PIVOT 2026-08-07** (12-editions.md), and the exact inversion of what this test used to - // pin: `session.git == nil` and the native stack, because the free tier composed no git state - // at all and this board's `.git` was inert. It is live now, in every tier. - #expect(session.git != nil, "every session composes a git state") - #expect(session.gitMode == .git) - #expect(session.history is GitHistoryProvider) - #expect(session.tier == .free, "recorded, and consulted by nothing on this path") - } - - @Test("Pro on a git board binds the git provider") - func proOnAGitBoardBindsGit() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let seed = HistoryStore.compose(boardRoot: fixture.root) - #expect(await seed.addGit()) - - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - - #expect(session.history is GitHistoryProvider) - } - - @Test("A mode-none board binds the native stack — opt-in is what keeps it there") - func aPlainBoardBindsTheNativeStack() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - - // The board composed a git state and detected a mode, and the mode is what chose the - // substrate: "gitless boards bind the native undo stack in every tier" (12). Since PIVOT - // 2026-08-07 the *only* thing keeping a board on this row is that nobody added git to it — - // opt-in per board is exactly what the pivot left standing (06 ▸ Rules; 13's header). - #expect(session.git != nil, "a git state is composed even where there is no repository") - #expect(session.gitMode == .none) - #expect(session.history is NativeHistoryProvider) - - // And it is a *working* stack, not a placeholder: the same command surface every other - // gitless board gets, which is what "a user relearns nothing" means here. - let log = StepLog() - #expect(session.undoManager.canUndo == false, "empty, not absent") - session.history?.register(log.step("Move Card")) - #expect(session.undoManager.canUndo) - #expect(session.undoManager.undoMenuItemTitle == "Undo Move Card") - session.undoManager.undo() - #expect(log.crossings == ["undo Move Card"]) - } - - @Test("A repo-nested board binds the native stack — the no-undo case is retired") - func aRepoNestedBoardBindsTheNativeStack() throws { - let outer = try WriterFixture() - defer { outer.tearDown() } - // A repository at the *parent*, with the board inside it — the nested posture. - let repoRoot = outer.root - _ = GitRepository.create(at: repoRoot) - let boardRoot = repoRoot.appendingPathComponent("board", isDirectory: true) - try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) - try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) - - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - - let ref = try openBoard(model, at: boardRoot) - let session = try #require(model.session(for: ref)) - - // The mode is detected — the ancestor walk runs — and it no longer decides ⌘Z: "mode-none - // **and repo-nested** boards alike bind it … what repo-nested denies is app-managed history, - // never ⌘Z" (13's header, re-ruled 2026-07-31). - #expect(session.gitMode == .repoNested) - #expect(session.history is NativeHistoryProvider) - #expect(session.undoManager.canUndo == false, "empty, not absent") - - // A working stack, exactly as on the same board under any other tier — see - // `freeTierOnARepoNestedBoardIsNativeToo`, which now detects the same mode this one does. - let log = StepLog() - session.history?.register(log.step("Move Card")) - #expect(session.undoManager.canUndo) - #expect(session.undoManager.undoMenuItemTitle == "Undo Move Card") - session.undoManager.undo() - #expect(log.crossings == ["undo Move Card"]) - - // And the enclosing repository is still left strictly alone: no repository was created at the - // board, and nothing here reaches for the ancestor's `.git` (13: memory-only, journal-free). - #expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)) - #expect(session.git?.committer == nil, "no committer, and so nothing that could write there") - } - - @Test("The default provider closure binds the native stack on an unverifiable board too") - func proOnAnUnverifiableBoardBindsTheNativeStack() throws { - // "`unverifiable` joins the same branch structurally" (`AppModel.makeHistoryProvider`) — a - // denied ancestor check is no more a repository the app manages than a repo-nested one is. - // - // This exercises the seam directly rather than through `openBoard`: reaching `.unverifiable` - // on a real board needs a denied *ancestor*, and POSIX permission bits (unlike the sandbox's - // independent per-path grants) cascade — chmoding a real ancestor to deny its `.git` check - // also denies reading the board's own files underneath it, so the board could never actually - // open (`BoardGitModeDenialTests` in `BoardGitModeTests.swift` covers the detection axis - // itself against real denied directories; this covers what the composition root does with - // whatever mode a `HistoryStore` reports, real detection or not). - let fixture = try makeBoard() - defer { fixture.tearDown() } - let store = try BoardStore(rootURL: fixture.root) - let (model, tearDown) = try makeModel() - defer { tearDown() } - - let git = HistoryStore(boardRoot: fixture.root, mode: .unverifiable, ledger: EchoLedger()) - let provider = model.makeHistoryProvider(store, git) - - #expect(provider is NativeHistoryProvider) - } - - @Test("The free tier's repo-nested board binds the native stack too — now that it detects one") - func freeTierOnARepoNestedBoardIsNativeToo() throws { - let outer = try WriterFixture() - defer { outer.tearDown() } - let repoRoot = outer.root - _ = GitRepository.create(at: repoRoot) - let boardRoot = repoRoot.appendingPathComponent("board", isDirectory: true) - try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) - try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) - - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .free } - - let ref = try openBoard(model, at: boardRoot) - let session = try #require(model.session(for: ref)) - - // The answer is unchanged and the *reason* is new. Before PIVOT 2026-08-07 the free tier - // composed no git state, so nothing here could tell this board from a plain one and native - // undo followed by default — "opening a board that has one … a repo-nested board … works - // normally … native undo runs" (12 ▸ The free tier and `.git`, now retired). Detection runs - // in every tier now, sees the enclosing repository, and binds native anyway, because what - // repo-nested denies is app-managed history and never ⌘Z (13's header, re-ruled 2026-07-31). - #expect(session.git != nil, "detection runs here now") - #expect(session.gitMode == .repoNested, "and it sees the enclosing repository") - #expect(session.history is NativeHistoryProvider) - // The same board under `.pro` answers identically (above) — one matrix, no tier column. - #expect(session.git?.committer == nil, "and the enclosing repository is still left alone") - } - - @Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded") - func addGitSwapsTheSubstrate() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - - let ref = try openBoard(model, at: fixture.root) - let native = try #require(model.session(for: ref)?.history as? NativeHistoryProvider) - // The AppKit face AppKit already holds: the swap must not replace *this* object, or every - // window's `windowWillReturnUndoManager` answer would go stale. - let manager = try #require(model.session(for: ref)?.undoManager) - - // A real in-session step, mid-flight when the flip arrives. - let log = StepLog() - native.register(log.step("Move Card")) - #expect(manager.canUndo) - #expect(manager.undoMenuItemTitle == "Undo Move Card") - - let git = try #require(model.session(for: ref)?.git) - #expect(await git.addGit()) - - let session = try #require(model.session(for: ref)) - #expect(session.gitMode == .git) - #expect(session.history is GitHistoryProvider, "the flip carries undo through with it") - #expect(session.store.history is GitHistoryProvider, "and the Writer boundary registers there") - #expect(session.undoManager === manager, "the same manager, over a different stack") - - // **The stack dies with the substrate** (13's header — the branch-switch discard-and-reseed - // precedent): no migration, and the discarded stack is cleared rather than merely dropped, - // so nothing holding a reference to it can cross a step against a board that now has a trail. - #expect(native.canUndo == false) - #expect(native.canRedo == false) - #expect(log.crossings.isEmpty, "the in-flight step never ran — it was discarded, not applied") - - // **Seeded from the root commit**, which is the stack's floor and not a step (06 ▸ Rules), so - // ⌘Z is correctly empty the instant the flip lands — and the menu row says nothing. - #expect(try trail(at: fixture.root).count == 1) - #expect(manager.canUndo == false, "on a trail whose only commit is the root") - #expect(manager.canRedo == false) - #expect(manager.undoMenuItemTitle == "Undo") - } - - @Test("The swapped-in git provider is live — the next landed commit is a step on it") - func theSwappedProviderHearsTheCommitter() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - - let ref = try openBoard(model, at: fixture.root) - let git = try #require(model.session(for: ref)?.git) - #expect(await git.addGit()) - - let session = try #require(model.session(for: ref)) - let provider = try #require(session.history as? GitHistoryProvider) - // `activateAutoCommit` remembered the session's wiring for exactly this — the committer - // add-git built reports landed commits to whatever provider the session now holds, which is - // the one the swap just installed. - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) - committer.noteReloadLanded(sawForeignChange: true) - await commitAndSettle(committer, provider) - - #expect(try trail(at: fixture.root).count == 2) - #expect(session.undoManager.canUndo, "the trail behind ⌘Z is the repository's, live") - } -} - -// MARK: - Failures reach the strip as failures - -/// **The one-shot failure class's second shape, wired** (02-architecture.md ▸ The banner surface, -/// settled 2026-07-31): "a failed undo restore, branch switch, or (pro-m2) pull/push is an action -/// that didn't happen: it presents in the error tone at the failure rank, never as a warning-tone -/// loss row (the shipped loss-row compromise is retired)". -/// -/// These are wiring tests: what the session hands each seam, and which class of row comes out the -/// other side. The sentences themselves are `BannerCenterTests`' subject, and the precedence is -/// `BannerCenter.rows(...)`'. -@MainActor -@Suite("Git undo ▸ a failed git operation is a failure row") -struct GitOperationFailureBannerTests { - - @Test("A failed restore posts the git failure shape, named by the key that was pressed") - func aFailedRestorePostsAFailureRow() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let seed = HistoryStore.compose(boardRoot: fixture.root) - #expect(await seed.addGit()) - - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - let provider = try #require(session.history as? GitHistoryProvider) - - // What `restore(_:to:message:)` hands the seam when libgit2 refuses: the direction, and the - // library's own message. The `operation` string on the failure is developer-facing and is - // deliberately not what the user reads. - provider.reportFailure?(.undo, GitOperationFailure( - operation: GitRestoreOperation.operationName, - message: "could not write to 'index.md': Permission denied" - )) - - let banners = session.store.banners - #expect(banners.gitFailures.count == 1) - #expect(banners.gitFailures.first?.operation == .undo) - #expect(banners.gitFailures.first?.reason == "could not write to 'index.md': Permission denied") - #expect(banners.losses.isEmpty, "the loss-row compromise is retired — this is a failure") - #expect(banners.oneShots.isEmpty, "and it stays off the closed WriteOperation vocabulary") - - // ⇧⌘Z's mirror, from the same seam and the same closure. - provider.reportFailure?(.redo, GitOperationFailure( - operation: GitRestoreOperation.operationName, - message: "the repository is locked" - )) - #expect(banners.gitFailures.map(\.operation) == [.redo, .undo], "newest first, like every one-shot") - } - - @Test("A failed branch switch posts the same shape; the interruption recovery stays a loss row") - func theSwitcherReportsFailureAndRecoveryDifferently() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let seed = HistoryStore.compose(boardRoot: fixture.root) - #expect(await seed.addGit()) - - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - let switcher = try #require(session.git?.switcher) - - switcher.reportFailure?(GitOperationFailure( - operation: GitBranchOperation.operationName, - message: "your local changes would be overwritten" - )) - // **The recovery is a success report** — "a branch switch was interrupted — the previous - // state is restored" — so it keeps the warning tone the ruling leaves it (02). - switcher.reportRecovery?(GitOperationStamp.interruptionMessage) - - let banners = session.store.banners - #expect(banners.gitFailures.map(\.operation) == [.branchSwitch]) - #expect(banners.losses.map(\.message) == [GitOperationStamp.interruptionMessage]) - } -} - -// MARK: - Routing - -@MainActor -@Suite("Git undo ▸ focus routing, with the git provider behind it") -struct GitUndoRoutingTests { - - @Test("A focused text surface takes ⌘Z, whatever the board's substrate is") - func textEditingWins() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let seed = HistoryStore.compose(boardRoot: fixture.root) - #expect(await seed.addGit()) - - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - #expect(session.history is GitHistoryProvider) - - let text = UndoManager() - // A field editor holds the keyboard: the *text* manager answers, so a reflexive ⌘Z over a - // typo can never become a tree checkout (06 ▸ Undo routing). - #expect(BoardUndoRouting.undoManager( - isTextEditing: true, - board: session.undoManager, - textFallback: text - ) === text) - // Focus outside every text surface: the board's own substrate answers. - #expect(BoardUndoRouting.undoManager( - isTextEditing: false, - board: session.undoManager, - textFallback: text - ) === session.undoManager) - } - - @Test("A board with no provider still routes to its own manager — it just cannot cross") - func noProviderIsStillTheBoardsManager() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - - let text = UndoManager() - let answered = BoardUndoRouting.undoManager( - isTextEditing: false, - board: session.undoManager, - textFallback: text - ) - // **No fall-through in either direction**: the board's manager answers even with nothing - // behind it, so an exhausted editor's ⌘Z never reaches a *different* stack — it reaches this - // one, which is disabled, and the system beeps. - #expect(answered === session.undoManager) - #expect(answered.canUndo == false) - } -} - -// MARK: - The card window's History section - -@MainActor -@Suite("Git undo ▸ the card's History trail") -struct CardHistorySectionTests { - - @Test("The trail lists the commits that touched this card, newest first") - func theTrailIsNewestFirst() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed again")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - let commits = GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root) - #expect(commits.map(\.subject) == [ - "Rename card 'Renamed' → 'Renamed again'", - "Rename card 'First' → 'Renamed'", - GitRepository.initialCommitSubject - ]) - } - - @Test("The trail follows the card across a lane move") - func theTrailFollowsALaneMove() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let committer = try quickCommitter(git) - - try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing")) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - // The card moves lane — its folder name (its identity) is unchanged, its path is not. - try FileManager.default.moveItem( - at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)"), - to: fixture.root.appendingPathComponent("\(Ident.lane2)/\(Ident.card1)") - ) - committer.noteReloadLanded(sawForeignChange: true) - await committer.flushNow() - - let commits = GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root) - #expect(commits.count == 2, "the move, and the root commit it was born in") - #expect(commits.first?.subject == "Move card 'First' to Doing") - } - - @Test("A card no commit has touched has an empty trail") - func anUntouchedCardHasNoTrail() async throws { - let (fixture, git, _) = try await makeGitBoard() - defer { fixture.tearDown() } - _ = try quickCommitter(git) - - let commits = GitHistoryWalk.commitsTouching(folderNamed: "not-a-card", at: fixture.root) - #expect(commits.isEmpty) - } - - @Test("A board with no git answers nothing at all") - func aPlainBoardHasNoTrail() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - - #expect(GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root).isEmpty) - #expect(GitHistoryWalk.ancestry(at: fixture.root).isEmpty) - #expect(GitHistoryWalk.headOID(at: fixture.root) == nil) - } - - @Test("A row reads subject over relative date and author") - func rowsRenderSubjectDateAuthor() { - let now = Date() - let commit = GitCommitRecord( - oid: "abc123", - subject: "Move card 'Fix login' to Doing", - authorName: "Claude", - date: now.addingTimeInterval(-60 * 60 * 48), - parentOID: "def456" - ) - - let rows = CardHistoryRows.rows(for: [commit], now: now, locale: Locale(identifier: "en_US")) - #expect(rows.count == 1) - #expect(rows[0].subject == "Move card 'Fix login' to Doing") - #expect(rows[0].attribution == "2 days ago · Claude") - #expect(rows[0].id == "abc123") - } - - @Test("A commit from moments ago reads 'just now' rather than a rounded future") - func aFreshCommitReadsJustNow() { - let now = Date() - #expect(CardHistoryRows.relativeDate(now.addingTimeInterval(-2), now: now) == "just now") - #expect(CardHistoryRows.relativeDate(now, now: now) == "just now") - } - - @Test("An unauthored commit drops the author segment rather than trailing a separator") - func anUnauthoredCommitDropsTheSegment() { - let now = Date() - let commit = GitCommitRecord( - oid: "abc", - subject: "Update board", - authorName: " ", - date: now, - parentOID: nil - ) - #expect(CardHistoryRows.attribution(of: commit, now: now) == "just now") - } - - @Test("Path matching is component-exact") - func pathMatchingIsExact() { - #expect(GitHistoryWalk.path("lane/card-1/index.md", isInsideFolderNamed: "card-1")) - #expect(GitHistoryWalk.path("lane/card-1/attachments/a.png", isInsideFolderNamed: "card-1")) - #expect(GitHistoryWalk.path(".trash/card-1/index.md", isInsideFolderNamed: "card-1")) - #expect(GitHistoryWalk.path("lane/card-10/index.md", isInsideFolderNamed: "card-1") == false) - #expect(GitHistoryWalk.path("card-1", isInsideFolderNamed: "card-1") == false) - } -} diff --git a/KanbanUITests/AccessibilityAuditTests.swift b/KanbanUITests/AccessibilityAuditTests.swift index a639e49..2c90f86 100644 --- a/KanbanUITests/AccessibilityAuditTests.swift +++ b/KanbanUITests/AccessibilityAuditTests.swift @@ -10,7 +10,7 @@ import XCTest /// The last of those is **one surface shorter than the design's sentence** since 2026-08-07: the /// board settings sheet retired that day (03-board-ui.md ▸ Board settings sheet, marked retired — /// the 2026-07-31 popover/sheet split reversed), and everything it held now renders inside the board -/// popover's Git tab. So the popover's own audit is where those controls are looked at, and the +/// popover itself. So the popover's own audit is where those controls are looked at, and the /// every-surface claim is satisfied by there being one surface fewer rather than by a test skipping /// one. /// @@ -25,10 +25,12 @@ import XCTest /// into Terminal (`UITestLaunch` ships in the app binary on purpose). The 2026-08-07 pivot /// (12-editions.md ▸ PIVOT 2026-08-07 — git left the paywall) made it reachable and it gained a test /// here; the **reversal later the same day retired the sheet outright**, and the test with it. What -/// remains is the precedent — the no-bypass objection stands for whatever the next split gates — and -/// one live consequence for this file: the surfaces that sheet held are now the board popover's Git -/// tab, which `testBoardInfoPopover` opens onto its Info tab. Auditing the Git tab specifically wants -/// a click on the popover's segmented strip and is a card of its own, filed rather than faked here. +/// remains is the precedent — the no-bypass objection stands for whatever the next split gates — +/// and `testBoardInfoPopover` opens the popover onto its Info tab, which the audit covers. +/// +/// The popover carried a fourth tab, Git, until the git excision (strategy/01-git-excision.md, +/// 2026-08-08) removed it; the popover is Info, Theme, Sync now, and there is no tab-specific audit +/// left to file. /// /// ### No waiving /// @@ -195,7 +197,7 @@ final class AccessibilityAuditTests: XCTestCase { try app.performAccessibilityAudit() } - /// The board popover — File ▸ Board Info (⌘I): "labeled controls throughout", with the git slot's + /// The board popover — File ▸ Board Info (⌘I): "labeled controls throughout", with every tab's /// information readable as text and never by colour or shape alone (10 ▸ Board popover). @MainActor func testBoardInfoPopover() throws {