Relocate loose card files into attachments
01's Lanework-owns-the-board carve-out: a regular file beside a card's index.md belongs in attachments/, and the app moves it there. The loader detects read-only — a new LoadResult.looseCardFiles channel, separate from the stray-tolerance warnings because it says the opposite thing — skipping directories, symlinks, hidden entries, and the reserved names compared case-insensitively (on APFS, Index.md IS the index). The relocation rides one performWrite bracket at the tail of every successful reload, which makes lock deferral free: the reload that lifts a read-only lock is the reload that relocates. A lane/card/filename memo keeps a failing relocation from hot-looping — one one-shot, then silence until disk changes. The notice rides the loss-row class, phrasing folded by BannerCenter (one file, one card's files, a multi-card sweep), naming original filenames per the importAttachment rule. Paste normalizes at the import boundary: staged snapshots' loose files land in the pasted card's attachments silently, every arrival path declaring its side via an explicit normalizingLooseFiles parameter — drag paths decline and fall back to the destination's own carve-out. checkIsCardFolder closes the hole where a lane's notes.txt would have been relocated: card depth is exact, UUID under UUID. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -223,6 +223,39 @@ struct FixtureStrayFilesTests {
|
||||
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
|
||||
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
|
||||
}
|
||||
|
||||
/// The board's card-level `scratch.md` is the **one** stray this fixture holds that is not
|
||||
/// tolerated: the loose-file carve-out (01-storage-format.md § Fractal layout ▸ Rules, settled
|
||||
/// 2026-07-28) says a regular file beside a card's `index.md` belongs in `attachments/`. It is
|
||||
/// reported on its own channel — never as a `warning`, which is the *tolerance* vocabulary —
|
||||
/// and the board-level and lane-level strays around it stay exactly as tolerated as they were.
|
||||
///
|
||||
/// **Detection does not mutate**: this is the loader, over a fixture that lives in git, and the
|
||||
/// assertion that the file is still there afterwards is the read-only claim stated on the one
|
||||
/// tree where a stray write would be visible in `git status`.
|
||||
@Test func aCardLevelLooseFileIsReportedForRelocationWithoutBeingTouched() throws {
|
||||
let lane = "10000000-0000-4000-8000-000000000001"
|
||||
let card = "20000000-0000-4000-8000-000000000002"
|
||||
|
||||
let result = try loadFixture("Valid/stray-files.kanban")
|
||||
#expect(result.warnings.isEmpty)
|
||||
#expect(result.looseCardFiles == [
|
||||
LooseCardFiles(
|
||||
laneID: ItemID(rawValue: lane),
|
||||
cardID: ItemID(rawValue: card),
|
||||
title: result.model.lanes[0].cards[0].title.value,
|
||||
fileNames: ["scratch.md"]
|
||||
),
|
||||
])
|
||||
|
||||
let scratch = fixtureBoard("Valid/stray-files.kanban")
|
||||
.appendingPathComponent("\(lane)/\(card)/scratch.md")
|
||||
#expect(FileManager.default.fileExists(atPath: scratch.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: fixtureBoard("Valid/stray-files.kanban")
|
||||
.appendingPathComponent("\(lane)/\(card)/attachments").path
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Valid/tombstones.kanban
|
||||
|
||||
Reference in New Issue
Block a user