Build HistoryStore — opt-in git and mode detection

The pro-m1 foundation card. SwiftGitX 0.4.0 (bundled libgit2, the
pathfinder's pin) joins the one target; new Kanban/Git/ holds
BoardGitMode (pure nearest-.git-wins detection, .git-as-file counts,
NSString ancestor walk), HistoryStore (@MainActor @Observable;
compose() is the tier gate — free tier gets no object, no detection,
no stat), GitRepository (scope-confined SwiftGitX handles: create =
init + HEAD forced to main + whole-tree "Initial board state" commit;
branch reads incl. unborn/detached; path-history ranks), GitIdentity
(derived default as a pure function + repo-local config reader — not
libgit2's merged ladder), and GitPathHistory (Mutex-guarded lazy
ranker). beginSession composes the git state beside the tier and
feeds BoardStore.makeIdentityHistoryRanker; git-mode loads pass the
git-backed IdentityHistoryRanker to BoardLoader. The popover's git
slot resolves a pure five-way matrix: free tier unchanged (absent /
BoardGitNote), Pro mode-aware — Add Git on mode none, honest prose on
repo-nested, read-only branch line on git. Provider binding
unchanged: both tiers still bind native until the undo/redo card.

42 new tests across 8 suites, all repositories built through bundled
libgit2; InertGitTests untouched and green. 2194 tests / 375 suites.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-31 13:18:07 -04:00
parent 9f8eebe23b
commit 189af238a1
15 changed files with 1943 additions and 17 deletions
+196 -12
View File
@@ -68,6 +68,13 @@ struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
/// The tier and 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 redraws the popover without anything here being re-created.
let tier: Tier
let git: HistoryStore?
@Bindable var presentation: BoardInfoPresentation
var body: some View {
@@ -87,7 +94,7 @@ struct BoardInfoWidget: View {
.help("Board Info")
.accessibilityLabel("Board Info")
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents)
BoardInfoView(store: store, recents: recents, tier: tier, git: git)
}
}
}
@@ -100,15 +107,23 @@ struct BoardInfoWidget: View {
/// 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
/// `tier`/`git` default to the free tier's posture a popover with no git section at all so that
/// a caller with no session in hand (the accessory-installation tests, which are about AppKit
/// plumbing rather than about git) describes a board honestly rather than by accident. The app's own
/// call site passes the session's values explicitly.
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
tier: Tier = .free,
git: HistoryStore? = nil,
presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
tier: tier,
git: git,
presentation: presentation
)
)
@@ -135,10 +150,16 @@ struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
let tier: Tier
let git: HistoryStore?
/// Whether this board carries a `.git` checked once, off disk, when the view is built (which
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
///
/// **The free tier's input only.** Under Pro the section reads the session's detected mode
/// instead a fact settled at open, which is where 06-history-undo.md puts detection and this
/// stays what it always was: the one quiet question the free tier asks of a board's folder.
private let hasGitDirectory: Bool
/// The style editor brings its own padding, so the sections around it carry the same number by
@@ -149,10 +170,14 @@ struct BoardInfoView: View {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(store: BoardStore, recents: StyleRecents) {
init(store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil) {
self.store = store
self.recents = recents
self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL)
self.tier = tier
self.git = git
// Asked only where it is the answer: under Pro the mode already knows, and a free-tier
// board is the only one this question is for (12-editions.md The free tier and `.git`).
self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
}
var body: some View {
@@ -176,15 +201,12 @@ struct BoardInfoView: View {
StyleEditorView(store: store, recents: recents, target: .board)
}
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary board adds
// nothing here at all no header, no divider, no placeholder and the popover ends at
// Styling, complete in itself. Only a board that actually carries an inert `.git` earns
// this closing note.
if hasGitDirectory {
Divider()
BoardGitNote()
.padding(inset)
}
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier
// board adds nothing here at all no header, no divider, no placeholder and the
// popover ends at Styling, complete in itself. What a Pro board adds instead is the
// mode-aware git section (03-board-ui.md Board popover), which is a *section*, header
// and all, because under Pro git is a feature of the board rather than a signpost.
gitSection
}
// The style editor's popover width, taken from the editor rather than restated: the embed
// below must lay out here exactly as it does at its other two anchors, and that number is
@@ -192,6 +214,49 @@ struct BoardInfoView: View {
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
/// The popover's closing section, whichever of the five postures this board is in see
/// `BoardGitSection`.
@ViewBuilder
private var gitSection: some View {
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
case .absent:
EmptyView()
case .proPointer:
Divider()
BoardGitNote()
.padding(inset)
case .addGit:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
if let git {
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
}
}
.padding(inset)
case .repoNested:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
BoardGitNestedNote()
}
.padding(inset)
case .branch:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
if let git {
BoardGitBranchLine(git: git)
}
}
.padding(inset)
}
}
/// The section titles, matching the style editor's own headers so the popover reads as one
/// surface rather than borrowed ones.
private func sectionHeader(_ title: String) -> some View {
@@ -278,6 +343,125 @@ private struct BoardRenameField: View {
// MARK: - Git
/// **What the popover's git slot is, for one board** (03-board-ui.md Board popover;
/// 06-history-undo.md Rules; 12-editions.md The free tier and `.git`) a pure function of two
/// facts, so the posture matrix is provable without a popover on screen.
///
/// The free tier's two cases are settled 2026-07-27 and unchanged by this card: absent on an
/// ordinary board, a one-line Pro pointer on a board carrying an inert `.git`. The Pro 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).
enum BoardGitSection: Equatable, CaseIterable {
/// Nothing at all the free tier's ordinary board, where "the popover is rename + style,
/// complete in itself".
case absent
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
/// to Pro (12 Tier naming).
case proPointer
/// Pro, mode `none`: the add-git action (06 Rules Opt-in init).
case addGit
/// Pro, repo-nested: the honest explanation, no action.
case repoNested
/// Pro, git mode: the read-only branch/source line. Branch switching and creation, the commit
/// identity fields and the remote controls are later cards nothing here is a control.
case branch
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
switch tier {
case .free:
// Detection never runs under the free tier, so the mode is not consulted here the one
// question asked is whether the folder carries a `.git`, which is what the pointer is
// about (12: "any `.git` is inert a stray like any other, preserved verbatim").
return hasGitDirectory ? .proPointer : .absent
case .pro:
switch mode {
case .none: return .addGit
case .git: return .branch
case .repoNested: return .repoNested
}
}
}
}
/// **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.
private 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.
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 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 branch/source display** (03-board-ui.md Board popover) read-only, and deliberately the
/// whole of the git-mode section for now: switching, creation, identity and remotes are each their
/// own card, and a control shown before it works is worse than one that isn't there yet.
///
/// The name is read when the popover appears rather than at session composition, so the open path
/// never waits on libgit2 (`HistoryStore.branch`).
private struct BoardGitBranchLine: View {
let git: HistoryStore
var body: some View {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.branch")
.imageScale(.small)
.foregroundStyle(.secondary)
Text(git.branch ?? "")
.font(.callout)
.foregroundStyle(git.branch == nil ? .secondary : .primary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(git.branch.map { "Branch \($0)" } ?? "Reading branch")
.task { await git.refreshBranch() }
}
}
/// The contextual git note **a quiet signpost, not a feature** (12-editions.md The free tier and
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old