HistoryStore.compose(boardRoot📒) returns non-optional and runs for every session — the nil the gate produced was the only nil it ever had. makeHistoryProvider is a one-axis decision: git-mode boards bind the git provider, everything else native, in every tier; Session.tier stays recorded, dormant. BoardGitSection shrinks to the four mode postures (.absent and .proPointer die, BoardGitNote and the .git probe with them); every board carries all three popover tabs (BoardInfoTab.available retired); the titlebar branch shows on any git-mode board; the settings sheet and card History section stop reading tier. InertGitTests is repurposed as UntouchedGitTests — the file layer still never opens .git, now load-bearing for mode-none boards. The accessibility audit reaches the settings sheet at last: the fixture board hosts it in every tier, so the free-fixture disabled-row test becomes an open-and-audit test. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
295 lines
15 KiB
Swift
295 lines
15 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// **The file layer never touches `.git`, stated against real bytes on disk** — every write and every
|
|
/// load the app makes leaves the whole `.git` subtree byte- and mtime-identical.
|
|
///
|
|
/// ### What this file used to be, and what it is now
|
|
///
|
|
/// It was written as the **inert-`.git` posture**'s byte-level proof (12-editions.md ▸ The free tier
|
|
/// and `.git`, as it then read: "any `.git` is inert … the app never reads history, never commits,
|
|
/// never touches `.git` in any way"). **PIVOT 2026-08-07** retired that posture outright — git left
|
|
/// the paywall, so a `.git` at a board root is *live* in every tier, detection runs at every open,
|
|
/// and the auto-committer writes into exactly the directory this file's fixture plants.
|
|
///
|
|
/// The tests survive the retirement unchanged because none of them ever composed a `HistoryStore` or
|
|
/// asserted anything about a tier: they drive `BoardWriter` and `BoardLoader` directly, and what they
|
|
/// pin is that **those layers are git-agnostic**. That claim is not only still true, it is now
|
|
/// load-bearing in two places the retired posture never reached:
|
|
///
|
|
/// - **A board nobody added git to** (mode `none`) is the one board the app manages no git for, for
|
|
/// good — git stays opt-in per board (06 ▸ Rules; 13-native-undo.md's header read through the
|
|
/// pivot). Nothing but this layer runs on such a board, so this layer's indifference *is* the
|
|
/// whole promise.
|
|
/// - **A `.git` the app does not manage** — a repo-nested board's ancestor, a clone dropped inside a
|
|
/// card folder (which the fixture below plants deliberately) — is left strictly alone by the same
|
|
/// indifference, whatever the board's own mode or the user's tier.
|
|
///
|
|
/// Two neighbouring claims are pinned elsewhere and are referenced, not repeated: `FolderWatcherTests`
|
|
/// ▸ ".git filtering" proves the watcher ignores churn under a `.git` at any depth, and
|
|
/// `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a `.git` at the
|
|
/// board root loads as an ordinary stray with no warning. Both are *input* claims: what the app does
|
|
/// with events and entries it is handed.
|
|
///
|
|
/// This file states the **output** claim: a full session of ordinary editing leaves every byte and
|
|
/// every mtime under `.git` exactly as it found them. It is asserted the only way
|
|
/// that is worth anything — by snapshotting the whole `.git` subtree from the filesystem before
|
|
/// the edits and re-reading it afterwards, never through the app's own read path
|
|
/// (`WriterTestSupport.swift`'s standing rule).
|
|
///
|
|
/// Note what "untouched" is worth as an mtime assertion specifically: bytes alone would pass even
|
|
/// if the app rewrote a file with identical content, and re-writing git's index with identical
|
|
/// bytes is exactly the kind of thing an accidental git dependency would do. The mtimes are the
|
|
/// assertion that nothing *opened for writing* down there at all.
|
|
|
|
// MARK: - Subtree snapshots
|
|
|
|
/// One entry under `.git`: its path relative to the board root, its bytes (nil for directories),
|
|
/// and its on-disk modification date. Directories carry an mtime too — a file created or removed
|
|
/// inside a directory moves *that directory's* mtime, so including them catches a write the
|
|
/// per-file comparison would miss because the file it added is not in the "before" set.
|
|
private struct SubtreeEntry: Equatable, CustomStringConvertible {
|
|
let relativePath: String
|
|
let data: Data?
|
|
let modified: Date
|
|
|
|
var description: String {
|
|
"\(relativePath) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))"
|
|
}
|
|
}
|
|
|
|
/// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `.git` is itself
|
|
/// hidden and everything inside it is reached through it, so `.skipsHiddenFiles` is deliberately
|
|
/// *not* passed — a snapshot that skipped hidden files would snapshot nothing at all.
|
|
private func snapshotSubtree(_ root: URL, _ subtree: String) throws -> [SubtreeEntry] {
|
|
let base = root.appendingPathComponent(subtree, isDirectory: true)
|
|
let manager = FileManager.default
|
|
guard let walker = manager.enumerator(atPath: base.path) else {
|
|
Issue.record("could not enumerate \(subtree)")
|
|
return []
|
|
}
|
|
|
|
var entries: [SubtreeEntry] = []
|
|
for case let relative as String in walker {
|
|
let url = base.appendingPathComponent(relative)
|
|
let attributes = try manager.attributesOfItem(atPath: url.path)
|
|
guard let modified = attributes[.modificationDate] as? Date else {
|
|
Issue.record("no modification date for \(subtree)/\(relative)")
|
|
continue
|
|
}
|
|
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
|
|
entries.append(SubtreeEntry(
|
|
relativePath: "\(subtree)/\(relative)",
|
|
data: isDirectory ? nil : try Data(contentsOf: url),
|
|
modified: modified
|
|
))
|
|
}
|
|
|
|
// The directory the subtree hangs from, which the enumerator above does not yield.
|
|
let rootAttributes = try manager.attributesOfItem(atPath: base.path)
|
|
if let modified = rootAttributes[.modificationDate] as? Date {
|
|
entries.append(SubtreeEntry(relativePath: subtree, data: nil, modified: modified))
|
|
}
|
|
|
|
return entries.sorted { $0.relativePath < $1.relativePath }
|
|
}
|
|
|
|
// MARK: - Fixture construction
|
|
|
|
/// A `.git` directory with the shape a real one has — a ref file, a binary index, a loose object
|
|
/// two levels down, a packed-refs file, and an empty `objects/pack` — written under `parent`.
|
|
/// The bytes are sentinels: recognizable, non-UTF-8 in the index's case, and nothing the app has
|
|
/// any reader for.
|
|
@discardableResult
|
|
private func makeGitDirectory(in fixture: WriterFixture, under parent: String) throws -> String {
|
|
let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
|
|
|
|
try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
|
|
try fixture.file("\(prefix)/config", Data("[core]\n\trepositoryformatversion = 0\n".utf8))
|
|
// Deliberately not valid UTF-8 — a real `.git/index` is binary, and a byte comparison that
|
|
// only ever sees text is not testing the thing that matters.
|
|
try fixture.file("\(prefix)/index", Data([0x44, 0x49, 0x52, 0x43, 0x00, 0x00, 0x00, 0x02, 0xFF, 0xFE, 0x00, 0x01]))
|
|
try fixture.file("\(prefix)/objects/ab/cdef0123456789", Data([0x78, 0x01, 0xCB, 0xC8, 0x4F, 0x00, 0x00]))
|
|
try fixture.file("\(prefix)/refs/heads/main", Data("0123456789abcdef0123456789abcdef01234567\n".utf8))
|
|
try fixture.file("\(prefix)/packed-refs", Data("# pack-refs with: peeled fully-peeled sorted\n".utf8))
|
|
try FileManager.default.createDirectory(
|
|
at: fixture.url("\(prefix)/objects/pack"),
|
|
withIntermediateDirectories: true
|
|
)
|
|
|
|
return prefix
|
|
}
|
|
|
|
/// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root
|
|
/// **and** a second one nested inside a card folder. Both, deliberately: the nested one is a `.git`
|
|
/// the app never manages under any ruling (a clone dropped inside a card), and the root one is the
|
|
/// case the pivot changed — it is a live repository to the *git* layer now, and still nothing at all
|
|
/// to the file layer these tests drive.
|
|
private struct UntouchedGitBoard {
|
|
let fixture: WriterFixture
|
|
let laneA: String
|
|
let laneB: String
|
|
let card1: String
|
|
let card2: String
|
|
/// The card carrying the nested repo — the one the move test drags across lanes.
|
|
let cardWithRepo: String
|
|
|
|
init() throws {
|
|
fixture = try WriterFixture()
|
|
laneA = Ident.lane1
|
|
laneB = Ident.lane2
|
|
card1 = Ident.card1
|
|
card2 = Ident.card2
|
|
cardWithRepo = Ident.card3
|
|
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(laneA, Item.rich(order: "1024", title: "Doing"))
|
|
try fixture.item(laneB, Item.rich(order: "2048", title: "Done"))
|
|
try fixture.item("\(laneA)/\(card1)", Item.rich(order: "1024", title: "First"))
|
|
try fixture.item("\(laneA)/\(card2)", Item.rich(order: "2048", title: "Second"))
|
|
try fixture.item("\(laneA)/\(cardWithRepo)", Item.rich(order: "3072", title: "Has a clone"))
|
|
|
|
try makeGitDirectory(in: fixture, under: "")
|
|
try makeGitDirectory(in: fixture, under: "\(laneA)/\(cardWithRepo)")
|
|
}
|
|
|
|
var root: URL { fixture.root }
|
|
func url(_ relativePath: String) -> URL { fixture.url(relativePath) }
|
|
func tearDown() { fixture.tearDown() }
|
|
}
|
|
|
|
// MARK: - The claim
|
|
|
|
@Suite("The file layer never touches `.git`")
|
|
struct UntouchedGitTests {
|
|
@Test("A full session of ordinary edits leaves every byte and mtime under .git untouched")
|
|
func anEditingSessionNeverTouchesGit() throws {
|
|
let board = try UntouchedGitBoard()
|
|
defer { board.tearDown() }
|
|
|
|
let before = try snapshotSubtree(board.root, ".git")
|
|
#expect(before.count == 12, "the fixture's own shape — files, directories and the root")
|
|
|
|
// A session's worth of every write the app can make, in one go. `.git` is at the
|
|
// board root, so anything that walks or renumbers the root's children walks past it.
|
|
try BoardWriter.updateIndex(inItemFolder: board.url(""), operation: .rename(title: nil)) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Renamed board"))
|
|
}
|
|
let newLane = try BoardWriter.createLane(inBoard: board.root, title: "Later")
|
|
let newCard = try BoardWriter.createCard(inLane: board.url(newLane.rawValue), title: "Fresh")
|
|
try BoardWriter.writeBody(inItemFolder: board.url("\(newLane.rawValue)/\(newCard.rawValue)"), body: "Body text.\n")
|
|
try BoardWriter.updateIndex(
|
|
inItemFolder: board.url("\(board.laneA)/\(board.card1)"),
|
|
operation: .style(title: nil)
|
|
) { document in
|
|
document.set(FrontmatterKeys.title, to: .string("Retitled"))
|
|
}
|
|
_ = try BoardWriter.moveItem(
|
|
at: board.url("\(board.laneA)/\(board.card2)"),
|
|
toParent: board.url(board.laneB),
|
|
sourceBoardRoot: board.root,
|
|
destinationBoardRoot: board.root,
|
|
order: nil
|
|
)
|
|
_ = try BoardWriter.copyItem(
|
|
at: board.url("\(board.laneA)/\(board.card1)"),
|
|
toParent: board.url(board.laneB),
|
|
order: nil,
|
|
stamps: .fork
|
|
)
|
|
// The delete and its restore are both folder moves now (03-board-ui.md § Trash), which is
|
|
// the pair most likely to notice a `.git` at the root: the first walks into `<root>/.trash/`
|
|
// and the second walks back out of it.
|
|
try BoardWriter.deleteCardToTrash(
|
|
at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root
|
|
)
|
|
_ = try BoardWriter.moveItem(
|
|
at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1),
|
|
toParent: board.url(board.laneA),
|
|
sourceBoardRoot: board.root,
|
|
destinationBoardRoot: board.root,
|
|
order: nil
|
|
)
|
|
// The renumbers are the pointed ones: both walk a parent's whole directory listing, which
|
|
// is where a `.git` entry actually gets looked at.
|
|
try BoardWriter.renumberVisibleChildren(of: board.root)
|
|
try BoardWriter.renumberVisibleChildren(of: board.url(board.laneA))
|
|
|
|
#expect(try snapshotSubtree(board.root, ".git") == before)
|
|
}
|
|
|
|
@Test("The board loads and renders normally with a .git at its root, without a warning")
|
|
func aGitBearingBoardLoadsLikeAnyOther() throws {
|
|
let board = try UntouchedGitBoard()
|
|
defer { board.tearDown() }
|
|
|
|
let before = try snapshotSubtree(board.root, ".git")
|
|
|
|
let result = try BoardLoader.load(boardRoot: board.root)
|
|
|
|
#expect(result.model.lanes.map(\.id.rawValue) == [board.laneA, board.laneB])
|
|
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [board.card1, board.card2, board.cardWithRepo])
|
|
#expect(result.warnings.isEmpty, "a `.git` is a stray like any other — strays are silent")
|
|
// A load is a read, but a read that opened `.git` would still move its atimes and would
|
|
// still be the app "touching" history; the mtime equality is what is checkable, and a
|
|
// loader that decided to *repair* something down there would break it.
|
|
#expect(try snapshotSubtree(board.root, ".git") == before)
|
|
}
|
|
|
|
@Test("A nested .git rides along a card move byte- and mtime-verbatim")
|
|
func aNestedRepositorySurvivesACardMove() throws {
|
|
let board = try UntouchedGitBoard()
|
|
defer { board.tearDown() }
|
|
|
|
let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
|
|
let before = try snapshotSubtree(board.root, nested)
|
|
|
|
let result = try BoardWriter.moveItem(
|
|
at: board.url("\(board.laneA)/\(board.cardWithRepo)"),
|
|
toParent: board.url(board.laneB),
|
|
sourceBoardRoot: board.root,
|
|
destinationBoardRoot: board.root,
|
|
order: nil
|
|
)
|
|
|
|
// Same identity, new parent — the folder moved whole, `.git` inside it.
|
|
let moved = "\(board.laneB)/\(result.id.rawValue)/.git"
|
|
let after = try snapshotSubtree(board.root, moved)
|
|
|
|
#expect(after.map(\.data) == before.map(\.data))
|
|
#expect(after.map(\.modified) == before.map(\.modified))
|
|
#expect(
|
|
after.map { $0.relativePath.replacingOccurrences(of: moved, with: nested) } == before.map(\.relativePath)
|
|
)
|
|
}
|
|
|
|
@Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone")
|
|
func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws {
|
|
let board = try UntouchedGitBoard()
|
|
defer { board.tearDown() }
|
|
|
|
let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
|
|
let before = try snapshotSubtree(board.root, nested)
|
|
|
|
let copyID = try BoardWriter.copyItem(
|
|
at: board.url("\(board.laneA)/\(board.cardWithRepo)"),
|
|
toParent: board.url(board.laneB),
|
|
order: nil,
|
|
stamps: .fork
|
|
)
|
|
|
|
#expect(try snapshotSubtree(board.root, nested) == before, "the source is never touched")
|
|
|
|
// The copy's `.git` is the same tree with the same bytes (mtimes are a copy's to set —
|
|
// `FileManager.copyItem` preserves them, but the promise being made here is about content
|
|
// and shape, not about a copy having been a rename).
|
|
let copied = try snapshotSubtree(board.root, "\(board.laneB)/\(copyID.rawValue)/.git")
|
|
#expect(copied.map(\.data) == before.map(\.data))
|
|
#expect(
|
|
copied.map { $0.relativePath.replacingOccurrences(of: "\(board.laneB)/\(copyID.rawValue)/.git", with: nested) }
|
|
== before.map(\.relativePath)
|
|
)
|
|
}
|
|
}
|