import Foundation /// File ▸ Duplicate (⇧⌘S) — the copy itself (03-board-ui.md § Welcome screen & templates). /// /// ### A literal tree copy, and every one of its exclusions is deliberate /// /// - **Every GUID is kept.** A whole-board copy is 01-storage-format.md's explicit carve-out from /// the copies-remint rule: "the remint rule governs *item-level* copies landing inside an existing /// board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's /// fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it /// describes)". /// - **Tombstoned items are carried too** (03, settled): "Duplicate is a full fork, trash included — /// dropping them would leave the copy's working tree disagreeing with its own copied HEAD". This /// file does nothing to achieve that: a tombstone is a `deleted:` key inside a file, so a copy /// carries it by declining to be clever. /// - **`.git` comes along** — a duplicate of a git board is a fork of its history — with only its /// remote configuration stripped, which is m7's. /// - Timestamps, unknown keys, strays, `CLAUDE.user.md`, attachments: verbatim, for the same reason. /// **Nothing here reads a board file at all.** /// /// Not `BoardWriter.copyItem`, whose copy path exists to remint identities and restamp frontmatter /// at an import boundary — this operation is defined by doing neither of those things. /// /// ### A per-item walk, because the copy is cancellable /// /// 03 settles the shape as well as the promise: "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 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 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 /// be (`BoardWriter.copyItem`'s all-or-nothing posture, and 02-architecture.md's "the action visibly /// doesn't happen"). Only the silence differs: `.cancelled` says nothing, `.failed` gets the banner. /// 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 /// window stays open with an in-progress banner row spinning (02-architecture.md § The banner /// surface names "big-board Duplicate" as an example). A spinner on a blocked main thread is a /// frozen picture, so the caller runs this off the main actor. That is safe by construction: it /// touches only its two URLs, and the board's security-scoped access is a process-wide grant the /// session holds open for the window's whole life, not a per-thread one. enum BoardDuplicator { // MARK: - Outcomes /// The three ways a duplicate ends without a copy — which are also, one for one, the three /// surfaces 03 gives them: silence, a save panel, a banner. /// /// An enum rather than a bare `BoardWriteError` because the command's branch has to be /// exhaustive: "cancelling the panel cancels the duplicate quietly (no banner — the user /// declined, nothing failed)" and "non-permission failures (disk full, …) keep the ordinary /// one-shot banner" are different outcomes of the same call, and a caller that forgot one of /// them should not compile. enum Failure: Error, Sendable, Equatable { /// The user pressed Cancel on the in-progress row. The partial sibling is already gone and /// there is nothing to report — "a cancelled duplicate never happened". case cancelled /// The sandbox refused the destination: "the board's security-scoped bookmark grants its /// subtree, not its parent, so the sibling destination may be unwritable" (03, settled). /// The caller's cue to open the save panel — the grant *is* the sandbox's own answer — and /// never a banner on its own. It still carries the write error, so a caller with no panel to /// offer (and the log) has the specifics. case refused(BoardWriteError) /// Anything else: a full disk, an unreadable source, a name already taken. The ordinary /// one-shot banner, in the vocabulary every other failed write in the app speaks. 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"`, /// `"Board copy 3"`, … — Finder's own ladder, counting up from 2 against what is on disk at /// decision time, one collision at a time. /// /// **A sibling**, per 03 ("a Finder-style 'copy' sibling"), so a board found in a folder full of /// boards produces its duplicate where the user is already looking. /// /// The extension rides on the end (`Board.kanban` → `Board copy.kanban`) and an extension-less /// board folder simply has none to carry (`Board` → `Board copy`) — both are shapes a board is /// allowed to be (01-storage-format.md § Document packaging, "Extension-less board folders still /// open"), and both are what `URL`'s own splitting produces, which is also how the Writer's /// attachment-collision helper spells the same idea. /// /// `fileExists` is the one test, and it is true for a file as much as a folder: anything already /// wearing the name blocks it, which is what keeps a duplicate from ever overwriting something. /// /// It doubles as the **save panel's suggested name** when the sibling is refused (03): a parent /// the sandbox will not let us write is usually one we cannot list either, so the ladder simply /// finds no collision and suggests `"Board copy"` — the right pre-fill, arrived at honestly. static func copyDestination(for rootURL: URL) -> URL { let parent = rootURL.deletingLastPathComponent() let base = rootURL.deletingPathExtension().lastPathComponent let ext = rootURL.pathExtension func candidate(_ name: String) -> URL { parent.appendingPathComponent(ext.isEmpty ? name : "\(name).\(ext)", isDirectory: true) } var name = "\(base) copy" var counter = 2 while FileManager.default.fileExists(atPath: candidate(name).path) { name = "\(base) copy \(counter)" counter += 1 } return candidate(name) } // MARK: - Duplicating /// Copies the board at `rootURL` to its Finder-style sibling and answers where it landed — the /// silent first attempt 03 asks for ("the silent Finder-style sibling is attempted first"). /// /// `title` is the board's display name, carried only so a failure can name the board the user /// pressed Duplicate on — this function never reads it off disk, which is the whole point. /// /// **This is the one entry point that can answer `.refused`**, because it is the one that chose /// the destination: a permission failure here is a question about *where*, and 03 hands that /// question to a save panel rather than to a banner. /// /// `isCancelled` is read between items and nowhere else. Its default is the ambient task's own /// cancellation, so the caller cancels a duplicate the way it cancels anything else — by /// cancelling the task doing it — and a test can trip it deterministically at item *N*. static func duplicate( boardAt rootURL: URL, titled title: String?, isCancelled: () -> Bool = { Task.isCancelled } ) throws(Failure) -> URL { try copyBoard(at: rootURL, titled: title, to: copyDestination(for: rootURL), isCancelled: isCancelled) } /// Copies the board at `rootURL` to `destination` — the panel's answer, honored verbatim. /// /// The user picked a name and a folder, so neither is second-guessed: no `copy` ladder, no /// extension repair, and nothing overwritten (an existing name fails rather than being replaced, /// `BoardWriter.createBoard`'s own refusal — the panel's replace prompt grants access, it does /// not delete anything). /// /// **A permission failure here is an ordinary failure, not a refusal.** The panel *was* the /// sandbox's answer; asking the same question twice would be a loop, so this one gets the banner. static func duplicate( boardAt rootURL: URL, titled title: String?, into destination: URL, isCancelled: () -> Bool = { Task.isCancelled } ) throws(Failure) -> URL { do { return try copyBoard(at: rootURL, titled: title, to: destination, isCancelled: isCancelled) } catch { if case let .refused(write) = error { throw .failed(write) } throw error } } /// The copy, once the destination is decided: root, walk, restore — with the partial removed on /// the way out of either exit that is not a copy. private static func copyBoard( at rootURL: URL, titled title: String?, to destination: URL, isCancelled: () -> Bool ) throws(Failure) -> URL { let operation = WriteOperation.duplicateBoard(title: title) /// Every failure in one shape, and the refusal/ordinary split decided in one place. func failure(at url: URL, _ error: any Error) -> Failure { let write = BoardWriteError( operation: operation, path: url.path, reason: .io(message: error.localizedDescription) ) return isPermissionRefusal(error) ? .refused(write) : .failed(write) } // The source's own attributes, read before anything is created — and the first thing that // fails when the board root isn't there at all, which is why that failure names the source. let rootAttributes: [FileAttributeKey: Any] do { rootAttributes = try FileManager.default.attributesOfItem(atPath: rootURL.path) } catch { throw failure(at: rootURL, error) } // A duplicate cancelled before it began is still a cancelled duplicate; answering here keeps // the empty destination from ever existing. if isCancelled() { throw .cancelled } // The root first, and **nothing is cleaned up if this fails**: a name already on disk is not // 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) } catch { throw failure(at: destination, error) } do { try copyContents(of: 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. try? FileManager.default.removeItem(at: destination) switch error { case .cancelled: throw .cancelled case let .failed(url, underlying): throw failure(at: url, underlying) } } 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 // fork keeps its commit identity (06-history-undo.md's identity home). Push-on-commit needs // nothing here — it lives on the registry record, and the copy's record is born fresh. 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 /// panel instead of a banner ("the board's security-scoped bookmark grants its subtree, not its /// parent"). /// /// Pure, and deliberately narrow on both axes: /// /// - **Write permission only.** `NSFileWriteNoPermissionError` is what `FileManager` reports for /// a create it is not allowed to make, and `EACCES`/`EPERM` is what that wraps when the /// underlying error survives. A *read* refusal is not in it: the source is inside the grant the /// window already holds, so a board we cannot read is a broken board, not a question about /// where the copy should go — re-pointing the destination would not help it. /// - **Permission only.** A full disk or a read-only volume is a genuine failure with a banner to /// its name; offering a save panel for it would be the app pretending the user chose wrong. /// /// **A Cocoa error answers for itself and its chain is not consulted**, which is the subtle half: /// Foundation hangs the POSIX `EACCES` under a *read* denial as readily as under a write one, so /// a walk that went looking for `EACCES` anywhere would call an unreadable source folder a /// question about the destination. Cocoa has already drawn the read/write line; this trusts it. /// The underlying chain is only followed out of domains that have not classified anything — /// where a bare POSIX `EACCES`/`EPERM` is all the refusal there is to find. static func isPermissionRefusal(_ error: any Error) -> Bool { let nsError = error as NSError if nsError.domain == NSCocoaErrorDomain { return nsError.code == NSFileWriteNoPermissionError } if nsError.domain == NSPOSIXErrorDomain { return nsError.code == Int(EACCES) || nsError.code == Int(EPERM) } guard let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError else { return false } return isPermissionRefusal(underlying) } }