GitoxideLabs/gitoxide · error
descendant merge commits cannot be squashed
Error message
descendant merge commits cannot be squashed
What it means
After determining the set of descendant commits affected by a squash, tix requires each of them to be a non-merge commit; a merge commit among the descendants would need its second parent's history rewritten or duplicated, which the squash rewrite does not support. The plan is rejected as soon as any scoped commit has more than one parent.
Solutions
- Choose a squash target/source pair that encloses no merge commits (a purely linear range).
- Rebase the merges away (linearize) before squashing, if losing merge topology is acceptable.
- Use `git rebase --rebase-merges`-style tooling or a manual edit if preserving merges while rewriting is required.
Example fix
// before: range target..source contains a merge plan_squash(repo, &graph, after_merge_commit, early_commit)? // after: limit the squash to the linear segment below the merge plan_squash(repo, &graph, last_linear_commit_before_merge, early_commit)?
Defensive patterns
Strategy: validation
Validate before calling
// Rust: verify the affected range has no merge commits before squashing
let scope: Vec<_> = graph.descendants_in_parent_order(target)?.collect();
for id in &scope {
if graph.parents_of(*id)?.len() > 1 {
eprintln!("merge commit {id} in range; squash not supported");
}
} Type guard
fn range_is_linear(scope: &[ObjectId], graph: &HistoryGraph) -> bool {
scope.iter().all(|id| graph.parents_of(*id).map(|p| p.len() <= 1).unwrap_or(false))
} Prevention
- Keep the target..source range linear (no `--no-ff` merges inside it).
- Linearize or split the squash into ranges that exclude merges.
- Prefer `git rebase --rebase-merges` when merge topology must be preserved.
When it happens
Trigger: Squashing a commit that has descendants (between target and source) containing at least one merge commit — `graph.parents_of(id)` returns len > 1 for some id in `descendants_in_parent_order(target)`.
Common situations: Squashing an early commit in a branch-based history where feature branches were merged back in after the target commit; histories with `--no-ff` merges between the squash target and source.
Understand the failure class
Background: UnsupportedOperationException and "is not supported" errors: when a library deliberately refuses a call — this error's family across 30 libraries.
Related errors
- the squash target must be a strict ancestor of the source
- the squash source must have exactly one parent
- the squash target must have exactly one parent
- copying a commit requires it to have exactly one parent
- copying a commit cannot rewrite root or merge commits
AI-assisted analysis of GitoxideLabs/gitoxide@e73179060b (2026-09-08).
Data as JSON: /api/errors/42ac49932a7c72af.
Report an issue: GitHub.
Appendix: source
Thrown at gix-tix/src/edit/rebase.rs:472
let source_parent = *source_parent;
let target_parents = graph
.parents_of(target)
.context("the squash target is not in the loaded history")?;
let [base] = target_parents.as_slice() else {
anyhow::bail!("the squash target must have exactly one parent");
};
let base = *base;
let scope = graph
.descendants_in_parent_order(target)
.context("the squash target is not in the loaded history")?;
let scope_set: HashSet<_> = scope.iter().copied().collect();
let mut non_leaves = HashSet::new();
for id in &scope {
let parents = graph
.parents_of(*id)
.context("an affected squash commit is incomplete")?;
if parents.len() > 1 {
anyhow::bail!("descendant merge commits cannot be squashed");
}
non_leaves.extend(parents.into_iter().filter(|parent| scope_set.contains(parent)));
}
let tips: Vec<_> = scope.iter().copied().filter(|id| !non_leaves.contains(id)).collect();
let mut steps = Vec::with_capacity(scope.len().saturating_sub(1));
let mut step_by_id = HashMap::new();
for id in scope.iter().copied().filter(|id| *id != source) {
let original_parent = graph
.parents_of(id)
.and_then(|parents| parents.first().copied())
.context("an affected squash commit has no parent")?;
let parent = if original_parent == source {
source_parent
} else {
original_parent
};
let parent = step_by_id
.get(&parent)View on GitHub (pinned to e73179060b)