fix(backend): handle detached HEAD state when pushing branch for PR creation (#1560)

* fix(backend): handle detached HEAD state when pushing branch for PR creation

When a worktree ends up in detached HEAD state (e.g. after rebase or merge
conflict resolution), `git rev-parse --abbrev-ref HEAD` returns the literal
string "HEAD" instead of a branch name. This caused `git push -u origin HEAD`
to fail with a refspec error, breaking PR creation for ~15% of tasks.

Three fixes:
- get_worktree_info: detect detached HEAD and resolve the actual branch name
  from git's worktree registry, falling back to the expected branch name
- push_branch: re-attach HEAD to the correct branch before pushing
- push_and_create_pr: include branch/remote in error response so frontend
  gets useful diagnostic info even on failure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: apply ruff formatting to worktree.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(backend): add error handling for detached HEAD re-attachment git commands

Add proper error checking for git rev-parse HEAD and git branch -f commands
in push_branch's detached HEAD re-attachment flow. Previously, failures in
these commands would silently fall through to checkout, potentially losing
commits made while in detached HEAD state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-27 15:46:07 +01:00
committed by GitHub
parent fe616f78f6
commit 2b72694d02
+127
View File
@@ -349,6 +349,28 @@ class WorktreeManager:
actual_branch = result.stdout.strip()
# Handle detached HEAD state: rev-parse --abbrev-ref returns literal "HEAD"
# when the worktree is in detached HEAD (e.g. after rebase, merge conflict, etc.)
# First try to resolve the branch from git's worktree registry, then fall back
# to the expected branch name derived from the spec name.
if actual_branch == "HEAD":
registered_branch = self._get_worktree_registered_branch(worktree_path)
if registered_branch:
debug_warning(
"worktree",
f"Worktree '{spec_name}' is in detached HEAD state. "
f"Resolved branch from git worktree registry: {registered_branch}",
)
actual_branch = registered_branch
else:
expected_branch = self.get_branch_name(spec_name)
debug_warning(
"worktree",
f"Worktree '{spec_name}' is in detached HEAD state. "
f"Using expected branch name: {expected_branch}",
)
actual_branch = expected_branch
# Get statistics
stats = self._get_worktree_stats(spec_name)
@@ -361,6 +383,50 @@ class WorktreeManager:
**stats,
)
def _get_worktree_registered_branch(self, worktree_path: Path) -> str | None:
"""
Get the branch name for a worktree from git's worktree registry.
Uses `git worktree list --porcelain` to find the branch associated with
a worktree path. This works even when the worktree is in detached HEAD state,
as git tracks the original branch association in its registry.
Args:
worktree_path: The path to the worktree directory.
Returns:
The branch name (without refs/heads/ prefix) if found, None otherwise.
"""
result = self._run_git(["worktree", "list", "--porcelain"])
if result.returncode != 0:
return None
resolved_path = worktree_path.resolve()
# Parse porcelain output: entries are separated by blank lines,
# each entry has "worktree <path>", "HEAD <sha>", "branch refs/heads/<name>"
# (or "detached" instead of "branch" if truly detached in registry too)
current_path = None
for line in result.stdout.split("\n"):
if line.startswith("worktree "):
current_path = Path(line.split(" ", 1)[1])
elif line.startswith("branch refs/heads/") and current_path is not None:
try:
if current_path.exists() and resolved_path.exists():
if os.path.samefile(resolved_path, current_path):
return line[len("branch refs/heads/") :]
except OSError:
pass
# Fallback to normalized case comparison
if os.path.normcase(str(resolved_path)) == os.path.normcase(
str(current_path)
):
return line[len("branch refs/heads/") :]
elif line == "":
current_path = None
return None
def _check_branch_namespace_conflict(self) -> str | None:
"""
Check if a branch named 'auto-claude' exists, which would block creating
@@ -976,6 +1042,65 @@ class WorktreeManager:
error=f"No worktree found for spec: {spec_name}",
)
# Verify we have an actual branch name (not detached HEAD)
# get_worktree_info already falls back to expected branch name for detached HEAD,
# but we also need to re-attach HEAD to the branch in the worktree so git push works.
head_check = self._run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=info.path)
if head_check.returncode == 0 and head_check.stdout.strip() == "HEAD":
# Resolve the target branch: first check git's worktree registry (which
# tracks the original branch even when detached), then fall back to the
# expected branch name derived from the spec name.
target_branch = self._get_worktree_registered_branch(info.path)
if not target_branch:
target_branch = self.get_branch_name(spec_name)
debug_warning(
"worktree",
f"Re-attaching detached HEAD to branch '{target_branch}' before push",
)
# Check if the target branch exists locally
if self._branch_exists(target_branch):
# Move the branch ref to current commit and switch to it
current_commit = self._run_git(["rev-parse", "HEAD"], cwd=info.path)
if current_commit.returncode != 0:
return PushBranchResult(
success=False,
branch=target_branch,
error=f"Failed to resolve HEAD commit: {current_commit.stderr}",
)
commit_sha = current_commit.stdout.strip()
# Update the branch to point to current commit
branch_update = self._run_git(
["branch", "-f", target_branch, commit_sha],
cwd=info.path,
)
if branch_update.returncode != 0:
return PushBranchResult(
success=False,
branch=target_branch,
error=f"Failed to update branch '{target_branch}' to commit {commit_sha}: {branch_update.stderr}",
)
# Switch to the branch
switch_result = self._run_git(
["checkout", target_branch], cwd=info.path
)
if switch_result.returncode != 0:
return PushBranchResult(
success=False,
branch=target_branch,
error=f"Failed to re-attach to branch '{target_branch}': {switch_result.stderr}",
)
else:
# Branch doesn't exist locally - create it at current HEAD
checkout_result = self._run_git(
["checkout", "-b", target_branch], cwd=info.path
)
if checkout_result.returncode != 0:
return PushBranchResult(
success=False,
branch=target_branch,
error=f"Failed to create branch '{target_branch}': {checkout_result.stderr}",
)
# Push the branch to origin
push_args = ["push", "-u", "origin", info.branch]
if force:
@@ -1315,6 +1440,8 @@ class WorktreeManager:
return PushAndCreatePRResult(
success=False,
pushed=False,
branch=push_result.get("branch", ""),
remote=push_result.get("remote", ""),
error=push_result.get("error", "Push failed"),
)