GitoxideLabs/gitoxide · error

moving a stack requires every commit to have exactly one…

Error message

moving a stack requires every commit to have exactly one parent

What it means

While walking the stack from `head` down to `base`, every commit on the stack must have exactly one parent, because moving the stack re-parents each of its commits linearly. If any commit on the walk is a root (no parent) or a merge (multiple parents), the move cannot be represented as a plan and this error is raised.

Solutions

  1. Choose a `base` below any merge commits so the stack base..head is linear.
  2. Rebase the stack linearly (e.g. rebase --onto, dropping merges) before moving it with tix.
  3. Move only the linear segment above the merge by adjusting `base`.

Example fix

// before
tix move-stack --base <merge-commit> --head HEAD

// after
let base = first_linear_commit_below_merges(&graph, head);
tix move-stack --base base --head HEAD
Defensive patterns

Strategy: validation

Validate before calling

let mut id = head;
loop {
    let parents = graph.parents_of(id)?;
    if parents.len() != 1 {
        eprintln!("stack contains a root or merge commit; cannot move");
        return Ok(());
    }
    if id == base { break; }
    id = parents[0];
}

Type guard

fn stack_is_linear(graph: &HistoryGraph, base: ObjectId, head: ObjectId) -> bool {
    let mut id = head;
    loop {
        match graph.parents_of(id) {
            Ok(p) if p.len() == 1 => {
                if id == base { return true; }
                id = p[0];
            }
            _ => return false,
        }
    }
}

Try / catch

match stack_insert_plan(&repo, &graph, base, head, target) {
    Err(e) if e.to_string().contains("exactly one parent") => eprintln!("stack spans a merge; linearize first"),
    other => other?,
}

Prevention

When it happens

Trigger: Calling `move_insert_plan`/`stack_insert_plan` where a commit between `base` and `head` (inclusive) has 0 or 2+ parents — e.g. HEAD's history contains a merge commit, or `base` is a root commit.

Common situations: Stacks built on top of a merge from another branch; trying to move a stack whose bottom is the repository's initial commit; repositories with interleaved merge workflow.

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


AI-assisted analysis of GitoxideLabs/gitoxide@e73179060b (2026-09-08). Data as JSON: /api/errors/a1ac9781c6289391. Report an issue: GitHub.

Appendix: source

Thrown at gix-tix/src/edit/rebase.rs:665

    target: ObjectId,
) -> Result<Plan> {
    if repo.head_id()?.detach() != head {
        anyhow::bail!("the move source must be the current HEAD");
    }
    if !graph.is_ancestor(base, head) {
        anyhow::bail!("the stack base must be an ancestor of HEAD");
    }
    graph
        .parents_of(target)
        .context("the move target is not in the loaded history")?;

    let mut stack = vec![head];
    let mut stack_parent = HashMap::new();
    loop {
        let id = *stack.last().expect("a stack always contains HEAD");
        let parents = graph.parents_of(id).context("a moved stack commit is incomplete")?;
        let [parent] = parents.as_slice() else {
            anyhow::bail!("moving a stack requires every commit to have exactly one parent");
        };
        stack_parent.insert(id, *parent);
        if id == base {
            break;
        }
        stack.push(*parent);
    }
    stack.reverse();
    let stack_set: HashSet<_> = stack.iter().copied().collect();
    if stack_set.contains(&target) {
        anyhow::bail!("the move target must not be part of the moved stack");
    }
    let base_parent = stack_parent[&base];
    if base_parent == target {
        anyhow::bail!("the stack is already directly above the move target");
    }

    let target_rewritten = graph.is_ancestor(base, target);

View on GitHub (pinned to e73179060b)