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

  1. No caller-side action — the else-if structure guarantees the length
  2. 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
  3. 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

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


AI-assisted analysis of gitbutlerapp/gitbutler@caf1f223d3 (2026-08-20). Data as JSON: /api/errors/f1dfce329971a10f. Report an issue: GitHub.