gitbutlerapp/gitbutler · error · anyhow::Error

Ad-hoc (single-branch) branch moves are not supported inside

Error message

Ad-hoc (single-branch) branch moves are not supported inside transactions

What it means

The transactional wrapper around branch moves asserts that the underlying but_workspace::branch::move_branch produced no new_tip/branch_stack_order. If it did, the move was an ad-hoc single-branch move (a legacy, non-transactional shape), which transactions cannot record atomically. The ensure! fires to refuse rather than half-apply.

Source

Thrown at crates/but-transaction/src/lib.rs:457

    /// field is ever set rather than silently dropping a metadata reorder or a required checkout.
    ///
    /// [`Outcome::new_tip`]: but_workspace::branch::move_branch::Outcome::new_tip
    /// [`Outcome::branch_stack_order`]: but_workspace::branch::move_branch::Outcome::branch_stack_order
    pub fn stack_branch_on(
        &mut self,
        source_branch: &FullNameRef,
        target_branch: &FullNameRef,
    ) -> anyhow::Result<()> {
        let (ws_meta, new_tip, branch_stack_order) = self.rebase(|editor, _| {
            let outcome = but_workspace::branch::move_branch(editor, source_branch, target_branch)?;
            Ok((
                (outcome.ws_meta, outcome.new_tip, outcome.branch_stack_order),
                MaterializeWithoutCheckout::No,
                outcome.rebase,
            ))
        })?;

        anyhow::ensure!(
            new_tip.is_none() && branch_stack_order.is_none(),
            "Ad-hoc (single-branch) branch moves are not supported inside transactions"
        );

        self.record_workspace_metadata_update(ws_meta)?;

        Ok(())
    }

    pub fn tear_off_branch(&mut self, source_branch: &FullNameRef) -> anyhow::Result<()> {
        let ws_meta = self.rebase(|editor, _| {
            let outcome = but_workspace::branch::tear_off_branch(editor, source_branch, None)?;
            Ok((
                outcome.ws_meta,
                MaterializeWithoutCheckout::No,
                outcome.rebase,
            ))
        })?;

View on GitHub (pinned to caf1f223d3)

Solutions

  1. Use the non-transactional single-branch move API for ad-hoc moves
  2. Use the transaction API only for operations that move entire stacks and return no per-branch tip/order changes
  3. If you maintain the workspace layer, return the stack-shaped outcome (no new_tip/branch_stack_order) for transactional callers
Defensive patterns

Strategy: type-guard

Validate before calling

// before the transactional call, confirm the move is stack-shaped, not ad-hoc
let outcome_preview = but_workspace::branch::move_branch(&editor, &src, &tgt)?;
if outcome_preview.new_tip.is_some() || outcome_preview.branch_stack_order.is_some() {
    return Err(anyhow!("ad-hoc move — use the non-transactional API"));
}

Type guard

fn is_stack_move(outcome: &MoveBranchOutcome) -> bool {
    outcome.new_tip.is_none() && outcome.branch_stack_order.is_none()
}

Try / catch

match tx.move_branch(&src, &tgt) {
    Err(e) if e.to_string().contains("Ad-hoc") => { /* route to non-transactional move API */ }
    other => other,
}

Prevention

When it happens

Trigger: Calling the transaction-based move_branch(source, target) where the workspace operation resolves to a single-branch (ad-hoc) move instead of a full stack move — e.g. moving a branch whose stack contains other managed branches such that only one branch relocates.

Common situations: UI or API code migrated from the ad-hoc move API onto the transaction API without restricting it to whole-stack moves; reordering branches in a multi-branch workspace via the wrong entry point.

Related errors


AI-assisted analysis of gitbutlerapp/gitbutler@caf1f223d3 (2026-08-20). Data as JSON: /api/errors/5ffede1d5ef864d7. Report an issue: GitHub.