Skip to content

git chain rebase panics (and dirties the worktree) when a chain branch is checked out in another worktree #48

Description

@dashed

Bug: git chain rebase panics (and dirties the worktree) when a chain branch is checked out in another worktree

Summary

git chain rebase aborts with a Rust panic! — not a graceful error — as soon as it
tries to switch to a chain branch that is checked out in a separate linked worktree
(git worktree). The panic originates in Git::checkout_branch, which force-panics if
set_head fails instead of propagating the Error.

A second, more insidious symptom: because checkout_branch calls checkout_tree()
before set_head(), the failed checkout leaves the working tree mutated to the
target branch's tree while HEAD still points at the previous branch. To the user this
looks like a large pile of spurious staged/unstaged changes appeared out of nowhere,
even though no real edits were made.

Affected version

  • Observed at runtime with the installed binary git-chain 0.0.9 (panic reported as
    src/main.rs:922:29).
  • The same defect is still present on master at v0.0.13 — the code moved to
    src/git_chain/core.rs:120-136 (Git::checkout_branch) but the logic is unchanged.
  • Dependencies: git2 0.20.2, libgit2-sys 0.18.2+1.9.1; system git 2.54.0.

Panic output (as seen by the user)

git rebase --keep-empty --onto master <fork> aleal/feat/package-retention-config
Rebasing (1/2)Rebasing (2/2)Successfully rebased and updated refs/heads/aleal/feat/package-retention-config.
thread 'main' panicked at src/main.rs:922:29:
Failed to set HEAD to branch aleal/feat/package-retention-defaults
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Note the first branch rebases fine; the panic fires when git-chain tries to check out
the next branch in the chain
(aleal/feat/package-retention-defaults), which happened
to be checked out in a linked worktree at
.claude/worktrees/agent-a57d7d70c8f5a9fd6.

Root cause

src/git_chain/core.rs:

pub fn checkout_branch(&self, branch_name: &str) -> Result<(), Error> {
    let (object, reference) = self.repo.revparse_ext(branch_name)?;

    // set working directory
    self.repo.checkout_tree(&object, None)?;          // (1) mutates the working tree FIRST

    // set HEAD to branch_name
    match reference {
        Some(ref_name) => self.repo.set_head(ref_name.name().unwrap()),  // (2) fails here
        None => self.repo.set_head_detached(object.id()),
    }
    .unwrap_or_else(|_| panic!("Failed to set HEAD to branch {}", branch_name));  // (3) panics

    Ok(())
}

Two distinct problems:

  1. Panic instead of error propagation (line 133). The function returns
    Result<(), Error> and every caller uses ?, but set_head's failure is turned into
    a panic! via unwrap_or_else. libgit2 refuses to point HEAD at a branch that is
    already checked out in another worktree, so this is a reachable, user-triggerable
    condition — not a "can't happen" invariant. It should be returned as an Err, ideally
    with a message naming the offending worktree.

  2. Working tree mutated before HEAD is validated (lines 124 vs 127-133).
    checkout_tree() runs first and rewrites the working directory/index to the target
    branch's tree. When the subsequent set_head fails and the process panics, the repo is
    left with HEAD on the previous branch but a working tree/index matching the target
    branch. The user then sees a mountain of phantom modifications (in the report case,
    ~46 files, e.g. re-deletions of files master had removed). The checkout should be
    atomic: verify the head move is possible (or do the ref update) before touching the
    working tree, or restore the tree on failure.

Why set_head fails here specifically

libgit2's set_head mirrors Git's rule that a branch may be checked out in at most one
worktree. Reproduction with plain Git shows the porcelain equivalent:

$ git worktree add ../wt feature      # 'feature' now held by linked worktree
$ git checkout feature                # from the main worktree:
fatal: 'feature' is already used by worktree at '/.../wt'

The chain being rebased had one of its branches (package-retention-defaults) checked out
in a stray .claude/worktrees/… agent worktree, which is what tripped the guard.

Reproduction

# 1. Build a 2-branch chain on master
git init repo && cd repo
git commit --allow-empty -m init
git checkout -b feature-1 && git commit --allow-empty -m f1
git checkout -b feature-2 && git commit --allow-empty -m f2
git chain setup demo master feature-1 feature-2

# 2. Occupy an INTERIOR chain branch in a separate worktree
git worktree add ../wt feature-2        # feature-2 now checked out elsewhere

# 3. Advance master so a rebase is actually required
git checkout master && git commit --allow-empty -m "new base"

# 4. Rebase the chain -> panics when it reaches feature-2
git checkout feature-2 2>/dev/null; git chain rebase
# thread 'main' panicked ... Failed to set HEAD to branch feature-2

After the panic, git status in the main worktree shows spurious changes despite HEAD
being unchanged.

Impact

  • Every git chain rebase / merge / navigation command that must checkout_branch
    a worktree-occupied branch will crash. --step does not help — it panics on the same
    branch each time.
  • The bogus dirty state is alarming and easy to misread as data loss or a broken rebase.
    It also blocks git-chain from re-running (subsequent runs can panic earlier on the dirty
    tree).
  • Worktrees under .claude/worktrees/… are created by coding agents, so users can hit
    this without knowingly running git worktree add.

Suggested fix

  1. Stop panicking: return the git2::Error (or a wrapped domain error) from
    checkout_branch so callers' ? can surface a clean message. At minimum:

    match reference {
        Some(ref_name) => self.repo.set_head(ref_name.name().unwrap()),
        None => self.repo.set_head_detached(object.id()),
    }?;   // propagate instead of panic!
    Ok(())
  2. Make it atomic / worktree-aware: detect when a target branch is checked out in another
    worktree (libgit2 exposes worktree enumeration; or catch the specific
    set_head error) and fail before checkout_tree mutates the working directory.
    Emit an actionable message, e.g.:

    Branch 'feature-2' is checked out in another worktree at '<path>'. Remove or switch that worktree, then retry.

  3. Consider skipping/【warning on】chain branches that live in other worktrees during
    rebase, since their HEAD cannot be moved from the current worktree anyway.

Workaround (for users hitting this today)

  • Remove the stray worktree so the branch is only checked out once:
    git worktree remove <path> (or git worktree prune for stale entries), then re-run
    git chain rebase.
  • If the working tree was already dirtied by a panic, it is safe to
    git reset --hard HEAD first (no real changes were made), after confirming there is no
    genuine work-in-progress.
  • To finish a rebase without removing the worktree, rebase the occupied branch from
    inside its own worktree
    (git -C <path> rebase --onto <new_parent> <old_parent> <branch>) and the remaining branches from the main worktree.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions