GitBranchSwitcher holds 06's sequence as one object: settle editors
explicitly (SessionSettleGate — Save All applies raw buffers with
validation and a refused buffer cancels the whole switch; Discard
reverts buffers AND reconciles the session folders against HEAD;
never silent), flush the pending auto-commit, stamp intent in the
per-board registry, bracketed safe checkout (git_checkout_tree
GIT_CHECKOUT_SAFE + set_head — no path passes FORCE, abort
included), one reload via the async wholesale bracket (failed final
reload engages the existing read-only lock), reseed undo/redo from
the new HEAD with redo empty, clear the stamp. Create-and-switch
keeps the full sequence — the tree-cannot-change proof fails under
concurrent writers. Lock contention shows the 02 in-progress row's
waiting state ("waiting for another writer's git lock"), bounded at
30s then failing cleanly naming the lock path.
GitOperationStamp + GitOperationRecovery: the own-leftovers rule as
a pure conjunction — pause state AND matching stamp = the app's own
interrupted operation, aborted to the pre-operation state with a
banner, stamp cleared on success only; either alone defers to the
pause-and-defer stance. Checked where the committer starts.
BoardGitControls replaces the read-only branch line: branch picker,
inline create-and-switch, the abnormal-state pause note in 06's own
words with controls dimmed, and commit-identity fields that read and
write repo-local .git/config (derived default as placeholder, never
value; unfocused resync, focused keystrokes kept; 2s poll while
visible — .git is watcher-filtered by design).
Also fixes a shipped bug from the undo card: plan(reconciling:)
matched card ids as path prefixes, so the reconcile branch was inert
on every board (<lane>/<card> never matches a bare id) — a session
file the restore diff couldn't name (attachment, comment, draft)
survived Discard and landed in the next flush's commit. One shared
component-exact folder-name resolver now serves both Discard paths;
noteDiscarded takes cardFolderName; regression test verified failing
against the pre-fix code.
41 branch tests + the regression; 2374 tests / 409 suites green;
InertGitTests untouched.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1158 lines
51 KiB
Swift
1158 lines
51 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)
|
|
}
|
|
|
|
@MainActor
|
|
private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter {
|
|
let committer = try #require(git.committer)
|
|
committer.debounceInterval = .milliseconds(20)
|
|
committer.lockRetryDelay = .milliseconds(5)
|
|
committer.holdRecheckInterval = .milliseconds(20)
|
|
return committer
|
|
}
|
|
|
|
/// A provider wired to a real committer the way `AppModel.wireGitUndo` wires one, minus the seams
|
|
/// that need windows (the settle gate) and a store (the bracket) — those get their own suites.
|
|
@MainActor
|
|
private func makeProvider(
|
|
_ fixture: WriterFixture,
|
|
_ git: HistoryStore,
|
|
committer: GitAutoCommitter
|
|
) async -> GitHistoryProvider {
|
|
let provider = GitHistoryProvider(boardRoot: fixture.root)
|
|
provider.flushPendingCommit = { [weak committer] in await committer?.flushNow() }
|
|
provider.isHeld = { [weak committer] in committer?.pause != nil }
|
|
provider.suspendCommitting = { [weak committer] in committer?.stop() }
|
|
provider.resumeCommitting = { [weak committer] in committer?.start() }
|
|
committer.reportLanded = { [weak provider] window in provider?.noteLanded(window) }
|
|
await provider.reseed()
|
|
return provider
|
|
}
|
|
|
|
/// Commits the pending window and waits for the stack to have heard about it — production validates
|
|
/// a menu a turn later; a test asserts the instant the flush returns.
|
|
@MainActor
|
|
private func commitAndSettle(_ committer: GitAutoCommitter, _ provider: GitHistoryProvider) async {
|
|
await committer.flushNow()
|
|
await provider.settled()
|
|
}
|
|
|
|
// MARK: Reading the trail back
|
|
|
|
private struct TrailCommit: Equatable {
|
|
let oid: String
|
|
let subject: String
|
|
let parents: Int
|
|
}
|
|
|
|
/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the provider.
|
|
private func trail(at boardRoot: URL, limit: Int = 64) throws -> [TrailCommit] {
|
|
let repository = try Repository.open(at: boardRoot)
|
|
guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
|
|
|
|
var records: [TrailCommit] = []
|
|
var current: Commit? = tip
|
|
while let commit = current, records.count < limit {
|
|
records.append(TrailCommit(
|
|
oid: commit.id.hex,
|
|
subject: commit.summary,
|
|
parents: (try? commit.parents)?.count ?? 0
|
|
))
|
|
current = (try? commit.parents)?.first
|
|
}
|
|
return records
|
|
}
|
|
|
|
private func subjects(at boardRoot: URL) throws -> [String] {
|
|
try trail(at: boardRoot).map(\.subject)
|
|
}
|
|
|
|
/// Every line of `.git/logs/HEAD`, as (old oid, new oid, message) — the trail's own record of how
|
|
/// the reference moved, which is where a reset or a force would be visible if one had happened.
|
|
private func reflog(at boardRoot: URL) throws -> [(old: String, new: String, message: String)] {
|
|
let url = boardRoot.appendingPathComponent(".git/logs/HEAD")
|
|
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] }
|
|
return text.split(separator: "\n").compactMap { line in
|
|
let fields = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false)
|
|
let head = fields[0].split(separator: " ")
|
|
guard head.count >= 2 else { return nil }
|
|
return (String(head[0]), String(head[1]), fields.count > 1 ? String(fields[1]) : "")
|
|
}
|
|
}
|
|
|
|
/// An `AppModel` over its own scratch registry — `HistoryStoreTests`' helper, file-private there.
|
|
@MainActor
|
|
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
|
let folder = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("GitUndoTests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
let model = AppModel(
|
|
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
|
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
|
)
|
|
return (model, { try? FileManager.default.removeItem(at: folder) })
|
|
}
|
|
|
|
@MainActor
|
|
@discardableResult
|
|
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
|
|
let ref = BoardWindowRef(url: url)
|
|
let recordID = model.boardRegistry.recordOpen(of: url)
|
|
let store = try model.storeRegistry.acquire(url)
|
|
model.boardRegistry.setOpenNow(id: recordID)
|
|
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
|
return ref
|
|
}
|
|
|
|
private func title(ofCard folder: String, at boardRoot: URL) -> String? {
|
|
let url = boardRoot.appendingPathComponent(folder).appendingPathComponent("index.md")
|
|
guard let text = try? String(contentsOf: url, encoding: .utf8),
|
|
let document = try? FrontmatterDocument.parse(text) else { return nil }
|
|
return document.rawValue(for: "title")?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
// MARK: - The stack is HEAD's ancestry
|
|
|
|
@MainActor
|
|
@Suite("Git undo ▸ the stack is HEAD's first-parent ancestry")
|
|
struct GitUndoStackTests {
|
|
|
|
@Test("A fresh board seeds from HEAD, with an empty redo")
|
|
func seedingReadsTheAncestry() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
// One commit only — the root — which is deliberately not a step.
|
|
#expect(provider.ancestry.count == 1)
|
|
#expect(provider.canUndo == false, "the board's existence is not an undo step")
|
|
#expect(provider.canRedo == false, "redo starts empty")
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
#expect(provider.ancestry.count == 2)
|
|
#expect(provider.canUndo, "the commit that just landed is the step")
|
|
#expect(provider.undoActionName == "Add card 'Second'")
|
|
}
|
|
|
|
@Test("The menu label is the crossed commit's own subject, so labels never nest")
|
|
func theLabelIsTheCrossedSubject() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
#expect(provider.undoActionName == "Add card 'Second'")
|
|
await provider.cross(.undo)
|
|
|
|
// HEAD is now "Undo: Add card 'Second'" — but the label is the *next* crossable commit's,
|
|
// which is the root, and the root is not a step. Nothing nested (06 ▸ Commit messages).
|
|
#expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'")
|
|
#expect(provider.undoActionName == nil)
|
|
#expect(provider.redoActionName == "Add card 'Second'")
|
|
}
|
|
|
|
@Test("A commit landing from anywhere clears the redo stack")
|
|
func anArrivalClearsRedo() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
await provider.cross(.undo)
|
|
#expect(provider.canRedo)
|
|
|
|
// Something else commits — an agent's work arriving through the watcher.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Third"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
#expect(provider.canRedo == false, "classic behavior: an arrival clears redo")
|
|
#expect(provider.undoActionName == "Add card 'Third'", "and becomes the new top step")
|
|
}
|
|
|
|
@Test("A self-commit made outside the app is learned by the pre-flight sync — ⌘Z steps back one")
|
|
func thePreflightSyncLearnsForeignCommits() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
// An agent commits its own work: the tree moves and HEAD moves, with no signal to the app.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
let identity = GitIdentity(name: "Agent", email: "[email protected]")
|
|
let landed = GitCommitOperation.perform(
|
|
at: fixture.root,
|
|
commits: [PlannedCommit(
|
|
paths: GitCommitOperation.changedPaths(at: fixture.root).map(\.path),
|
|
message: "Add card 'Second'",
|
|
author: identity,
|
|
committer: identity
|
|
)]
|
|
)
|
|
guard case .committed = landed else {
|
|
Issue.record("the agent's own commit did not land")
|
|
return
|
|
}
|
|
#expect(provider.canUndo == false, "the cached stack has not heard about it yet")
|
|
|
|
await provider.cross(.undo)
|
|
|
|
// Exactly one step back: the agent's commit, not the whole session.
|
|
#expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'")
|
|
#expect(try trail(at: fixture.root).count == 3, "root, the agent's commit, the restore")
|
|
}
|
|
|
|
@Test("clear() drops the cache and leaves the repository untouched")
|
|
func clearingIsACacheDrop() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
#expect(provider.canUndo)
|
|
|
|
provider.clear()
|
|
#expect(provider.canUndo == false)
|
|
#expect(try trail(at: fixture.root).count == 2, "the trail is where it was")
|
|
|
|
await provider.reseed()
|
|
#expect(provider.canUndo, "and reseeding finds it again")
|
|
}
|
|
}
|
|
|
|
// MARK: - Forward commits, never a rewrite
|
|
|
|
@MainActor
|
|
@Suite("Git undo ▸ restores are forward commits")
|
|
struct GitUndoForwardTests {
|
|
|
|
@Test("Undo restores an earlier state as a new commit — refs only move forward")
|
|
func undoIsAForwardCommit() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
let before = try trail(at: fixture.root)
|
|
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")
|
|
|
|
await provider.cross(.undo)
|
|
|
|
let after = try trail(at: fixture.root)
|
|
// **The trail only grew.** Every commit that existed before the undo is still in HEAD's
|
|
// ancestry, in the same order, with the restore on top — which is precisely what a reset or
|
|
// a force could not produce.
|
|
#expect(after.count == before.count + 1)
|
|
#expect(Array(after.dropFirst()) == before)
|
|
#expect(after.first?.subject == "Undo: Rename card 'First' → 'Renamed'")
|
|
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First")
|
|
}
|
|
|
|
@Test("Redo restores forward again, as another new commit")
|
|
func redoIsAForwardCommitToo() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
await provider.cross(.undo)
|
|
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First")
|
|
|
|
await provider.cross(.redo)
|
|
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")
|
|
|
|
let subjects = try subjects(at: fixture.root)
|
|
#expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'")
|
|
#expect(subjects.count == 4, "root, rename, undo, redo — four commits, none rewritten")
|
|
#expect(provider.canRedo == false, "the redo list is spent")
|
|
#expect(provider.undoActionName == "Rename card 'First' → 'Renamed'", "and ⌘Z crosses it again")
|
|
}
|
|
|
|
@Test("The reflog only ever appends — no reset, no force")
|
|
func theReflogOnlyAppends() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
await provider.cross(.undo)
|
|
await provider.cross(.redo)
|
|
|
|
// **Trail inspection, as any git client would do it.** libgit2 writes one reflog entry per
|
|
// reference movement, naming the tip before and the tip after. Three facts together are the
|
|
// whole of "never reset, never force":
|
|
let entries = try reflog(at: fixture.root)
|
|
#expect(entries.count == 4, "root, rename, undo, redo — one entry per commit, nothing else")
|
|
|
|
// 1. Every movement is a *commit*. A reset writes "reset: …", a checkout "checkout: …", a
|
|
// force-fetch "update by push" — none of which the app can produce, and none of which is
|
|
// here.
|
|
#expect(entries.allSatisfy { $0.message.hasPrefix("commit") })
|
|
|
|
// 2. The chain is unbroken: each entry's `old` is the previous entry's `new`, so the branch
|
|
// never jumped sideways or backwards.
|
|
for (index, entry) in entries.enumerated() where index > 0 {
|
|
#expect(entry.old == entries[index - 1].new)
|
|
}
|
|
|
|
// 3. Every tip the reference ever held is still in HEAD's first-parent ancestry — old commits
|
|
// stay reachable, which is precisely what a rewrite would destroy.
|
|
let reachable = Set(try trail(at: fixture.root).map(\.oid))
|
|
#expect(entries.dropFirst().allSatisfy { reachable.contains($0.old) })
|
|
#expect(entries.allSatisfy { reachable.contains($0.new) })
|
|
|
|
let repository = try Repository.open(at: fixture.root)
|
|
#expect(try repository.HEAD.name == GitRepository.initialBranchName, "and the branch is the same one")
|
|
}
|
|
|
|
@Test("A restore materializes only the diff — an untouched card's file is not rewritten")
|
|
func onlyTheDiffIsMaterialized() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
// A third card, uncommitted and untouched by the restore's diff.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Bystander"))
|
|
let bystander = fixture.root
|
|
.appendingPathComponent("\(Ident.lane1)/\(Ident.card3)/index.md")
|
|
let before = try Data(contentsOf: bystander)
|
|
|
|
let root = try #require(provider.ancestry.last?.oid)
|
|
let plan = try #require(GitRestoreOperation.plan(at: fixture.root, target: root))
|
|
#expect(plan.paths.allSatisfy { $0.contains(Ident.card2) },
|
|
"the plan names only what differs between the two commits")
|
|
#expect(try Data(contentsOf: bystander) == before, "and the bystander's bytes are its own")
|
|
}
|
|
|
|
@Test("A card the undo removes takes its emptied folder with it")
|
|
func aRemovedCardLeavesNoEmptyFolder() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
await provider.cross(.undo)
|
|
|
|
let folder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card2)")
|
|
#expect(FileManager.default.fileExists(atPath: folder.path) == false)
|
|
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty,
|
|
"and the tree is clean — the restore committed everything it wrote")
|
|
}
|
|
|
|
@Test("A pending auto-commit flushes before the restore, so both states are commits")
|
|
func theRestoreSettlesTheTreeFirst() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
// A change that has *not* been committed when ⌘Z arrives.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
|
|
await provider.cross(.undo)
|
|
|
|
let subjects = try subjects(at: fixture.root)
|
|
// The pending window became its own commit first — so the state ⌘Z stepped back *from*
|
|
// exists in the trail — and the restore sits on top of it.
|
|
#expect(subjects.first == "Undo: Add card 'Second'")
|
|
#expect(subjects.dropFirst().first == "Add card 'Second'")
|
|
}
|
|
}
|
|
|
|
// MARK: - Heal transparency
|
|
|
|
@MainActor
|
|
@Suite("Git undo ▸ heal commits are transparent")
|
|
struct GitUndoHealTransparencyTests {
|
|
|
|
@Test("The pointer passes over a heal commit and crosses the step beneath it")
|
|
func thePointerPassesOverAHeal() async throws {
|
|
let (fixture, git, ledger) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
// An ordinary step.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
// A heal: the app's own repair, vouched for by a heal-marked receipt.
|
|
let healed = fixture.root.appendingPathComponent(AgentGuide.filename)
|
|
try "<!-- lanework-guide v2 -->\nrepaired\n".write(to: healed, atomically: true, encoding: .utf8)
|
|
ledger.recordWrite(at: healed, data: try Data(contentsOf: healed))
|
|
ledger.markHeal(at: healed)
|
|
committer.noteWriteBracketClosed()
|
|
await commitAndSettle(committer, provider)
|
|
|
|
#expect(try trail(at: fixture.root).count == 3, "root, the add, the heal")
|
|
// The heal is on top of the trail, and the label names the *add* beneath it.
|
|
#expect(provider.undoActionName == "Add card 'Second'", "the heal is not a step")
|
|
|
|
await provider.cross(.undo)
|
|
#expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'")
|
|
}
|
|
|
|
@Test("A restore excludes the paths a heal repaired — the repair survives the undo")
|
|
func aRestoreExcludesHealedPaths() async throws {
|
|
let (fixture, git, ledger) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
let guide = fixture.root.appendingPathComponent(AgentGuide.filename)
|
|
try "<!-- lanework-guide v2 -->\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8)
|
|
ledger.recordWrite(at: guide, data: try Data(contentsOf: guide))
|
|
ledger.markHeal(at: guide)
|
|
committer.noteWriteBracketClosed()
|
|
await commitAndSettle(committer, provider)
|
|
|
|
// ⌘Z crosses the add — whose parent is the root commit, which predates the guide entirely.
|
|
// Without the exclusion the restore would delete the healer's file and re-arm it.
|
|
await provider.cross(.undo)
|
|
|
|
#expect(FileManager.default.fileExists(atPath: guide.path),
|
|
"the repair is never reverted (06 ▸ Rules ▸ Heal commits are transparent)")
|
|
let text = try String(contentsOf: guide, encoding: .utf8)
|
|
#expect(text.contains("repaired"))
|
|
}
|
|
|
|
@Test("A heal landing mid-run leaves the pointer and the redo list alone")
|
|
func aHealDoesNotTrapAnUndoRun() async throws {
|
|
let (fixture, git, ledger) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
await provider.cross(.undo)
|
|
#expect(provider.canRedo, "a step is on the redo list")
|
|
|
|
// The healer runs. An ordinary arrival would clear redo and trap the run on a renewing top.
|
|
let guide = fixture.root.appendingPathComponent(AgentGuide.filename)
|
|
try "<!-- lanework-guide v2 -->\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8)
|
|
ledger.recordWrite(at: guide, data: try Data(contentsOf: guide))
|
|
ledger.markHeal(at: guide)
|
|
committer.noteWriteBracketClosed()
|
|
await commitAndSettle(committer, provider)
|
|
|
|
#expect(provider.canRedo, "transparency dissolves the trap rather than suppressing the healer")
|
|
#expect(provider.redoActionName == "Add card 'Second'")
|
|
}
|
|
|
|
@Test("A window's landed commits are reported with their classes")
|
|
func theCommitterReportsClasses() async throws {
|
|
let (fixture, git, ledger) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
|
|
var seen: [GitLandedWindow] = []
|
|
committer.reportLanded = { seen.append($0) }
|
|
|
|
// One window carrying a heal and a foreign change: two commits, two classes.
|
|
let guide = fixture.root.appendingPathComponent(AgentGuide.filename)
|
|
try "<!-- lanework-guide v2 -->\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8)
|
|
ledger.recordWrite(at: guide, data: try Data(contentsOf: guide))
|
|
ledger.markHeal(at: guide)
|
|
committer.noteWriteBracketClosed()
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await committer.flushNow()
|
|
|
|
let window = try #require(seen.first)
|
|
#expect(window.commits.count == 2)
|
|
#expect(window.healOIDs.count == 1)
|
|
#expect(window.healPaths.contains(AgentGuide.filename))
|
|
#expect(window.isEntirelyHeal == false)
|
|
}
|
|
}
|
|
|
|
// MARK: - The abnormal-state pause
|
|
|
|
@MainActor
|
|
@Suite("Git undo ▸ the held repository disables the pair")
|
|
struct GitUndoHoldTests {
|
|
|
|
@Test("A held repository disables Undo and Redo")
|
|
func aHeldRepositoryDisablesThePair() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
#expect(provider.canUndo)
|
|
|
|
// A merge left by outside-the-app git — 06 ▸ Rules ▸ Abnormal repo states: the *whole* git
|
|
// surface pauses, "Undo/Redo and the branch controls disable".
|
|
let marker = fixture.root.appendingPathComponent(".git/MERGE_HEAD")
|
|
try "0000000000000000000000000000000000000000\n".write(to: marker, atomically: true, encoding: .utf8)
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Third"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
#expect(committer.pause == .merge)
|
|
|
|
#expect(provider.canUndo == false)
|
|
#expect(provider.canRedo == false)
|
|
|
|
let before = try trail(at: fixture.root)
|
|
await provider.cross(.undo)
|
|
#expect(try trail(at: fixture.root) == before, "and a crossing that started anyway writes nothing")
|
|
}
|
|
}
|
|
|
|
// MARK: - The save-or-discard gate
|
|
|
|
@MainActor
|
|
@Suite("Git undo ▸ the save-or-discard step")
|
|
struct SessionSettleGateTests {
|
|
|
|
/// A settleable session a test drives by hand.
|
|
@MainActor
|
|
private final class FakeSession {
|
|
var isSettled = false
|
|
var saveSucceeds = true
|
|
var saves = 0
|
|
var discards = 0
|
|
|
|
func descriptor(id: String, folder: String) -> SettleableSession {
|
|
SettleableSession(
|
|
id: id,
|
|
cardFolderName: folder,
|
|
needsSettling: { [self] in !isSettled },
|
|
saveAll: { [self] in
|
|
saves += 1
|
|
guard saveSucceeds else { return false }
|
|
isSettled = true
|
|
return true
|
|
},
|
|
discard: { [self] in
|
|
discards += 1
|
|
isSettled = true
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
@Test("A diff that touches no session card never asks")
|
|
func anUntouchedSessionIsNeverAsked() async throws {
|
|
let session = FakeSession()
|
|
var asked = 0
|
|
let gate = SessionSettleGate(
|
|
sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
|
|
ask: { asked += 1; return .cancel }
|
|
)
|
|
|
|
let outcome = await gate.settle(touching: ["lane-1/card-b/index.md"])
|
|
|
|
#expect(outcome == .proceed)
|
|
#expect(asked == 0, "most undos never meet an editor at all")
|
|
#expect(session.saves == 0)
|
|
}
|
|
|
|
@Test("A clean session in the diff's path is not asked about either")
|
|
func aSettledSessionIsNotAsked() async throws {
|
|
let session = FakeSession()
|
|
session.isSettled = true
|
|
var asked = 0
|
|
let gate = SessionSettleGate(
|
|
sessions: { [session.descriptor(id: "card-a", folder: "card-a")] },
|
|
ask: { asked += 1; return .cancel }
|
|
)
|
|
|
|
#expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed)
|
|
#expect(asked == 0)
|
|
}
|
|
|
|
@Test("Save All ends every touched session; Cancel keeps everything")
|
|
func theThreeAnswers() async throws {
|
|
let first = FakeSession()
|
|
let second = FakeSession()
|
|
let descriptors = {
|
|
[
|
|
first.descriptor(id: "card-a", folder: "card-a"),
|
|
second.descriptor(id: "card-b", folder: "card-b")
|
|
]
|
|
}
|
|
|
|
var choice = SessionSettleChoice.cancel
|
|
let gate = SessionSettleGate(sessions: descriptors, ask: { choice })
|
|
|
|
#expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .cancelled)
|
|
#expect(first.saves == 0)
|
|
#expect(first.discards == 0, "Cancel keeps everything")
|
|
|
|
choice = .saveAll
|
|
#expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed)
|
|
#expect(first.saves == 1)
|
|
#expect(second.saves == 0, "only the sessions the diff reaches")
|
|
|
|
choice = .discard
|
|
#expect(await gate.settle(touching: ["lane-2/card-b/index.md"]) == .proceed)
|
|
#expect(second.discards == 1)
|
|
}
|
|
|
|
@Test("A raw buffer that will not validate cancels the whole operation, focused on the offender")
|
|
func aFailingBufferCancelsEverything() async throws {
|
|
let good = FakeSession()
|
|
let bad = FakeSession()
|
|
bad.saveSucceeds = false
|
|
|
|
var focused: [String] = []
|
|
let gate = SessionSettleGate(
|
|
sessions: {
|
|
[
|
|
good.descriptor(id: "card-a", folder: "card-a"),
|
|
bad.descriptor(id: "card-b", folder: "card-b")
|
|
]
|
|
},
|
|
ask: { .saveAll },
|
|
focus: { focused.append($0) }
|
|
)
|
|
|
|
let outcome = await gate.settle(touching: [
|
|
"lane-1/card-a/index.md",
|
|
"lane-1/card-b/index.md"
|
|
])
|
|
|
|
#expect(outcome == .failed("card-b"))
|
|
#expect(focused == ["card-b"], "focus on the offending window")
|
|
#expect(bad.isSettled == false)
|
|
}
|
|
|
|
@Test("Folder matching is component-exact — a prefix does not borrow another card's session")
|
|
func matchingIsComponentExact() throws {
|
|
let session = FakeSession()
|
|
let descriptors = [session.descriptor(id: "card-1", folder: "card-1")]
|
|
|
|
#expect(SessionSettleGate.reached(by: ["lane/card-1/index.md"], among: descriptors).count == 1)
|
|
#expect(SessionSettleGate.reached(by: ["lane/card-10/index.md"], among: descriptors).isEmpty)
|
|
#expect(SessionSettleGate.reached(by: ["card-1"], among: descriptors).isEmpty,
|
|
"the folder itself is not a file inside it")
|
|
}
|
|
}
|
|
|
|
// MARK: - The restore meeting a session
|
|
|
|
@MainActor
|
|
@Suite("Git undo ▸ restores meet open sessions")
|
|
struct GitUndoSessionTests {
|
|
|
|
@Test("A restore whose diff misses every session runs untouched")
|
|
func anUnrelatedSessionIsUndisturbed() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
var asked = 0
|
|
provider.settleSessions = { paths in
|
|
let gate = SessionSettleGate(
|
|
sessions: {
|
|
[SettleableSession(
|
|
id: Ident.card1,
|
|
cardFolderName: Ident.card1,
|
|
needsSettling: { true },
|
|
saveAll: { true },
|
|
discard: {}
|
|
)]
|
|
},
|
|
ask: { asked += 1; return .cancel }
|
|
)
|
|
return await gate.settle(touching: paths)
|
|
}
|
|
|
|
await provider.cross(.undo)
|
|
|
|
#expect(asked == 0, "the diff names card 2 only")
|
|
#expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'")
|
|
}
|
|
|
|
@Test("Cancel at the step leaves the tree and the stack exactly as they were")
|
|
func cancelStopsTheRestore() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
let before = try trail(at: fixture.root)
|
|
|
|
provider.settleSessions = { _ in .cancelled }
|
|
await provider.cross(.undo)
|
|
|
|
#expect(try trail(at: fixture.root) == before, "nothing was written")
|
|
#expect(provider.canRedo == false, "and the stack did not move")
|
|
#expect(provider.undoActionName == "Rename card 'First' → 'Renamed'")
|
|
}
|
|
|
|
@Test("Discard reconciles the session card against the working tree, not against HEAD")
|
|
func discardRevertsUncommittedSaves() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
// **An open Edit session**, whose folder the committer stages around: its ~700 ms save lands
|
|
// on disk and is deliberately never committed. That is the exact state 06 says a restore
|
|
// would otherwise bury — and the reason the step exists at all.
|
|
let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
|
|
let token = UUID()
|
|
committer.beginEditSession(token) { cardFolder }
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Half-typed"))
|
|
committer.noteReloadLanded(sawForeignChange: false)
|
|
await commitAndSettle(committer, provider)
|
|
#expect(try subjects(at: fixture.root).first == "Rename card 'First' → 'Renamed'",
|
|
"the session's saves committed nothing while it stood")
|
|
|
|
provider.settleSessions = { _ in
|
|
// What the gate's Discard branch does — **exactly as production does it**: the window
|
|
// reverts its buffer and ends its session, and the card's *folder name* (its id, which is
|
|
// what `AppModel`'s gate hands over) goes to the plan to reconcile against the working
|
|
// tree. Passing the `<lane>/<card>` path here instead is what once made this test pass
|
|
// over a rule that did not work at all — see `discardReconcilesACardIdentifiedByName`.
|
|
committer.endEditSession(token)
|
|
provider.noteDiscarded(cardFolderName: Ident.card1)
|
|
return .proceed
|
|
}
|
|
await provider.cross(.undo)
|
|
|
|
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First",
|
|
"the discarded save is gone and the restore landed, in one pass")
|
|
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "on a settled tree")
|
|
}
|
|
|
|
/// **The regression.** `AppModel`'s settle gate reports a discarded session by
|
|
/// `CardWindowRef.cardID` — a folder *name* — and the plan matched it as a board-root-relative
|
|
/// path prefix. Every live card is `<lane>/<card>`, so the match never fired on any board: Discard
|
|
/// reverted the in-memory buffer and left the uncommitted on-disk saves exactly where they were,
|
|
/// which then rode into the next commit — the one outcome 06 ▸ Rules ▸ Undo restore vs open Edit
|
|
/// sessions singles out ("a surviving dirty buffer's next debounced save would write pre-undo text
|
|
/// over the restored card — a ⌘Z that visibly doesn't happen").
|
|
///
|
|
/// The fix is one shared resolver (`GitRestoreOperation.folderPaths(named:at:)`), so this asserts
|
|
/// the property the resolver exists for: a card nested under a lane, named only by its id.
|
|
///
|
|
/// **What it takes to see the bug.** The restore's own diff already rewrites the session card's
|
|
/// `index.md` — that is why the gate appeared at all — so a test that only checks the body is
|
|
/// green either way. What reconciliation alone can reach is the session's uncommitted state the
|
|
/// diff *cannot* name: a file the session created, present in neither HEAD nor the target, which
|
|
/// the plan can only learn about by walking the folder on disk. That is what this leaves behind
|
|
/// and then looks for.
|
|
@Test("Discard reconciles a card identified by folder name alone, however deep it is nested")
|
|
func discardReconcilesACardIdentifiedByName() async throws {
|
|
let (fixture, git, _) = try await makeGitBoard()
|
|
defer { fixture.tearDown() }
|
|
let committer = try quickCommitter(git)
|
|
let provider = await makeProvider(fixture, git, committer: committer)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
|
committer.noteReloadLanded(sawForeignChange: true)
|
|
await commitAndSettle(committer, provider)
|
|
|
|
// The open session's uncommitted work, staged around and committed by nothing: a crash-safe
|
|
// body save, and a file the session added beside it (05-card-window.md's attachments land in
|
|
// the card's own folder) — which no commit anywhere has ever seen.
|
|
let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
|
|
let token = UUID()
|
|
committer.beginEditSession(token) { cardFolder }
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed", body: "Half-typed."))
|
|
let stray = "\(Ident.lane1)/\(Ident.card1)/attachments/sketch.txt"
|
|
try fixture.file(stray, Data("dropped mid-session".utf8))
|
|
|
|
provider.settleSessions = { _ in
|
|
committer.endEditSession(token)
|
|
// The bare id — never the path. This is the whole regression.
|
|
provider.noteDiscarded(cardFolderName: Ident.card1)
|
|
return .proceed
|
|
}
|
|
await provider.cross(.undo)
|
|
|
|
#expect(!fixture.exists(stray),
|
|
"the discarded session's uncommitted file is gone from disk, not merely from a buffer")
|
|
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First",
|
|
"and the restore landed over the body")
|
|
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty,
|
|
"on a settled tree — nothing is left for the next flush to sweep into a commit")
|
|
}
|
|
}
|
|
|
|
// MARK: - The provider binding
|
|
|
|
@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 no provider at all — the pair disables")
|
|
func proOnAPlainBoardBindsNothing() 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))
|
|
|
|
#expect(session.gitMode == .none)
|
|
#expect(session.history == nil, "a board without git has no undo/redo")
|
|
#expect(session.undoManager.canUndo == false)
|
|
#expect(session.undoManager.canRedo == false)
|
|
#expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name")
|
|
// And a crossing that somehow started still writes nothing.
|
|
session.undoManager.undo()
|
|
session.undoManager.redo()
|
|
}
|
|
|
|
@Test("Pro on a repo-nested board binds no provider either")
|
|
func proOnARepoNestedBoardBindsNothing() throws {
|
|
let outer = try WriterFixture()
|
|
defer { outer.tearDown() }
|
|
// A repository at the *parent*, with the board inside it — the nested posture.
|
|
let repoRoot = outer.root
|
|
_ = GitRepository.create(at: repoRoot)
|
|
let boardRoot = repoRoot.appendingPathComponent("board", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
|
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
|
|
|
|
let (model, tearDown) = try makeModel()
|
|
defer { tearDown() }
|
|
model.currentTier = { .pro }
|
|
|
|
let ref = try openBoard(model, at: boardRoot)
|
|
let session = try #require(model.session(for: ref))
|
|
|
|
#expect(session.gitMode == .repoNested)
|
|
#expect(session.history == nil, "the app leaves that repository strictly alone")
|
|
#expect(session.undoManager.canUndo == false)
|
|
}
|
|
|
|
@Test("Add-git binds the provider on the open session — the commanded mid-session flip")
|
|
func addGitBindsUndoLive() 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)
|
|
#expect(try #require(model.session(for: ref)).history == nil)
|
|
|
|
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.undoManager.canUndo == false, "on a trail whose only commit is the root")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|