Build the template engine — board-as-template instantiation
A template is a board folder the ordinary loader reads — no second schema, no Swift catalog. BoardTemplate became exactly that: a loaded BoardModel with chooser-facing derivations, the lane-title stub gone. TemplateEngine instantiates by the copy-remint-restamp walk: .git and .trash excluded at top level only — both names mean something at a board root and nowhere else, and .gitignore must survive — every materialized folder reminted, created/modified stamped fresh (born today, not forked), modified-by cleared, the template: key carried inert, the blurb and style inherited, and loose card files normalized at this import boundary per the paste precedent so a new board never opens with a notice about a mess its own birth made. Legacy deleted: keys copy through verbatim to the one migrator — stripping would resurrect, skipping would destroy. Atomicity is construct-then-clean: a sibling temp can be sandbox-refused and a cross-volume rename is just a second copy, so the call removes what it created on every non-board exit and never touches an occupied destination. The cancellable per-item walk extracted into BoardTreeCopy serves Duplicate and instantiation with two parameters — top-level exclusions and folder-attribute carriage, the only axes they differ on. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -28,12 +28,10 @@ import Foundation
|
||||
/// sibling before dismissing the banner (the attachment partial-cleanup precedent): a cancelled
|
||||
/// duplicate never happened."
|
||||
///
|
||||
/// So the walk *is* the promise: one `FileManager.copyItem` per file — bytes and their metadata
|
||||
/// copied by the file system, never re-encoded, symlinks copied as symlinks — folders recreated by
|
||||
/// hand, and a cancellation read **between** items, never mid-file (a copy interrupted inside a
|
||||
/// 200 MB pack file leaves rubble that is harder to reason about than one more file's wait). The
|
||||
/// entries of every folder are walked in name order, so the same cancellation removes the same
|
||||
/// partial tree every time and a failure names the same file twice running.
|
||||
/// The walk itself is `BoardTreeCopy`, shared with template instantiation (09-templates.md), which
|
||||
/// needs the same cancellable per-item copy of a whole board and differs only in what it excludes
|
||||
/// and whether folder attributes carry. Duplicate excludes **nothing** and carries **everything** —
|
||||
/// which is not a default taken but this flow's entire definition, stated in the call below.
|
||||
///
|
||||
/// **The partial goes on both exits.** Cancel promises removal; a failure gets it too, because a
|
||||
/// half-copied board is pure residue — nothing was there before, so there is no true state for it to
|
||||
@@ -42,11 +40,6 @@ import Foundation
|
||||
/// The one thing never removed is a destination this walk did not create — an existing name is the
|
||||
/// user's, and refusing to clobber it is the same stance `BoardWriter.createBoard` takes.
|
||||
///
|
||||
/// The accepted cost of building folders by hand rather than handing the tree to one `copyItem`:
|
||||
/// **extended attributes on folders do not carry** (files keep theirs — each is still copied by
|
||||
/// `copyItem` — and POSIX permissions and timestamps are carried explicitly below). Nothing the app,
|
||||
/// the storage format, or git keeps lives in a folder xattr.
|
||||
///
|
||||
/// ### Not `@MainActor`
|
||||
///
|
||||
/// Duplicating a board with a year of `.git` behind it is real I/O, and it runs while the original's
|
||||
@@ -85,13 +78,6 @@ enum BoardDuplicator {
|
||||
case failed(BoardWriteError)
|
||||
}
|
||||
|
||||
/// Why a walk stopped and on which item — the recursion's private currency, converted to a
|
||||
/// `Failure` (and the partial removed) the moment it surfaces.
|
||||
private enum WalkStop: Error {
|
||||
case cancelled
|
||||
case failed(url: URL, error: any Error)
|
||||
}
|
||||
|
||||
// MARK: - Where the copy lands
|
||||
|
||||
/// The Finder-style destination for duplicating `rootURL`: `"Board copy"`, then `"Board copy 2"`,
|
||||
@@ -214,13 +200,16 @@ enum BoardDuplicator {
|
||||
// ours to remove, and this is also where the sandbox says "not here" — the refusal the save
|
||||
// panel answers, raised before a single byte has been copied.
|
||||
do {
|
||||
try createDirectory(at: destination)
|
||||
try BoardTreeCopy.createDirectory(at: destination)
|
||||
} catch {
|
||||
throw failure(at: destination, error)
|
||||
}
|
||||
|
||||
do {
|
||||
try copyContents(of: rootURL, into: destination, isCancelled: isCancelled)
|
||||
// No exclusions and folder attributes carried: a duplicate is a full fork — `.git`,
|
||||
// `.trash/`, strays, modes and dates included (03; 01-storage-format.md § Fractal layout
|
||||
// ▸ Rules, "Whole-board copies are the carve-out").
|
||||
try BoardTreeCopy.copy(contentsOf: rootURL, into: destination, isCancelled: isCancelled)
|
||||
} catch {
|
||||
// Cancelled or failed, the partial sibling goes: the tree exists only because this call
|
||||
// made it, and half a board is not a state anything should have to render.
|
||||
@@ -233,7 +222,7 @@ enum BoardDuplicator {
|
||||
}
|
||||
}
|
||||
|
||||
restoreAttributes(from: rootAttributes, onto: destination)
|
||||
BoardTreeCopy.restoreAttributes(from: rootAttributes, onto: destination)
|
||||
// m7-git: strip the copy's remote configuration — "the duplicate keeps `.git` but has its
|
||||
// remote configuration stripped ... it must not silently push into the original's remote"
|
||||
// (03-board-ui.md). Remotes only: the repo-local `user.name`/`user.email` survives, so the
|
||||
@@ -242,107 +231,6 @@ enum BoardDuplicator {
|
||||
return destination
|
||||
}
|
||||
|
||||
// MARK: - The walk
|
||||
|
||||
/// Copies everything inside `source` into the already-created `destination`, one item at a time.
|
||||
///
|
||||
/// Name order, hidden entries included (no `.skipsHiddenFiles`): `.git`, `.DS_Store` and every
|
||||
/// other dotfile are part of the fork, and a deterministic order is what makes a cancellation
|
||||
/// reproducible.
|
||||
///
|
||||
/// Each entry's type comes from `attributesOfItem`, which does **not** traverse symlinks — so a
|
||||
/// link is copied as a link (by `copyItem`, which does not follow it either) rather than being
|
||||
/// mistaken for the folder it points at and walked into.
|
||||
private static func copyContents(
|
||||
of source: URL,
|
||||
into destination: URL,
|
||||
isCancelled: () -> Bool
|
||||
) throws(WalkStop) {
|
||||
let entries: [URL]
|
||||
do {
|
||||
entries = try FileManager.default.contentsOfDirectory(
|
||||
at: source,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: []
|
||||
)
|
||||
} catch {
|
||||
throw .failed(url: source, error: error)
|
||||
}
|
||||
|
||||
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||||
// Between items, never mid-item: this is the whole of "checks cancellation between
|
||||
// items", and the reason the copy is a walk at all.
|
||||
if isCancelled() { throw .cancelled }
|
||||
|
||||
let attributes: [FileAttributeKey: Any]
|
||||
do {
|
||||
attributes = try FileManager.default.attributesOfItem(atPath: entry.path)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
|
||||
let isDirectory = attributes[.type] as? FileAttributeType == .typeDirectory
|
||||
let target = destination.appendingPathComponent(entry.lastPathComponent, isDirectory: isDirectory)
|
||||
|
||||
guard isDirectory else {
|
||||
// Files, symlinks, and whatever else the file system holds: `copyItem` lands the
|
||||
// bytes and the metadata that rides with them, byte-for-byte, unread.
|
||||
do {
|
||||
try FileManager.default.copyItem(at: entry, to: target)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
try createDirectory(at: target)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
try copyContents(of: entry, into: target, isCancelled: isCancelled)
|
||||
restoreAttributes(from: attributes, onto: target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates `url` as a plain directory, wearing the process's own default permissions until its
|
||||
/// contents have landed (`restoreAttributes(from:onto:)` puts the source's back afterwards).
|
||||
///
|
||||
/// **Default permissions first, the source's last**, because a folder is not only a thing being
|
||||
/// copied but the thing being copied *into*: a source folder that is read-only, or unreadable,
|
||||
/// would otherwise be reproduced as a destination this walk cannot write its own children into —
|
||||
/// and, when something later fails, as a partial tree the cleanup cannot remove either. `cp -R`
|
||||
/// defers the mode for the same reason.
|
||||
///
|
||||
/// `withIntermediateDirectories: false` throughout: every parent either exists already (the walk
|
||||
/// just made it) or is the one the user pointed at, and inventing a missing folder would be this
|
||||
/// function deciding where a board lives.
|
||||
private static func createDirectory(at url: URL) throws {
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||
}
|
||||
|
||||
/// Puts a folder's POSIX permissions and its creation and modification dates back, **after** its
|
||||
/// contents have landed — writing into a folder is itself a modification, and its mode may be
|
||||
/// what stops the writing (above), so both wait for the subtree to be finished.
|
||||
///
|
||||
/// Best effort by design: a volume that will not take a date back (or a file system with no
|
||||
/// creation dates at all) is not a reason to fail a duplicate that otherwise worked, and neither
|
||||
/// a folder's timestamp nor its mode is something the storage format reads.
|
||||
private static func restoreAttributes(from attributes: [FileAttributeKey: Any], onto url: URL) {
|
||||
var carried: [FileAttributeKey: Any] = [:]
|
||||
if let permissions = attributes[.posixPermissions] {
|
||||
carried[.posixPermissions] = permissions
|
||||
}
|
||||
if let created = attributes[.creationDate] {
|
||||
carried[.creationDate] = created
|
||||
}
|
||||
if let modified = attributes[.modificationDate] {
|
||||
carried[.modificationDate] = modified
|
||||
}
|
||||
guard !carried.isEmpty else { return }
|
||||
try? FileManager.default.setAttributes(carried, ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
// MARK: - Classifying a refusal
|
||||
|
||||
/// Whether `error` is the sandbox saying **not here** — the one failure 03 answers with a save
|
||||
|
||||
Reference in New Issue
Block a user