Materialize the trash — storage layer
Phase 1 of the trash pivot: the file format learns .trash/. The loader parses the reserved root container — cards only, one shared parseCard for both containers so fail-fast, attachments, and verbatim documents are literally the same code; absent means empty; symlinks and lane-shaped nestings fall out as strays by construction. BoardModel grows snapshot.trash as a plain rank-ordered card list — the container has no identity to carry. Legacy deleted: keys keep flowing through the retiring flag path so every tombstone consumer stays green, and are additionally reported through LoadResult.legacyTombstones in the loose-file idiom for phase 2's migration scheduling — nothing vanishes from view before its folder has actually moved, which is also 01's lock-deferral posture. Writer primitives land value-passing: move to trash with caller-minted rank and the deliberate modified stamp, tombstone migrations that surgically remove the key, physical lane removal, per-card and whole-container purge that leaves strays verbatim, and byte-faithful whole-subtree capture/recreate for lane undo. Board-wide identity now spans the trash, so an import colliding with a trashed UUID remints instead of colliding. The watcher already delivered .trash events — isGitInternal tests a component, not a dot — now stated and pinned rather than relied on. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -372,6 +372,44 @@ struct FolderWatcherTests {
|
||||
#expect(log.events == [.treeChanged(.foreign)])
|
||||
}
|
||||
|
||||
// MARK: .trash coverage
|
||||
|
||||
/// **The trash is watched, unlike `.git`** (01-storage-format.md § Deletion, resettled
|
||||
/// 2026-07-28). It is the one hidden folder in a board whose contents are *rendered* — a
|
||||
/// materialized container of ordinary cards — so a foreign delete, restore or purge has to
|
||||
/// reload the board like any other move. The filter tests for a `.git` component specifically
|
||||
/// rather than for a dot prefix, and this is the test that keeps it that way.
|
||||
@Test("Changes inside .trash are delivered, unlike .git churn")
|
||||
func trashChangesAreDelivered() async throws {
|
||||
let fixture = try WatchFixture()
|
||||
defer { fixture.tearDown() }
|
||||
fixture.makeDirectory(".git")
|
||||
fixture.makeDirectory(".trash")
|
||||
let log = EventLog()
|
||||
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
||||
log.record($0)
|
||||
}
|
||||
#expect(watcher.start())
|
||||
defer { watcher.stop() }
|
||||
await drainStartupChurn(log)
|
||||
|
||||
// What another device's (or an agent's) delete looks like from here: a card folder
|
||||
// appearing inside the container.
|
||||
let card = "2b7c9d10-0000-4000-8000-000000000000"
|
||||
fixture.write(".trash/\(card)/index.md", "---\nschema: 1\norder: -1024\n---\n")
|
||||
await waitUntil { log.count >= 1 }
|
||||
await quiet()
|
||||
#expect(log.events == [.treeChanged(.foreign)])
|
||||
|
||||
// …and a purge of it is a change too, while `.git` in the same board stays filtered.
|
||||
log.reset()
|
||||
fixture.write(".git/index", "fake index")
|
||||
try? FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".trash/\(card)"))
|
||||
await waitUntil { log.count >= 1 }
|
||||
await quiet()
|
||||
#expect(log.events == [.treeChanged(.foreign)])
|
||||
}
|
||||
|
||||
// MARK: Root identity
|
||||
|
||||
@Test("Deleting the watched root delivers rootChanged and tears the stream down")
|
||||
|
||||
Reference in New Issue
Block a user