gitbutlerapp/gitbutler · info
Heads is length 1
Error message
Heads is length 1
What it means
Panic from `expect("Heads is length 1")` in legacy workspace-tree computation (but-workspace legacy/head.rs:65). The code buckets `heads` by count: empty uses the target tree, exactly one reads that commit's tree via `heads.first()`, and multiple does an octopus merge. The `expect` sits inside the `heads.len() == 1` arm, so `first()` is guaranteed Some; it is an unreachable-by-construction assertion.
Source
Thrown at crates/but-workspace/src/legacy/head.rs:65
pub fn remerged_workspace_tree_v2(
repo: &gix::Repository,
ws: &but_graph::Workspace,
) -> Result<(gix::ObjectId, Vec<gix::ObjectId>, gix::ObjectId)> {
let target_base_oid = ws
.stored_target_commit_id()
.context("failed to get target base oid")?;
let heads = ws
.stacks
.iter()
.map(|stack| stack.tip_skip_empty().unwrap_or(target_base_oid))
.collect::<Vec<_>>();
let workspace_tree_id = if heads.is_empty() {
but_core::Commit::try_from(repo.find_commit(target_base_oid)?)?
.tree_id_or_auto_resolution()?
.detach()
} else if heads.len() == 1 {
let commit = but_core::Commit::try_from(
repo.find_commit(*heads.first().expect("Heads is length 1"))?,
)?;
commit.tree_id_or_auto_resolution()?.detach()
} else {
let base_tree_id = but_core::Commit::try_from(
repo.find_commit(repo.merge_base_octopus(heads.iter().copied())?)?,
)?
.tree_id_or_auto_resolution()?
.detach();
let mut workspace_tree_id = base_tree_id;
let (merge_options_fail_fast, conflict_kind) = repo.merge_options_fail_fast()?;
for head in &heads {
let stack_head = but_core::Commit::try_from(repo.find_commit(*head)?)?;
let branch_tree_id = stack_head.tree_id_or_auto_resolution()?.detach();
let mut merge = repo.merge_trees(
base_tree_id,
workspace_tree_id,View on GitHub (pinned to caf1f223d3)
Solutions
- No caller-side action — the else-if structure guarantees the length
- If refactoring, prefer destructuring: `let [head] = heads.as_slice() else { unreachable!() };` or restructure into a match on `heads.as_slice()` so each arm owns its length proof
- Keep snapshot tests for 0/1/N stack workspaces
Example fix
// before
} else if heads.len() == 1 {
let commit = but_core::Commit::try_from(
repo.find_commit(*heads.first().expect("Heads is length 1"))?,
)?;
// after: length proof in the pattern
match heads.as_slice() {
[] => { /* target tree */ }
[head] => {
let commit = but_core::Commit::try_from(repo.find_commit(*head)?)?;
commit.tree_id_or_auto_resolution()?.detach()
}
_ => { /* octopus merge */ }
} Defensive patterns
Strategy: validation
Prevention
- Unreachable in shipped code; the len()==1 else-if arm owns the length proof
- Maintainers: match on heads.as_slice() ([], [head], _) so each arm carries its own evidence
When it happens
Trigger: Computing the legacy workspace tree for a workspace with exactly one non-empty stack tip; the panic becomes reachable only if the `if/else if` chain is reordered so the `len() == 1` arm can see a different count.
Common situations: Maintainers touching legacy workspace-tree logic (e.g. adding empty-stack filtering inside the arms); none for end users.
Related errors
- anchor is always present in the order at this point
- validated pick arity above
- validated merge arity above
- checked all selectors are present
- validated non-empty squash commit list
AI-assisted analysis of gitbutlerapp/gitbutler@caf1f223d3 (2026-08-20).
Data as JSON: /api/errors/f1dfce329971a10f.
Report an issue: GitHub.