import Foundation /// The **board-scale tree copy**: a per-item file walk that checks cancellation between items, /// shared by File ▸ Duplicate (`BoardDuplicator`) and template instantiation (`TemplateEngine`). /// /// ### Why the walk is a walk /// /// 03-board-ui.md settles the shape for Duplicate and the reason generalizes to every copy of a /// whole board: "the copy runs as a per-item file walk that checks cancellation between items — /// **never one monolithic `copyItem`** — and Cancel removes the partial sibling before dismissing /// the banner (the attachment partial-cleanup precedent)". A monolithic `copyItem` is uncancellable /// and indivisible; a walk is both, and it is also the only shape that can *exclude* something /// (09-templates.md's `.git` and `.trash/`). /// /// 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** (01-storage-format.md /// § Fractal layout ▸ Rules: "Copy flows copy the link itself, never its target: Duplicate, Save as /// Template, instantiation, cross-board copies … preserve the link verbatim") — 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 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`). Nothing the app, the storage format, or git keeps lives in a folder xattr. /// /// ### What it does not do /// /// It never creates the destination root, never removes a partial, and never classifies a failure. /// Those are the caller's, because they are exactly where the two flows differ: Duplicate answers a /// permission failure with a save panel, instantiation with a banner, and each owns its own cleanup /// promise. This type only knows how to move a tree, one item at a time, and where to stop. /// /// ### Not `@MainActor` /// /// Copying a board with a year of `.git` behind it is real I/O and runs off the main actor so an /// in-progress banner can actually spin (02-architecture.md § The banner surface). Safe by /// construction: it touches only the two URLs it is handed, and a board's security-scoped access is /// a process-wide grant, not a per-thread one. enum BoardTreeCopy { /// Why a walk stopped and on which item — the recursion's currency, which each caller converts /// into its own failure vocabulary the moment it surfaces. enum Stop: Error { case cancelled case failed(url: URL, error: any Error) } /// Copies everything inside `source` into the already-created `destination`, one item at a time. /// /// Name order, hidden entries included (no `.skipsHiddenFiles`): `.DS_Store`, `.gitignore`, /// `CLAUDE.user.md` and every other dotfile or stray is part of the copy — "the copy is literal /// apart from the stated exclusions" (09-templates.md ▸ Save as Template) — and a deterministic /// order is what makes a cancellation reproducible. /// /// `excludedTopLevelNames` is compared **lowercased against the top level only**, which is the /// exclusions' actual scope rather than a shortcut: `.git` and `.trash/` mean something at a /// board root and nowhere else (06-history-undo.md's nearest-`.git`-wins detection starts at the /// board root; `BoardLoader.trashCandidates` looks in exactly one place), so a `.git` a template /// author left inside a card folder is an ordinary stray and copies verbatim like any other. /// /// `carriesFolderAttributes` decides whether a recreated folder gets the source folder's POSIX /// permissions and timestamps back once its contents have landed. Duplicate says yes — it is a /// fork, down to the mode bits. Instantiation says no: a template folder is content the app /// ships or the user dropped, and carrying a read-only mode (or a two-year-old date) out of it /// would mint a board that is read-only, or born older than itself, from a copy whose whole /// premise is "born today" (09-templates.md ▸ Instantiation). /// /// 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. static func copy( contentsOf source: URL, into destination: URL, excludingTopLevel excludedTopLevelNames: Set = [], carryingFolderAttributes carriesFolderAttributes: Bool = true, isCancelled: () -> Bool ) throws(Stop) { try copyContents( of: source, into: destination, excluding: excludedTopLevelNames, carryingFolderAttributes: carriesFolderAttributes, isCancelled: isCancelled ) } /// The recursion. `excluded` is emptied one level down, which is what makes the exclusions /// top-level-only without the walk having to count its own depth. private static func copyContents( of source: URL, into destination: URL, excluding excluded: Set, carryingFolderAttributes carriesFolderAttributes: Bool, isCancelled: () -> Bool ) throws(Stop) { 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 }) { guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue } // 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, excluding: [], carryingFolderAttributes: carriesFolderAttributes, isCancelled: isCancelled ) if carriesFolderAttributes { 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, /// where the caller asked for them). /// /// **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. 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 copy that otherwise worked, and neither a /// folder's timestamp nor its mode is something the storage format reads. 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) } }