Files
lanework/KanbanTests/GitUndoTests.swift
T
rzen d076427ee0 Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.

2448 tests in 423 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 21:19:42 -04:00

1301 lines
59 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.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 `<lane>/<card>` 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 `<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.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, 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: **boards without app-managed git — repo-nested included — bind the native stack in
/// *every* tier** ("an upgrade never removes undo"), and a Pro git board binds the git provider.
/// There is no third answer any more.
///
/// ### The repo-nested row stopped being an exception
///
/// `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**". Pro used to answer differently on the same board, which made subscribing
/// *remove* ⌘Z from it; the re-ruling of 2026-07-31 retired that case outright — "leave strictly
/// alone concerns *git*, and this stack never touches git — memory-only, journal-free,
/// session-scoped" (13's header) — so the two tiers now agree on every board there is, and the tests
/// below pin both halves of that agreement.
@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 the native stack — the no-undo case is retired")
func proOnARepoNestedBoardBindsTheNativeStack() 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 — Pro walks the ancestors — and it no longer decides ⌘Z: "Pro binds it
// on mode-none **and repo-nested** boards alike … 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 the free tier's on this same board — which is the whole point
// of the re-ruling: the two tiers answer alike here now.
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 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)
// Pro on this same board now answers identically (above) — the two tiers agree, which is what
// the re-ruling of 2026-07-31 bought: an upgrade never removes undo from *any* board.
}
@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)
}
}