diff --git a/README.md b/README.md index 7f6f70b..900793d 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,20 @@ xpt delete feature/old-branch --- +### `xpt rename` + +Renames a saved breakpoint snapshot to match a renamed branch. + +Use this after running `git branch -m ` to keep your saved breakpoints reachable under the new branch name. + +```sh +xpt rename feature/old-name feature/new-name +``` + +If no snapshot exists for ``, xpt exits with an error. If a snapshot already exists for ``, xpt exits with an error — delete it first with `xpt delete ` if you want to overwrite. + +--- + ### `xpt config` Displays or sets per-repo configuration. diff --git a/Sources/xpt/Xpt.swift b/Sources/xpt/Xpt.swift index ed8e825..8a0e0f1 100644 --- a/Sources/xpt/Xpt.swift +++ b/Sources/xpt/Xpt.swift @@ -13,6 +13,7 @@ struct Xpt: ParsableCommand { Restore.self, List.self, Delete.self, + Rename.self, Config.self, Hook.self, ] diff --git a/Sources/xptCore/Commands/Rename.swift b/Sources/xptCore/Commands/Rename.swift new file mode 100644 index 0000000..a5278fc --- /dev/null +++ b/Sources/xptCore/Commands/Rename.swift @@ -0,0 +1,22 @@ +import ArgumentParser + +public struct Rename: ParsableCommand { + public static let configuration = CommandConfiguration( + abstract: "Rename a saved breakpoint snapshot to match a renamed branch." + ) + + @Argument(help: "The old branch name (snapshot to rename).") + public var oldBranch: String + + @Argument(help: "The new branch name (what to rename the snapshot to).") + public var newBranch: String + + public init() {} + + public mutating func run() throws { + let repoRoot = try GitUtilities.repoRoot() + let storage = try StorageManager(repoRoot: repoRoot) + try storage.rename(from: oldBranch, to: newBranch) + print("xpt: Renamed '\(oldBranch)' → '\(newBranch)'.") + } +} diff --git a/Sources/xptCore/StorageManager.swift b/Sources/xptCore/StorageManager.swift index 40470fb..a1e6ec7 100644 --- a/Sources/xptCore/StorageManager.swift +++ b/Sources/xptCore/StorageManager.swift @@ -1,11 +1,12 @@ import Foundation import CryptoKit -public enum StorageError: Error, CustomStringConvertible { +public enum StorageError: Error, CustomStringConvertible, Equatable { case noSnapshotFound(String) case symlinkDetected(String) case invalidBranchName(String) case invalidSnapshot(String) + case snapshotAlreadyExists(String) public var description: String { switch self { @@ -17,6 +18,8 @@ public enum StorageError: Error, CustomStringConvertible { return "Invalid branch name '\(branch)': must not be empty or contain null bytes or newlines." case .invalidSnapshot(let path): return "Snapshot at '\(path)' is not a valid plist file and will not be restored. Delete it with 'xpt delete' and re-save if needed." + case .snapshotAlreadyExists(let branch): + return "A snapshot already exists for branch '\(branch)'. Delete it first with 'xpt delete \(branch)'." } } } @@ -33,6 +36,11 @@ public struct StorageManager { .appendingPathComponent(identifier) } + /// Internal initializer for testing — injects a custom storage directory. + init(repoDirectory: URL) { + self.repoDirectory = repoDirectory + } + // MARK: - Repo identifier static func repoIdentifier(repoRoot: URL) -> String { @@ -102,6 +110,26 @@ public struct StorageManager { try FileManager.default.copyItem(at: source, to: destinationURL) } + public func rename(from oldBranch: String, to newBranch: String) throws { + try Self.validateBranchName(oldBranch) + try Self.validateBranchName(newBranch) + let oldURL = snapshotURL(for: oldBranch) + let newURL = snapshotURL(for: newBranch) + guard FileManager.default.fileExists(atPath: oldURL.path) else { + throw StorageError.noSnapshotFound(oldBranch) + } + if Self.isSymlink(at: oldURL) { + throw StorageError.symlinkDetected(oldURL.path) + } + if Self.isSymlink(at: newURL) { + throw StorageError.symlinkDetected(newURL.path) + } + if FileManager.default.fileExists(atPath: newURL.path) { + throw StorageError.snapshotAlreadyExists(newBranch) + } + try FileManager.default.moveItem(at: oldURL, to: newURL) + } + public func delete(branch: String) throws { try Self.validateBranchName(branch) let url = snapshotURL(for: branch) diff --git a/Tests/xptTests/RenameTests.swift b/Tests/xptTests/RenameTests.swift new file mode 100644 index 0000000..29824e0 --- /dev/null +++ b/Tests/xptTests/RenameTests.swift @@ -0,0 +1,147 @@ +import Testing +import Foundation +@testable import xptCore + +// MARK: - Helpers + +private let minimalSnapshot = """ + + +""".data(using: .utf8)! + +/// Creates a StorageManager wired to a temporary directory so tests don't +/// touch the real ~/.xpt/ storage. +private func makeStorage() throws -> (StorageManager, URL) { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("xptRenameTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + return (StorageManager(repoDirectory: tmp), tmp) +} + +// MARK: - Tests + +@Suite("StorageManager.rename") +struct RenameTests { + + @Test("Happy path — snapshot file moves to new branch name") + func happyPath() throws { + let (storage, _) = try makeStorage() + let oldURL = storage.snapshotURL(for: "feature/old") + try FileManager.default.createDirectory( + at: oldURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try minimalSnapshot.write(to: oldURL) + + try storage.rename(from: "feature/old", to: "feature/new") + + #expect(!FileManager.default.fileExists(atPath: oldURL.path)) + let newURL = storage.snapshotURL(for: "feature/new") + #expect(FileManager.default.fileExists(atPath: newURL.path)) + } + + @Test("Old snapshot not found — throws noSnapshotFound") + func oldNotFound() throws { + let (storage, _) = try makeStorage() + #expect(throws: StorageError.noSnapshotFound("feature/old")) { + try storage.rename(from: "feature/old", to: "feature/new") + } + } + + @Test("New snapshot already exists — throws snapshotAlreadyExists") + func newAlreadyExists() throws { + let (storage, _) = try makeStorage() + + for branch in ["feature/old", "feature/new"] { + let url = storage.snapshotURL(for: branch) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try minimalSnapshot.write(to: url) + } + + #expect(throws: StorageError.snapshotAlreadyExists("feature/new")) { + try storage.rename(from: "feature/old", to: "feature/new") + } + } + + @Test("Invalid old branch name — throws invalidBranchName") + func invalidOldName() throws { + let (storage, _) = try makeStorage() + #expect(throws: StorageError.invalidBranchName("")) { + try storage.rename(from: "", to: "feature/new") + } + } + + @Test("Invalid new branch name — throws invalidBranchName") + func invalidNewName() throws { + let (storage, _) = try makeStorage() + #expect(throws: StorageError.invalidBranchName("bad\0name")) { + try storage.rename(from: "feature/old", to: "bad\0name") + } + } + + @Test("Same old and new name — throws snapshotAlreadyExists") + func sameName() throws { + let (storage, _) = try makeStorage() + let url = storage.snapshotURL(for: "main") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try minimalSnapshot.write(to: url) + + #expect(throws: StorageError.snapshotAlreadyExists("main")) { + try storage.rename(from: "main", to: "main") + } + } + + @Test("Symlink at old path — throws symlinkDetected") + func symlinkAtOldPath() throws { + let (storage, tmp) = try makeStorage() + let oldURL = storage.snapshotURL(for: "feature/old") + // Create a real file to be the symlink target, then symlink to it + let target = tmp.appendingPathComponent("real.xcbkptlist") + try minimalSnapshot.write(to: target) + try FileManager.default.createSymbolicLink(at: oldURL, withDestinationURL: target) + + #expect(throws: StorageError.symlinkDetected(oldURL.path)) { + try storage.rename(from: "feature/old", to: "feature/new") + } + } + + @Test("Symlink at new path — throws symlinkDetected") + func symlinkAtNewPath() throws { + let (storage, tmp) = try makeStorage() + // Create a real old snapshot + let oldURL = storage.snapshotURL(for: "feature/old") + try minimalSnapshot.write(to: oldURL) + // Place a symlink at the new path + let target = tmp.appendingPathComponent("real.xcbkptlist") + try minimalSnapshot.write(to: target) + let newURL = storage.snapshotURL(for: "feature/new") + try FileManager.default.createSymbolicLink(at: newURL, withDestinationURL: target) + + #expect(throws: StorageError.symlinkDetected(newURL.path)) { + try storage.rename(from: "feature/old", to: "feature/new") + } + } + + @Test("Renamed snapshot content is preserved") + func contentPreserved() throws { + let (storage, _) = try makeStorage() + let oldURL = storage.snapshotURL(for: "feature/old") + try FileManager.default.createDirectory( + at: oldURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try minimalSnapshot.write(to: oldURL) + + try storage.rename(from: "feature/old", to: "feature/new") + + let newURL = storage.snapshotURL(for: "feature/new") + let data = try Data(contentsOf: newURL) + #expect(data == minimalSnapshot) + } +}