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
- Validate/normalize steps before calling: replace any squash with <2 commits by a `Pick` of its single commit, or drop empty ones.
- Prefer round-tripping through `render_integration_steps_script` + `parse_integration_steps_script` to get the same validation the editor path enforces.
- 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
- Never construct Squash steps with <2 commits; collapse them to Pick at build time.
- Round-trip programmatic plans through render+parse to reuse the parser's validation.
- Re-derive plans from the current divergence instead of reusing stale ones.
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
- Integration steps cannot be empty
- line {line_number}: squash requires at least two commits
- line {line_number}: invalid squash commit '{commit}': {err}
- line {line_number}: invalid squash message: {err}
- line {line_number}: pick does not accept a message clause
AI-assisted analysis of gitbutlerapp/gitbutler@caf1f223d3 (2026-08-20).
Data as JSON: /api/errors/6dd9fbacbf04327b.
Report an issue: GitHub.