Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <old> <new>` 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 `<old>`, xpt exits with an error. If a snapshot already exists for `<new>`, xpt exits with an error — delete it first with `xpt delete <new>` if you want to overwrite.

---

### `xpt config`

Displays or sets per-repo configuration.
Expand Down
1 change: 1 addition & 0 deletions Sources/xpt/Xpt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct Xpt: ParsableCommand {
Restore.self,
List.self,
Delete.self,
Rename.self,
Config.self,
Hook.self,
]
Expand Down
22 changes: 22 additions & 0 deletions Sources/xptCore/Commands/Rename.swift
Original file line number Diff line number Diff line change
@@ -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)'.")
}
}
30 changes: 29 additions & 1 deletion Sources/xptCore/StorageManager.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)'."
}
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
147 changes: 147 additions & 0 deletions Tests/xptTests/RenameTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import Testing
import Foundation
@testable import xptCore

// MARK: - Helpers

private let minimalSnapshot = """
<?xml version="1.0" encoding="UTF-8"?>
<Bucket type="1" version="2.0"></Bucket>
""".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)
}
}
Loading