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:
2026-07-28 15:55:40 -04:00
parent 96c4014fef
commit 4cf5f09d93
8 changed files with 1746 additions and 30 deletions
+38
View File
@@ -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")