GitoxideLabs/gitoxide · error

the checkout ancestry contains a cycle

Error message

the checkout ancestry contains a cycle

What it means

When validating a rebase plan, the library walks the parent chain of the plan's checkout target step-by-step and records visited step indices. If the same index is seen twice, the checkout ancestry forms a loop (a step whose ancestry includes itself), which cannot be executed.

Solutions

  1. Fix the parent indices so each step's ancestry is strictly decreasing (no step is its own ancestor)
  2. Rebuild the plan from the intended linear order instead of patching indices
  3. Add a pre-submit assertion that following `parent` from `plan.checkout` terminates at `Existing`

Example fix

// before
steps[2].parent = PlanParent::Step(5); steps[5].parent = PlanParent::Step(2); // loop
// after
steps[5].parent = PlanParent::Existing(base); steps[2].parent = PlanParent::Step(5);
Defensive patterns

Strategy: validation

Validate before calling

fn checkout_chain_acyclic(plan: &Plan) -> bool {
    let mut seen = HashSet::new();
    let mut cur = Some(plan.checkout);
    while let Some(PlanParent::Step(i)) = cur {
        if !seen.insert(i) { return false; }
        cur = plan.steps.get(i).map(|s| s.parent);
    }
    true
}

Prevention

When it happens

Trigger: Constructing a plan whose `checkout` PlanParent::Step chain points back to an earlier step — e.g. step 2's parent is step 5 while step 5's ancestry leads back to step 2; programmatically splicing steps with incorrect parent indices.

Common situations: Custom tooling that builds rebase plans by index and sets a wrong parent index after reordering; buggy plan-serialization round-trips that corrupt parent links.

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/75c49cd1b95987da. Report an issue: GitHub.

Appendix: source

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

    } else {
        None
    };
    if plan.checkout.is_none()
        && checkout_target.is_some()
        && !plan
            .steps
            .iter()
            .any(|step| matches!(step.commit, PlanCommit::Resolved(_)))
        && let Some(head) = repo.head()?.id().map(gix::Id::detach)
        && plan.scope.contains(&head)
    {
        reject_pending_checkout_path(&repo, head)?;
    }
    let mut eager = HashSet::new();
    let mut cursor = checkout_target;
    while let Some(PlanParent::Step(index)) = cursor {
        if !eager.insert(index) {
            anyhow::bail!("the checkout ancestry contains a cycle");
        }
        cursor = match plan.steps.get(index).context("the checkout step is missing")?.parent {
            parent @ PlanParent::Step(_) => Some(parent),
            PlanParent::Existing(_) => None,
        };
    }

    let mut rewritten = HashMap::<ObjectId, Option<ObjectId>>::new();
    let mut note_rewrites = Vec::new();
    let mut produced = Vec::with_capacity(plan.steps.len());
    let mut delete_refs = Vec::new();
    let mut conflict = None;
    let mut marked = false;
    for (index, step) in plan.steps.iter().enumerate() {
        let mut resolved_head = None;
        let parent = match step.parent {
            PlanParent::Existing(id) => {
                repo.find_commit(id).context("could not find a fork target")?;

View on GitHub (pinned to e73179060b)