gitbutlerapp/gitbutler · error

Expected a branch ID, got

Error message

Expected a branch ID, got {}

What it means

`but merge` expects its argument to resolve to a branch (`CliId::Branch`). The match on `resolved_ids[0]` accepts only the `CliId::Branch` variant; any other resolved kind (commit, file, hunk, etc.) is rejected with this message, which includes the human-readable kind via `kind_for_humans()`. The identifier resolved successfully but points at a workspace item that cannot be landed.

Solutions

  1. Look up the branch containing the item with `but branch list` and pass its branch ID/name instead.
  2. If you have a commit ID, find its parent branch (e.g. via `but log` or `but branch list`) and merge that branch.
  3. Remember `but merge` lands whole branches onto the target; use the appropriate command (e.g. `but commit` operations) for commit-level actions.
  4. Use the full branch CLI ID (`br#N`) copied from `but branch list` output to avoid resolving to other item kinds.

Example fix

// before: commit id passed to merge
but merge cm#2
// error: Expected a branch ID, got a commit
// after: merge the branch that owns the commit
but branch list   # feature-x is br#1
but merge feature-x
Defensive patterns

Strategy: validation

Validate before calling

// Ensure the identifier is a branch before calling but merge:
const branches = JSON.parse(runBut(['branch', 'list', '--json']).stdout);
if (!branches.some(b => b.id === id || b.name === id)) {
  throw new Error(`${id} is not a branch ID; but merge only accepts branch identifiers.`);
}

Type guard

function isBranchId(id, branches) {
  return branches.some(b => b.id === id || b.name === id);
}

Try / catch

try {
  runBut(['merge', id]);
} catch (e) {
  if (e.stderr.startsWith('Expected a branch ID, got')) {
    // id resolved to a commit/file/hunk; map it to its owning branch and retry
    runBut(['merge', owningBranchName]);
  } else {
    throw e;
  }
}

Prevention

When it happens

Trigger: Calling `but merge <id>` with an identifier that resolves to exactly one non-branch item: a commit ID (e.g. from `but log`), a file or hunk ID, or a workspace/target reference. `parse_using_context` returns a single result, but it is not `CliId::Branch`, so the `other =>` bail arm fires.

Common situations: Copy-pasting a commit ID from `but log` output into `but merge` instead of a branch ID. Selecting a file ID from TUI output when intending to merge its branch. Confusion between `but merge` (lands branches) and other commands that accept commit IDs.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


AI-assisted analysis of gitbutlerapp/gitbutler@58e5313667 (2026-09-18). Data as JSON: /api/errors/989b7ecaa010248a. Report an issue: GitHub.

Appendix: source

Thrown at crates/but/src/command/legacy/merge/mod.rs:53

                bail!(
                    "`but merge` requires an active GitButler workspace (`gitbutler/workspace`). \
                     Switch into the workspace and try again."
                );
            }
        }

        let id_map = IdMap::new_from_context(ctx, guard.read_permission())?;
        let resolved_ids = id_map.parse_using_context(branch_id, ctx)?;
        if resolved_ids.is_empty() {
            bail!("Could not find branch: {branch_id}");
        }
        if resolved_ids.len() > 1 {
            bail!("Ambiguous branch '{branch_id}', matches multiple items");
        }

        let branch_name = match &resolved_ids[0] {
            CliId::Branch(branch) => branch.name.clone(),
            other => bail!("Expected a branch ID, got {}", other.kind_for_humans()),
        };

        let base_branch =
            but_api::legacy::virtual_branches::get_base_branch_data(ctx, guard.write_permission())?
                .ok_or_else(|| anyhow::anyhow!("No base branch configured"))?;
        (branch_name, base_branch)
    };

    // Display strings for the prompt and the final report. The API recomputes the target/remote
    // configuration internally; the CLI only needs these names to describe what's about to happen.
    let target_branch_name = base_branch.short_name.clone();
    let push_remote_name = if base_branch.push_remote_name.is_empty() {
        base_branch.remote_name.clone()
    } else {
        base_branch.push_remote_name.clone()
    };
    let target_display = format!("{push_remote_name}/{target_branch_name}");

View on GitHub (pinned to 58e5313667)