Implement tombstone delete, restore, and purge
deleteItem writes deleted: <now> in place through updateIndex — the folder never moves, nothing beneath it is touched (hiding the subtree is the renderer's ancestor walk, not a stored flag). restoreItem removes the key — position-perfect by construction, every duplicate occurrence taken so a hand-written twin cannot resurrect the tombstone. purgeItem physically removes the tree; an already-missing folder is success (a Finder deletion converges on the same end state), and no prior tombstone is required (Delete Immediately skips the stage by design). Board-root deletion is structurally unreachable via the UUID-shape guard shared with move/copy. Neither delete nor restore polices liveness — re-deleting refreshes the timestamp, restoring a live item is a harmless stamped rewrite. 14 new unit tests; 234 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -732,6 +732,88 @@ public enum BoardWriter: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tombstone
|
||||||
|
|
||||||
|
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
||||||
|
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
|
||||||
|
/// renames, and nothing beneath it is touched: hiding the subtree is the renderer's
|
||||||
|
/// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own
|
||||||
|
/// file — its cards' files are exactly as they were.
|
||||||
|
///
|
||||||
|
/// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped`
|
||||||
|
/// — the same guard `moveItem`/`copyItem` lean on — refuses any folder whose name isn't
|
||||||
|
/// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key
|
||||||
|
/// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on
|
||||||
|
/// it), but this call is simply never able to *produce* one: it has no board-root code
|
||||||
|
/// path to fall through, only a refusal.
|
||||||
|
///
|
||||||
|
/// Deleting an **already-tombstoned** item is not refused — it just refreshes the
|
||||||
|
/// timestamp, a harmless rewrite (the gesture happened again; this layer does not police
|
||||||
|
/// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract
|
||||||
|
/// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
|
||||||
|
/// cleared, atomic replace.
|
||||||
|
public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||||
|
let operation = "delete item"
|
||||||
|
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
|
||||||
|
try checkIsUUIDShaped(itemFolder, operation: operation)
|
||||||
|
|
||||||
|
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
|
||||||
|
document.set(FrontmatterKeys.deleted, to: .date(Date()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote.
|
||||||
|
/// **Position-perfect by construction** — the folder never moved, so the item simply
|
||||||
|
/// re-enters the visible set at its recorded `order` among its current siblings
|
||||||
|
/// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every*
|
||||||
|
/// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the
|
||||||
|
/// tombstone the instant the winning occurrence is gone.
|
||||||
|
///
|
||||||
|
/// Restoring an item that **isn't** tombstoned is not refused — it is a harmless stamped
|
||||||
|
/// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not
|
||||||
|
/// police liveness (a second, independent liveness check here could only drift from the
|
||||||
|
/// store UI's own, which is what actually decides whether Put Back is offered at all).
|
||||||
|
public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||||
|
let operation = "restore item"
|
||||||
|
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
|
||||||
|
try checkIsUUIDShaped(itemFolder, operation: operation)
|
||||||
|
|
||||||
|
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
|
||||||
|
document.remove(FrontmatterKeys.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical removal — Delete Immediately / Empty Trash (03-board-ui.md): deletes the
|
||||||
|
/// folder tree from disk. Irreversible, and distinct from tombstoning — this call does
|
||||||
|
/// **not** require the item to be tombstoned first, since Delete Immediately skips the
|
||||||
|
/// tombstone stage by design.
|
||||||
|
///
|
||||||
|
/// **A folder that is already gone is success, not an error** — checked first, before the
|
||||||
|
/// shape guard below. A Finder deletion converges on exactly the end state a purge would
|
||||||
|
/// produce (01-storage-format.md § Deletion, "a folder that disappears without a
|
||||||
|
/// tombstone... is also a delete"), so there is nothing left here to distinguish: a stray
|
||||||
|
/// path that never existed and a once-real item someone already threw away in Finder both
|
||||||
|
/// purge cleanly, silently, without inspecting what used to be there.
|
||||||
|
///
|
||||||
|
/// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability
|
||||||
|
/// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this
|
||||||
|
/// call, only a lane or a card.
|
||||||
|
public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||||
|
let operation = "purge item"
|
||||||
|
guard FileManager.default.fileExists(atPath: itemFolder.path) else { return }
|
||||||
|
|
||||||
|
try checkIsUUIDShaped(itemFolder, operation: operation)
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: itemFolder)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: itemFolder.path,
|
||||||
|
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Move/copy pre-flight
|
// MARK: - Move/copy pre-flight
|
||||||
|
|
||||||
/// The rank a moved or copied root lands on: the caller's explicit value — a drop between
|
/// The rank a moved or copied root lands on: the caller's explicit value — a drop between
|
||||||
@@ -754,14 +836,17 @@ public enum BoardWriter: Sendable {
|
|||||||
|
|
||||||
/// Refuses a folder that is not a lane or a card. Level detection is by name shape
|
/// Refuses a folder that is not a lane or a card. Level detection is by name shape
|
||||||
/// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, an uppercase
|
/// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, an uppercase
|
||||||
/// UUID, a hand-made folder — is not an item, and moving or copying one as if it were would
|
/// UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or
|
||||||
/// invent an identity the loader would then ignore.
|
/// purging one as if it were would invent (or destroy) an identity the loader would
|
||||||
|
/// otherwise just ignore. Shared by every operation that must never reach a board root: a
|
||||||
|
/// board root's folder name is never UUID-shaped (§ Board naming), so this one check is
|
||||||
|
/// what makes board-root deletion/restore/purge structurally unreachable at this layer.
|
||||||
private static func checkIsUUIDShaped(_ folder: URL, operation: String) throws(BoardWriteError) {
|
private static func checkIsUUIDShaped(_ folder: URL, operation: String) throws(BoardWriteError) {
|
||||||
guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else {
|
guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: operation,
|
operation: operation,
|
||||||
path: folder.path,
|
path: folder.path,
|
||||||
reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards move and copy")
|
reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards are valid here")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1598,6 +1598,262 @@ struct BoardWriterCopyTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete / Restore
|
||||||
|
|
||||||
|
/// `BoardWriter.deleteItem`/`restoreItem` — the tombstone half of 01-storage-format.md §
|
||||||
|
/// Deletion: `deleted: <now>` written into the item's own `index.md` in place, and Put Back
|
||||||
|
/// (`remove(deleted)`) undoing exactly that. The folder never moves; hiding a tombstoned
|
||||||
|
/// subtree is the renderer's ancestor walk, not anything either call does — a deleted lane's
|
||||||
|
/// cards are never touched.
|
||||||
|
struct BoardWriterDeleteRestoreTests {
|
||||||
|
@Test func deletingALaneWritesATombstoneInPlaceAndPreservesEverythingElse() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("A.kanban", Item.board)
|
||||||
|
let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
||||||
|
let before = try fixture.indexText("A.kanban/\(Ident.lane1)")
|
||||||
|
|
||||||
|
try BoardWriter.deleteItem(at: laneFolder)
|
||||||
|
|
||||||
|
// Same path, same name — a tombstone never moves or renames the folder.
|
||||||
|
#expect(fixture.exists("A.kanban/\(Ident.lane1)"))
|
||||||
|
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
|
||||||
|
|
||||||
|
let after = try fixture.indexText("A.kanban/\(Ident.lane1)")
|
||||||
|
let stamped = [FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.deleted]
|
||||||
|
#expect(lines(of: after, excludingKeys: stamped) == lines(of: before, excludingKeys: stamped))
|
||||||
|
|
||||||
|
let document = try FrontmatterDocument.parse(after)
|
||||||
|
#expect(document.unknownFields.map(\.key) == ["project", "labels"])
|
||||||
|
#expect(document.body.hasSuffix("body — with *markdown*.\n"))
|
||||||
|
#expect(document.modifiedBy == .missing)
|
||||||
|
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
|
||||||
|
let deleted = try #require(document.deleted.value)
|
||||||
|
#expect(abs(deleted.timeIntervalSinceNow) < 60)
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
|
||||||
|
#expect(result.model.lanes.first?.isDeleted == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hiding beneath is the renderer's walk, not a stored flag: deleting a lane writes only
|
||||||
|
/// the lane's own file.
|
||||||
|
@Test func deletingALaneLeavesItsNestedCardUntouched() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("A.kanban", Item.board)
|
||||||
|
let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One"))
|
||||||
|
let cardBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
|
try BoardWriter.deleteItem(at: laneFolder)
|
||||||
|
|
||||||
|
#expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == cardBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loaderRoundTripDeletingACardShowsItDeletedStillInTheSnapshotAtItsRecordedOrder() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("A.kanban", Item.board)
|
||||||
|
try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
||||||
|
let cardFolder = try fixture.item(
|
||||||
|
"A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")
|
||||||
|
)
|
||||||
|
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
|
||||||
|
|
||||||
|
try BoardWriter.deleteItem(at: cardFolder)
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
|
||||||
|
#expect(result.warnings.isEmpty)
|
||||||
|
let cards = try #require(result.model.lanes.first?.cards)
|
||||||
|
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
|
||||||
|
#expect(cards[0].isDeleted == true)
|
||||||
|
#expect(cards[0].order == 1024)
|
||||||
|
#expect(cards[1].isDeleted == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After restore the file carries no residue of `deleted` at all, and the item reappears
|
||||||
|
/// among its current siblings at the `order` it had all along.
|
||||||
|
@Test func deleteThenRestoreLeavesNoResidueAndReappearsAtItsRecordedOrder() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("A.kanban", Item.board)
|
||||||
|
try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
||||||
|
let cardFolder = try fixture.item(
|
||||||
|
"A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1536", title: "Card One")
|
||||||
|
)
|
||||||
|
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
|
||||||
|
|
||||||
|
try BoardWriter.deleteItem(at: cardFolder)
|
||||||
|
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
.deleted.value != nil)
|
||||||
|
|
||||||
|
try BoardWriter.restoreItem(at: cardFolder)
|
||||||
|
|
||||||
|
let after = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
#expect(!after.contains("deleted"))
|
||||||
|
let document = try FrontmatterDocument.parse(after)
|
||||||
|
#expect(document.deleted == .missing)
|
||||||
|
#expect(document.order == .valid(1536))
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
|
||||||
|
let cards = try #require(result.model.lanes.first?.cards)
|
||||||
|
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
|
||||||
|
#expect(cards[0].isDeleted == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `remove` takes every occurrence, so a hand-duplicated `deleted` line cannot resurrect
|
||||||
|
/// the tombstone the instant the winning one is gone.
|
||||||
|
@Test func restoreRemovesAHandDuplicatedDeletedKeyEntirely() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let text = """
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
title: Twice Gone
|
||||||
|
deleted: 2026-01-01T00:00:00Z
|
||||||
|
deleted: 2026-06-01T00:00:00Z
|
||||||
|
---
|
||||||
|
body
|
||||||
|
|
||||||
|
"""
|
||||||
|
let folder = try fixture.item(Ident.lane1, text)
|
||||||
|
|
||||||
|
try BoardWriter.restoreItem(at: folder)
|
||||||
|
|
||||||
|
let after = try fixture.indexText(Ident.lane1)
|
||||||
|
#expect(after.components(separatedBy: "\n").filter { $0.hasPrefix("deleted:") }.isEmpty)
|
||||||
|
#expect(try FrontmatterDocument.parse(after).deleted == .missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tombstones are inert to ordering: a sibling deleted via `deleteItem` must not factor into
|
||||||
|
/// a subsequent create's append target.
|
||||||
|
@Test func aTombstonedSiblingIsExcludedFromTheAppendRankAfterDeleteItem() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
|
||||||
|
let highOrder = try fixture.item(Child.b, "---\nschema: 1\norder: 9999\ntitle: B\n---\nbody\n")
|
||||||
|
|
||||||
|
try BoardWriter.deleteItem(at: highOrder)
|
||||||
|
let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
|
||||||
|
|
||||||
|
let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue))
|
||||||
|
#expect(document.order == .valid(2048))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renumberLeavesATombstonedSiblingByteIdentical() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("lane/\(Child.a)", "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
|
||||||
|
let toDelete = try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: 3000\ntitle: B\n---\nbody\n")
|
||||||
|
|
||||||
|
try BoardWriter.deleteItem(at: toDelete)
|
||||||
|
let tombstoneAfterDelete = try fixture.indexData("lane/\(Child.b)")
|
||||||
|
|
||||||
|
try BoardWriter.renumberVisibleChildren(of: fixture.url("lane"))
|
||||||
|
|
||||||
|
#expect(try fixture.indexData("lane/\(Child.b)") == tombstoneAfterDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Board-root deletion is structurally unreachable at the writer level: a board root's
|
||||||
|
/// folder name is never UUID-shaped.
|
||||||
|
@Test func deleteRefusesANonUUIDShapedFolder() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let root = try fixture.item("A.kanban", Item.board)
|
||||||
|
|
||||||
|
let error = writeFailure { try BoardWriter.deleteItem(at: root) }
|
||||||
|
guard case let .unreadable(message) = error?.reason else {
|
||||||
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("UUID-shaped"))
|
||||||
|
#expect(try fixture.indexText("A.kanban") == Item.board)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func restoreRefusesANonUUIDShapedFolder() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let root = try fixture.item("A.kanban", Item.board)
|
||||||
|
|
||||||
|
let error = writeFailure { try BoardWriter.restoreItem(at: root) }
|
||||||
|
guard case let .unreadable(message) = error?.reason else {
|
||||||
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("UUID-shaped"))
|
||||||
|
#expect(try fixture.indexText("A.kanban") == Item.board)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comes free via `updateIndex`'s pre-flight: a readable-but-uneditable shape refuses every
|
||||||
|
/// app-mediated write, delete included.
|
||||||
|
@Test func deleteOnAnUneditableItemRefuses() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let folder = try fixture.item(Ident.lane1, Fixture.flowMapping)
|
||||||
|
|
||||||
|
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
|
||||||
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||||
|
#expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Purge
|
||||||
|
|
||||||
|
/// `BoardWriter.purgeItem`: physical removal — Delete Immediately / Empty Trash
|
||||||
|
/// (01-storage-format.md § Deletion) — irreversible, and distinct from tombstoning. Does not
|
||||||
|
/// require the item to be tombstoned first: Delete Immediately skips that stage by design.
|
||||||
|
struct BoardWriterPurgeTests {
|
||||||
|
@Test func purgeRemovesTheFolderTreeIncludingNestedContentFromDisk() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("A.kanban", Item.board)
|
||||||
|
let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One"))
|
||||||
|
|
||||||
|
try BoardWriter.purgeItem(at: laneFolder)
|
||||||
|
|
||||||
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)"))
|
||||||
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder that is already gone (a Finder deletion) is success, not an error — the same
|
||||||
|
/// end state a purge would have produced.
|
||||||
|
@Test func purgeOfAnAlreadyMissingFolderSucceedsSilently() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let gone = fixture.url("A.kanban/\(Ident.lane1)")
|
||||||
|
#expect(!fixture.exists("A.kanban/\(Ident.lane1)"))
|
||||||
|
|
||||||
|
try BoardWriter.purgeItem(at: gone)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func purgeRefusesANonUUIDShapedFolder() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let root = try fixture.item("A.kanban", Item.board)
|
||||||
|
|
||||||
|
let error = writeFailure { try BoardWriter.purgeItem(at: root) }
|
||||||
|
guard case let .unreadable(message) = error?.reason else {
|
||||||
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.contains("UUID-shaped"))
|
||||||
|
#expect(fixture.exists("A.kanban"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func purgeDoesNotRequirePriorTombstone() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let folder = try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Live"))
|
||||||
|
#expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane1)).deleted == .missing)
|
||||||
|
|
||||||
|
try BoardWriter.purgeItem(at: folder)
|
||||||
|
|
||||||
|
#expect(!fixture.exists(Ident.lane1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Same-parent move degrades to a reorder
|
// MARK: - Same-parent move degrades to a reorder
|
||||||
|
|
||||||
/// A move whose destination is the item's current parent is the same gesture as any other drop
|
/// A move whose destination is the item's current parent is the same gesture as any other drop
|
||||||
|
|||||||
Reference in New Issue
Block a user