GitoxideLabs/gitoxide · error

moving the stack would create a commit cycle

Error message

moving the stack would create a commit cycle

What it means

During rebase-plan construction in `gix-tix/src/edit/rebase.rs`, moving a stack of commits is rejected if none of the proposed steps advanced the plan (steps.len() == before after an iteration). That means re-parenting the stack would point a step at one of its own descendants, i.e. a commit cycle. The library refuses because a cycle could never be serialized into a linear rebase.

Solutions

  1. Verify the destination position is not inside the ancestry of any moved commit before invoking the edit
  2. Recompute the requested ordering so moved steps always come after their new parent step
  3. If using a UI, disable moves that would drop a commit onto its own subtree

Example fix

// before: destination index inside the moved stack's ancestry
edit.move_stack(&[c3, c4], Position::after(c4)); // cycle
// after: place the stack after a commit outside its own chain
edit.move_stack(&[c3, c4], Position::after(c1));
Defensive patterns

Strategy: validation

Validate before calling

fn is_valid_move(steps: &[usize], dest_parent: usize) -> bool {
    // destination parent must not be inside the moved stack's own ancestry
    !steps.contains(&dest_parent)
}

Try / catch

match result {
    Err(e) if e.to_string().contains("commit cycle") => {
        eprintln!("requested order loops onto itself; pick a destination outside the moved stack");
    }
    r => r?,
}

Prevention

When it happens

Trigger: Calling the stack-move/reorder edit when the requested new position places a picked commit underneath (an ancestor of) itself, e.g. moving commit A to be a child of its own descendant B, or moving a stack to position 0 when the stack's target parent chain loops back to the stack.

Common situations: Interactive tooling that lets users drag commits in a history view and constructs an order that loops; scripted rebases computed from parent/child arrays with an off-by-one that makes the destination index fall inside the moved range's own ancestry.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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

Appendix: source

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

                continue;
            }
            let parent = new_parent[id];
            if scope_set.contains(&parent) && !step_by_id.contains_key(&parent) {
                continue;
            }
            let parent = step_by_id
                .get(&parent)
                .copied()
                .map_or(PlanParent::Existing(parent), PlanParent::Step);
            step_by_id.insert(*id, steps.len());
            steps.push(PlanStep {
                parent,
                commit: PlanCommit::Pick(*id),
                squash: Vec::new(),
            });
        }
        if steps.len() == before {
            anyhow::bail!("moving the stack would create a commit cycle");
        }
    }

    let head_step = PlanParent::Step(step_by_id[&head]);
    let mut ref_scope = scope.clone();
    if !scope_set.contains(&target) {
        ref_scope.push(target);
    }
    let ref_scope_set: HashSet<_> = ref_scope.iter().copied().collect();
    let mut non_leaves = HashSet::new();
    for id in &ref_scope {
        non_leaves.extend(
            graph
                .parents_of(*id)
                .context("an affected move commit is incomplete")?
                .into_iter()
                .filter(|parent| ref_scope_set.contains(parent)),
        );

View on GitHub (pinned to e73179060b)