Files
lanework/KanbanTests/GitUndoTests.swift
T
rzen 2e4dde5655 Bind the undo provider to the board, not the tier
The 2026-07-31 re-ruling: gitless boards bind the native stack in every
tier — a Pro upgrade no longer removes undo from mode-none boards — and
Pro git boards bind the git provider; repo-nested stays the no-undo
case under Pro, while the free tier (which never runs detection) binds
native there too, per 12's inert posture. Add-git now swaps a live
native substrate mid-session: the in-flight stack is cleared with the
discarded provider, the git trail seeds from the root commit, and the
same BoardUndoManager instance keeps nil-target menu validation fresh.

2405 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 18:53:38 -04:00

1285 lines
58 KiB
Swift

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 = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, 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: "[email protected]")
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: - 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 "<!-- lanework-guide v2 -->\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 "<!-- lanework-guide v2 -->\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 "<!-- lanework-guide v2 -->\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 "<!-- lanework-guide v2 -->\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.beginEditSession(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 `<lane>/<card>` path here instead is what once made this test pass
// over a rule that did not work at all — see `discardReconcilesACardIdentifiedByName`.
committer.endEditSession(token)
provider.noteDiscarded(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 `<lane>/<card>`, so the match never fired on any board: Discard
/// reverted the in-memory buffer and left the uncommitted on-disk saves exactly where they were,
/// which then rode into the next commit — the one outcome 06 ▸ Rules ▸ Undo restore vs open Edit
/// sessions singles out ("a surviving dirty buffer's next debounced save would write pre-undo text
/// over the restored card — a ⌘Z that visibly doesn't happen").
///
/// The fix is one shared resolver (`GitRestoreOperation.folderPaths(named:at:)`), so this asserts
/// the property the resolver exists for: a card nested under a lane, named only by its id.
///
/// **What it takes to see the bug.** The restore's own diff already rewrites the session card's
/// `index.md` — that is why the gate appeared at all — so a test that only checks the body is
/// green either way. What reconciliation alone can reach is the session's uncommitted state the
/// diff *cannot* name: a file the session created, present in neither HEAD nor the target, which
/// the plan can only learn about by walking the folder on disk. That is what this leaves behind
/// and then looks for.
@Test("Discard reconciles a card identified by folder name alone, however deep it is nested")
func discardReconcilesACardIdentifiedByName() async throws {
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
let provider = await makeProvider(fixture, git, committer: committer)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
committer.noteReloadLanded(sawForeignChange: true)
await commitAndSettle(committer, provider)
// The open session's uncommitted work, staged around and committed by nothing: a crash-safe
// body save, and a file the session added beside it (05-card-window.md's attachments land in
// the card's own folder) — which no commit anywhere has ever seen.
let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
let token = UUID()
committer.beginEditSession(token) { cardFolder }
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed", body: "Half-typed."))
let stray = "\(Ident.lane1)/\(Ident.card1)/attachments/sketch.txt"
try fixture.file(stray, Data("dropped mid-session".utf8))
provider.settleSessions = { _ in
committer.endEditSession(token)
// The bare id — never the path. This is the whole regression.
provider.noteDiscarded(cardFolderName: Ident.card1)
return .proceed
}
await provider.cross(.undo)
#expect(!fixture.exists(stray),
"the discarded session's uncommitted file is gone from disk, not merely from a buffer")
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First",
"and the restore landed over the body")
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty,
"on a settled tree — nothing is left for the next flush to sweep into a commit")
}
}
// MARK: - The provider binding
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12-editions.md
/// ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md ▸ Rules), stated as the
/// matrix it is: gitless boards bind the native stack in *every* tier ("an upgrade never removes
/// undo"), a Pro git board binds the git provider, and a repo-nested board binds nothing.
///
/// ### The free tier's row is one cell wide, structurally
///
/// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all
/// and cannot tell a repo-nested board from a plain one — which is not an omission but 12 ▸ The free
/// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a
/// 1.x board, **a repo-nested board**) works normally — files read and write as on any board,
/// **native undo runs**". 06's no-undo rule for repo-nested boards is a rule of a doc whose own first
/// line reads "Tier scope: Lanework Pro", and the tests below pin both halves.
@MainActor
@Suite("Git undo ▸ which board gets a provider")
struct GitUndoBindingTests {
@Test("Free tier binds the native stack everywhere, git or not")
func freeTierIsNativeEverywhere() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#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))
#expect(session.git == nil, "the free tier composes no git state at all")
#expect(session.history is NativeHistoryProvider)
#expect(session.undoManager.canUndo == false, "empty, not absent")
}
@Test("Pro on a git board binds the git provider")
func proOnAGitBoardBindsGit() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#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("Pro on a mode-none board binds the native stack — an upgrade never removes undo")
func proOnAPlainBoardBindsTheNativeStack() 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 is Pro's — it 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).
#expect(session.git != nil, "Pro composes a git state 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 a free-tier
// session gets, which is what "a user subscribing 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("Pro on a repo-nested board binds no provider — the one no-undo case")
func proOnARepoNestedBoardBindsNothing() 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))
// 06 ▸ Rules: a board inside somebody else's repository is "left strictly alone … so they get
// **no undo**" — no app-managed undo journal, which an in-memory stack here would be.
#expect(session.gitMode == .repoNested)
#expect(session.history == nil, "the app leaves that repository strictly alone")
#expect(session.undoManager.canUndo == false)
#expect(session.undoManager.canRedo == false)
#expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name")
// And a crossing that somehow started still writes nothing.
session.undoManager.undo()
session.undoManager.redo()
}
@Test("The free tier's repo-nested board still binds the native stack — it never 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))
// 12 ▸ The free tier and `.git` names this board by hand: "opening a board that has one …
// a repo-nested board … works normally … native undo runs". The tier composes no git state
// at all, so there is nothing here that *could* tell this board from a plain one — the inert
// posture made structural rather than remembered.
#expect(session.git == nil)
#expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode")
#expect(session.history is NativeHistoryProvider)
}
@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: - 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 = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#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)
}
}