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:
-
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.
-
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
-
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(())
-
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.
-
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.
Bug:
git chain rebasepanics (and dirties the worktree) when a chain branch is checked out in another worktreeSummary
git chain rebaseaborts with a Rustpanic!— not a graceful error — as soon as ittries to switch to a chain branch that is checked out in a separate linked worktree
(
git worktree). The panic originates inGit::checkout_branch, which force-panics ifset_headfails instead of propagating theError.A second, more insidious symptom: because
checkout_branchcallscheckout_tree()before
set_head(), the failed checkout leaves the working tree mutated to thetarget branch's tree while
HEADstill points at the previous branch. To the user thislooks like a large pile of spurious staged/unstaged changes appeared out of nowhere,
even though no real edits were made.
Affected version
src/main.rs:922:29).masterat v0.0.13 — the code moved tosrc/git_chain/core.rs:120-136(Git::checkout_branch) but the logic is unchanged.git2 0.20.2,libgit2-sys 0.18.2+1.9.1; systemgit 2.54.0.Panic output (as seen by the user)
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 happenedto be checked out in a linked worktree at
.claude/worktrees/agent-a57d7d70c8f5a9fd6.Root cause
src/git_chain/core.rs:Two distinct problems:
Panic instead of error propagation (line 133). The function returns
Result<(), Error>and every caller uses?, butset_head's failure is turned intoa
panic!viaunwrap_or_else. libgit2 refuses to pointHEADat a branch that isalready 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, ideallywith a message naming the offending worktree.
Working tree mutated before HEAD is validated (lines 124 vs 127-133).
checkout_tree()runs first and rewrites the working directory/index to the targetbranch's tree. When the subsequent
set_headfails and the process panics, the repo isleft with
HEADon the previous branch but a working tree/index matching the targetbranch. 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_headfails here specificallylibgit2's
set_headmirrors Git's rule that a branch may be checked out in at most oneworktree. Reproduction with plain Git shows the porcelain equivalent:
The chain being rebased had one of its branches (
package-retention-defaults) checked outin a stray
.claude/worktrees/…agent worktree, which is what tripped the guard.Reproduction
After the panic,
git statusin the main worktree shows spurious changes despiteHEADbeing unchanged.
Impact
git chain rebase/merge/ navigation command that mustcheckout_brancha worktree-occupied branch will crash.
--stepdoes not help — it panics on the samebranch each time.
It also blocks git-chain from re-running (subsequent runs can panic earlier on the dirty
tree).
.claude/worktrees/…are created by coding agents, so users can hitthis without knowingly running
git worktree add.Suggested fix
Stop panicking: return the
git2::Error(or a wrapped domain error) fromcheckout_branchso callers'?can surface a clean message. At minimum: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_headerror) and fail beforecheckout_treemutates the working directory.Emit an actionable message, e.g.:
Consider skipping/【warning on】chain branches that live in other worktrees during
rebase, since their
HEADcannot be moved from the current worktree anyway.Workaround (for users hitting this today)
git worktree remove <path>(orgit worktree prunefor stale entries), then re-rungit chain rebase.git reset --hard HEADfirst (no real changes were made), after confirming there is nogenuine work-in-progress.
inside its own worktree (
git -C <path> rebase --onto <new_parent> <old_parent> <branch>) and the remaining branches from the main worktree.