The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
1482 lines
69 KiB
Swift
1482 lines
69 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: - Restore subjects compose the inverse
|
|
|
|
/// **"Subjects don't nest either — crossing a restore composes the inverse"** (06-history-undo.md ▸
|
|
/// Commit messages, settled 2026-07-31). The composer is a pure function of the crossed subject and
|
|
/// the direction (`GitHistoryProvider.restoreSubject(_:crossing:)`), so most of this suite needs no
|
|
/// repository at all — and the one test that does is the case the rule exists for: the relaunch that
|
|
/// turns yesterday's restore commit into an ordinary step.
|
|
@MainActor
|
|
@Suite("Git undo ▸ restore subjects compose the inverse")
|
|
struct GitUndoRestoreSubjectTests {
|
|
|
|
@Test("An ordinary subject takes one prefix, per direction")
|
|
func anOrdinarySubjectNestsOnce() {
|
|
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Move card 'Fix login' to Doing")
|
|
== "Undo: Move card 'Fix login' to Doing")
|
|
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Move card 'Fix login' to Doing")
|
|
== "Redo: Move card 'Fix login' to Doing")
|
|
}
|
|
|
|
@Test("Undoing across a restore emits the inverse label, not a second prefix")
|
|
func undoingARestoreInverts() {
|
|
// 06's own two examples: "crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
|
|
// 'Undo: S'" — because an undo restores the crossed commit's *parent*, the state that
|
|
// commit took away.
|
|
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Move card 'X'")
|
|
== "Redo: Move card 'X'")
|
|
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Move card 'X'")
|
|
== "Undo: Move card 'X'")
|
|
}
|
|
|
|
@Test("Redoing across a restore restates it — the mirror of the undo rule, not a copy of it")
|
|
func redoingARestoreRestates() {
|
|
// A redo restores the target commit *itself*, so the label the new commit carries is that
|
|
// commit's own reading: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out.
|
|
// Emitting "Redo: S" there — the label the ⌘Z that crossed it already used, for the opposite
|
|
// tree — would be the euphemism 06 rules out.
|
|
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Move card 'X'")
|
|
== "Undo: Move card 'X'")
|
|
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Redo: Move card 'X'")
|
|
== "Redo: Move card 'X'")
|
|
}
|
|
|
|
@Test("A legacy double prefix reads as two flips, and comes out carrying one")
|
|
func theLegacyDoublePrefixReadsAsTwoFlips() {
|
|
// **The honest reading of a commit the shipped nesting build made.** "Undo: Undo: S" undid
|
|
// the commit that undid S, so its tree is the one where S is *in*. Undoing across it puts S
|
|
// back out — "Undo: S" — which is what 06's "the truer label, not a euphemism" asks for;
|
|
// "Redo: S" would claim the opposite tree, and "Redo: Undo: S" would keep the nesting the
|
|
// ruling caps at one ("it caps prefixes at one across any number of relaunches").
|
|
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Undo: Move card 'X'")
|
|
== "Undo: Move card 'X'")
|
|
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Undo: Move card 'X'")
|
|
== "Redo: Move card 'X'")
|
|
// The legacy redo's shape reads the same way: "Redo:" restates whatever follows it.
|
|
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Undo: Move card 'X'")
|
|
== "Redo: Move card 'X'")
|
|
// And any depth caps at one, which is the property the ruling actually claims — the reading
|
|
// is the parity of the "Undo:"s (two here, so the tree has the move in it) and never the
|
|
// depth of the stack.
|
|
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Redo: Undo: Move card 'X'")
|
|
== "Undo: Move card 'X'")
|
|
}
|
|
|
|
@Test("A prefix with nothing after it is somebody's subject, not a label")
|
|
func aBarePrefixIsASubject() {
|
|
// The sniff is on the subject string (06), and a subject that is *only* a prefix has no base
|
|
// to talk about — stripping it would compose "Undo: " with nothing, naming no change at all.
|
|
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: ") == "Undo: Undo: ")
|
|
// Foreign subjects that merely look like prefixes are unaffected — the match is exact.
|
|
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "undo: fix the build")
|
|
== "Undo: undo: fix the build")
|
|
}
|
|
|
|
@Test("After a relaunch, ⌘Z over yesterday's restore commits the inverse — the trail never nests")
|
|
func theRelaunchCaseLandsTheInverse() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
await provider.cross(.undo)
|
|
#expect(try subjects(at: fixture.root).first == "Undo: Rename card 'First' → 'Renamed'")
|
|
|
|
// **The relaunch**, which is what `reseed()` is: the stack starts again at HEAD with an empty
|
|
// redo, so the restore commit above is now an ordinary step the pointer sits on.
|
|
await provider.reseed()
|
|
#expect(provider.undoActionName == "Undo: Rename card 'First' → 'Renamed'",
|
|
"the menu label is still the crossed commit's own subject — labels never nested")
|
|
|
|
await provider.cross(.undo)
|
|
|
|
let subjects = try subjects(at: fixture.root)
|
|
#expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'",
|
|
"the trail says what the restore did: the rename is back")
|
|
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")
|
|
}
|
|
}
|
|
|
|
// MARK: - Heal transparency
|
|
|
|
@MainActor
|
|
@Suite("Git undo ▸ heal commits are transparent")
|
|
struct GitUndoHealTransparencyTests {
|
|
|
|
@Test("The pointer passes over a heal commit and crosses the step beneath it")
|
|
func thePointerPassesOverAHeal() async throws {
|
|
let (fixture, git, ledger) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
// An ordinary step.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
// A heal: the app's own repair, vouched for by a heal-marked receipt.
|
|
let healed = fixture.root.appendingPathComponent(AgentGuide.filename)
|
|
try "<!-- 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: - Failures reach the strip as failures
|
|
|
|
/// **The one-shot failure class's second shape, wired** (02-architecture.md ▸ The banner surface,
|
|
/// settled 2026-07-31): "a failed undo restore, branch switch, or (pro-m2) pull/push is an action
|
|
/// that didn't happen: it presents in the error tone at the failure rank, never as a warning-tone
|
|
/// loss row (the shipped loss-row compromise is retired)".
|
|
///
|
|
/// These are wiring tests: what the session hands each seam, and which class of row comes out the
|
|
/// other side. The sentences themselves are `BannerCenterTests`' subject, and the precedence is
|
|
/// `BannerCenter.rows(...)`'.
|
|
@MainActor
|
|
@Suite("Git undo ▸ a failed git operation is a failure row")
|
|
struct GitOperationFailureBannerTests {
|
|
|
|
@Test("A failed restore posts the git failure shape, named by the key that was pressed")
|
|
func aFailedRestorePostsAFailureRow() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let seed = 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))
|
|
let provider = try #require(session.history as? GitHistoryProvider)
|
|
|
|
// What `restore(_:to:message:)` hands the seam when libgit2 refuses: the direction, and the
|
|
// library's own message. The `operation` string on the failure is developer-facing and is
|
|
// deliberately not what the user reads.
|
|
provider.reportFailure?(.undo, GitOperationFailure(
|
|
operation: GitRestoreOperation.operationName,
|
|
message: "could not write to 'index.md': Permission denied"
|
|
))
|
|
|
|
let banners = session.store.banners
|
|
#expect(banners.gitFailures.count == 1)
|
|
#expect(banners.gitFailures.first?.operation == .undo)
|
|
#expect(banners.gitFailures.first?.reason == "could not write to 'index.md': Permission denied")
|
|
#expect(banners.losses.isEmpty, "the loss-row compromise is retired — this is a failure")
|
|
#expect(banners.oneShots.isEmpty, "and it stays off the closed WriteOperation vocabulary")
|
|
|
|
// ⇧⌘Z's mirror, from the same seam and the same closure.
|
|
provider.reportFailure?(.redo, GitOperationFailure(
|
|
operation: GitRestoreOperation.operationName,
|
|
message: "the repository is locked"
|
|
))
|
|
#expect(banners.gitFailures.map(\.operation) == [.redo, .undo], "newest first, like every one-shot")
|
|
}
|
|
|
|
@Test("A failed branch switch posts the same shape; the interruption recovery stays a loss row")
|
|
func theSwitcherReportsFailureAndRecoveryDifferently() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let seed = 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))
|
|
let switcher = try #require(session.git?.switcher)
|
|
|
|
switcher.reportFailure?(GitOperationFailure(
|
|
operation: GitBranchOperation.operationName,
|
|
message: "your local changes would be overwritten"
|
|
))
|
|
// **The recovery is a success report** — "a branch switch was interrupted — the previous
|
|
// state is restored" — so it keeps the warning tone the ruling leaves it (02).
|
|
switcher.reportRecovery?(GitOperationStamp.interruptionMessage)
|
|
|
|
let banners = session.store.banners
|
|
#expect(banners.gitFailures.map(\.operation) == [.branchSwitch])
|
|
#expect(banners.losses.map(\.message) == [GitOperationStamp.interruptionMessage])
|
|
}
|
|
}
|
|
|
|
// MARK: - Routing
|
|
|
|
@MainActor
|
|
@Suite("Git undo ▸ focus routing, with the git provider behind it")
|
|
struct GitUndoRoutingTests {
|
|
|
|
@Test("A focused text surface takes ⌘Z, whatever the board's substrate is")
|
|
func textEditingWins() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let seed = 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)
|
|
}
|
|
}
|