gitbutlerapp/gitbutler · error · anyhow::Error

Squash step must have at least two commits

Error message

Squash step must have at least two commits

What it means

Defensive precondition in `prepare_squash_step_for_editor` (plan.rs), hit when `integrate_branch_with_steps` processes an `InteractiveIntegrationStep::Squash` whose `commits` vec has fewer than two entries. The script parser already rejects single-commit squashes (parsing.rs), so this bail catches callers that build `InteractiveIntegration` programmatically and bypass parsing.

Source

Thrown at crates/but-workspace/src/branch/integrate_branch_upstream/plan.rs:257

            bail_precondition!(
                "Integration plan is invalid: prepared commit {commit_id} appears more than once"
            );
        }
        prepared.push(step);
    }

    Ok(prepared)
}

/// Precompute the squash payload from the current editor/repository state,
/// before later integration graph mutations can rewire step-graph ancestry.
fn prepare_squash_step_for_editor<M: RefMetadata>(
    editor: &Editor<'_, '_, M>,
    commit_ids: &[gix::ObjectId],
    message: Option<&str>,
) -> Result<gix::ObjectId> {
    if commit_ids.len() < 2 {
        bail!("Squash step must have at least two commits");
    }

    let maybe_selectors = commit_ids
        .iter()
        .map(|commit_id| editor.try_select_commit(*commit_id))
        .collect::<Vec<_>>();
    let ordered_commit_ids = if maybe_selectors.iter().all(Option::is_some) {
        let ordered_selectors = editor.order_commit_selectors_by_parentage(
            maybe_selectors
                .into_iter()
                .map(|selector| selector.expect("checked all selectors are present"))
                .collect::<Vec<_>>(),
        )?;
        ordered_selectors
            .iter()
            .map(|selector| {
                editor
                    .find_selectable_commit(*selector)

View on GitHub (pinned to caf1f223d3)

Solutions

  1. Validate/normalize steps before calling: replace any squash with <2 commits by a `Pick` of its single commit, or drop empty ones.
  2. Prefer round-tripping through `render_integration_steps_script` + `parse_integration_steps_script` to get the same validation the editor path enforces.
  3. Re-derive the plan with `initial_integration_steps` from the current divergence instead of reusing an old one.

Example fix

// before
let steps = vec![InteractiveIntegrationStep::Squash { commits: vec![only_commit], message: None }];
integrate_branch_with_steps(&ref, integration, ...)?;

// after
let steps = vec![InteractiveIntegrationStep::Pick { commit_id: only_commit }];
integrate_branch_with_steps(&ref, integration, ...)?;
Defensive patterns

Strategy: validation

Validate before calling

// Normalize programmatically built steps before integrate_branch_with_steps:
fn normalize(steps: Vec<InteractiveIntegrationStep>) -> Vec<InteractiveIntegrationStep> {
    steps.into_iter()
        .filter_map(|s| match s {
            InteractiveIntegrationStep::Squash { commits, message } if commits.len() < 2 => {
                commits.first().map(|id| InteractiveIntegrationStep::Pick { commit_id: *id })
            }
            InteractiveIntegrationStep::Squash { .. } if false => None,
            other => Some(other),
        })
        .collect()
}

Type guard

fn squash_steps_are_valid(steps: &[InteractiveIntegrationStep]) -> bool {
    steps.iter().all(|s| !matches!(s, InteractiveIntegrationStep::Squash { commits, .. } if commits.len() < 2))
}

Try / catch

if let Err(err) = integrate_branch_with_steps(&ref_name, integration, workspace, meta, repo, db) {
    if err.to_string().contains("Squash step must have at least two commits") {
        // fix the plan construction site, not here: this only fires for programmatic plans
    }
}

Prevention

When it happens

Trigger: Constructing `InteractiveIntegration { steps: vec![InteractiveIntegrationStep::Squash { commits: vec![one_id], message: None }], .. }` and passing it to `integrate_branch_with_steps`. Also reachable by deserializing an integration plan (SDK/IPC) that contains a degenerate squash step.

Common situations: SDK/CLI callers composing steps by hand; filtering logic that drops commits from squash groups but keeps the step; a stale plan from a previous session where a group lost members.

Related errors


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