Skip to content

Commit 2c33471

Browse files
Merge pull request #5 from hi2gage/feature/recover-stale-worktree-branch-conflicts
Recover from stale worktree branch conflicts
2 parents ea4242a + d3d6aac commit 2c33471

File tree

2 files changed

+88
-19
lines changed

2 files changed

+88
-19
lines changed

Sources/sprout/Commands/Launch.swift

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,9 @@ struct Launch: AsyncParsableCommand {
360360
private func ensureWorktreeExists(at path: String, branch: String, hasPRBranch: Bool, dryRun: Bool) async throws -> Bool {
361361
let gitService = GitService()
362362

363+
// Prune stale worktree references before checking
364+
try await gitService.pruneWorktrees()
365+
363366
// Check if worktree already exists at this path
364367
let existingWorktrees = try await gitService.listWorktrees()
365368
if existingWorktrees.contains(where: { $0.path == path }) {
@@ -395,30 +398,26 @@ struct Launch: AsyncParsableCommand {
395398
}
396399
try await gitService.fetchBranch(branch)
397400

398-
// Create worktree from existing branch
399-
do {
400-
try await gitService.createWorktreeFromExisting(at: path, branch: branch)
401-
if verbose {
402-
print("Created worktree from existing branch: \(branch)")
403-
}
404-
return false
405-
} catch {
406-
throw GitError.worktreeCreationFailed("Failed to create worktree from PR branch '\(branch)': \(error)")
407-
}
401+
// Create worktree from existing branch (with recovery for branch conflicts)
402+
try await createWorktreeWithRecovery(
403+
gitService: gitService,
404+
at: path,
405+
branch: branch,
406+
isPR: true
407+
)
408+
return false
408409
}
409410

410411
// For non-PR sources, try to create a new branch
411412
let branchExists = try await gitService.branchExists(branch)
412413
if branchExists {
413-
// Branch exists - create worktree from existing branch
414-
do {
415-
try await gitService.createWorktreeFromExisting(at: path, branch: branch)
416-
if verbose {
417-
print("Created worktree from existing branch: \(branch)")
418-
}
419-
} catch {
420-
throw GitError.worktreeCreationFailed("Branch '\(branch)' exists but worktree creation failed: \(error)")
421-
}
414+
// Branch exists - create worktree from existing branch (with recovery)
415+
try await createWorktreeWithRecovery(
416+
gitService: gitService,
417+
at: path,
418+
branch: branch,
419+
isPR: false
420+
)
422421
} else {
423422
// Create new branch with worktree
424423
do {
@@ -434,6 +433,68 @@ struct Launch: AsyncParsableCommand {
434433
return false
435434
}
436435

436+
/// Create a worktree from an existing branch, with automatic recovery if the branch
437+
/// is already checked out in another worktree (stale or active).
438+
private func createWorktreeWithRecovery(
439+
gitService: GitService,
440+
at path: String,
441+
branch: String,
442+
isPR: Bool
443+
) async throws {
444+
do {
445+
try await gitService.createWorktreeFromExisting(at: path, branch: branch)
446+
if verbose {
447+
print("Created worktree from existing branch: \(branch)")
448+
}
449+
} catch {
450+
let errorMessage = String(describing: error)
451+
guard errorMessage.contains("is already used by worktree") else {
452+
let context = isPR ? "PR branch" : "Branch"
453+
throw GitError.worktreeCreationFailed("\(context) '\(branch)' worktree creation failed: \(error)")
454+
}
455+
456+
// Branch is locked by another worktree - attempt recovery
457+
let conflictingPath = parseConflictingWorktreePath(from: errorMessage)
458+
let conflictingExists = conflictingPath.map { FileManager.default.fileExists(atPath: $0) } ?? false
459+
460+
if !conflictingExists {
461+
// The conflicting worktree no longer exists on disk - prune stale reference and retry
462+
if verbose {
463+
let displayPath = conflictingPath ?? "unknown path"
464+
print("Conflicting worktree at \(displayPath) no longer exists on disk, pruning...")
465+
}
466+
try await gitService.pruneWorktrees()
467+
468+
// Re-fetch the branch now that it's no longer "checked out"
469+
if isPR {
470+
try await gitService.fetchBranch(branch)
471+
}
472+
473+
try await gitService.createWorktreeFromExisting(at: path, branch: branch)
474+
if verbose {
475+
print("Created worktree from existing branch after pruning: \(branch)")
476+
}
477+
} else {
478+
// Branch is genuinely checked out in another active worktree - force create
479+
if verbose {
480+
print("Branch '\(branch)' is checked out at \(conflictingPath!), force-creating worktree...")
481+
}
482+
try await gitService.createWorktreeFromExistingForce(at: path, branch: branch)
483+
if verbose {
484+
print("Created worktree (forced) from existing branch: \(branch)")
485+
}
486+
}
487+
}
488+
}
489+
490+
/// Parse the conflicting worktree path from a git "already used by worktree" error message.
491+
private func parseConflictingWorktreePath(from errorMessage: String) -> String? {
492+
if let match = errorMessage.firstMatch(of: /is already used by worktree at '([^']+)'/) {
493+
return String(match.1)
494+
}
495+
return nil
496+
}
497+
437498
// MARK: - Script Execution
438499

439500
private func executeScript(_ script: String) async throws {

Sources/sprout/Services/GitService.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ struct GitService {
7474
}
7575
}
7676

77+
/// Create a worktree from an existing branch, even if it's checked out in another worktree
78+
func createWorktreeFromExistingForce(at path: String, branch: String) async throws {
79+
let result = try runWithStderr(["worktree", "add", "--force", path, branch])
80+
guard result.success else {
81+
throw GitError.worktreeCreationFailed(result.stderr)
82+
}
83+
}
84+
7785
/// Get the current repo's owner/repo from the remote origin
7886
/// Returns format like "owner/repo" (e.g., "hi2gage/FreshWall")
7987
func getRemoteRepo() async throws -> String? {

0 commit comments

Comments
 (0)