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.** /// /// `FileManager.copyItem` rather than a walk through `BoardWriter.copyItem`: the Writer's copy path /// exists to remint identities and restamp frontmatter at an import boundary, and this operation is /// defined by doing neither of those things. /// /// ### 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 { /// 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. 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) } /// Copies the board at `rootURL` to its Finder-style sibling and answers where it landed. /// /// `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. /// /// The failure is a `BoardWriteError` like every other write in the app, so the board window's /// banner renders it in the vocabulary it already speaks. A copy that fails part-way leaves a /// partial folder behind; `FileManager` cleans up its own destination on most failures, and the /// residue that survives is a folder the user can see and delete — the honest outcome, and the /// one 02's "remove the partial copy" Cancel affordance would formalise when it lands. static func duplicate(boardAt rootURL: URL, titled title: String?) throws(BoardWriteError) -> URL { let destination = copyDestination(for: rootURL) do { try FileManager.default.copyItem(at: rootURL, to: destination) } catch { throw BoardWriteError( operation: .duplicateBoard(title: title), path: destination.path, reason: .io(message: error.localizedDescription) ) } // 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 } }