gitbutlerapp/gitbutler · error
Can only rename local branches under refs/heads, got
Error message
Can only rename local branches under refs/heads, got '{}' What it means
`branch_rename` (mirroring `branch_checkout_with_perm`) only renames local branches under `refs/heads`. The guard exists so an SDK/server caller cannot pass e.g. `refs/remotes/origin/foo` and have GitButler create a local branch while deleting the remote-tracking ref. Non-local-branch refs are rejected with the offending refname in the message.
Solutions
- Pass a `refs/heads/...` full name to the rename API
- If the intent is to rename a remote branch, rename the local branch and push the new name, then delete the old remote ref
- Check the ref category before invoking rename
Example fix
// before
await api.renameBranch("refs/remotes/origin/old-name", "new-name");
// after
await api.renameBranch("refs/heads/old-name", "new-name");
await api.pushBranch("new-name");
await api.deleteRemoteBranch("origin/old-name"); Defensive patterns
Strategy: validation
Validate before calling
if (!ref.startsWith("refs/heads/")) throw new Error(`rename requires a local branch ref, got: ${ref}`); Type guard
function isLocalBranchRef(ref: string): ref is `refs/heads/${string}` {
return ref.startsWith("refs/heads/");
} Try / catch
try {
await api.branchRename(ref, newName);
} catch (e) {
if (String(e).includes("Can only rename local branches under refs/heads")) {
console.warn("rename a local branch, then push/delete on the remote");
} else { throw e; }
} Prevention
- Only offer rename in the UI for local branches
- Map remote-tracking branches to their local counterpart before renaming
- Never pass refs/remotes/* directly into rename endpoints
- Document that rename implies local ref surgery, not remote updates
When it happens
Trigger: Calling branch rename with a remote-tracking ref (`refs/remotes/origin/x`), a tag, or any ref whose `category()` is not `LocalBranch`.
Common situations: Renaming from a UI bound to a remote-tracking branch; scripts iterating all refs including remotes; SDK callers passing raw ref names from `git branch -r` output.
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
- Branch name ' ' collides with existing branch
- Can only check out local branches under refs/heads, got
- Can only check out local branches under refs/heads or…
- Can only delete local branches under refs/heads, got
- Cannot add the target
AI-assisted analysis of gitbutlerapp/gitbutler@58e5313667 (2026-09-18).
Data as JSON: /api/errors/05a6056164ce4ced.
Report an issue: GitHub.
Appendix: source
Thrown at crates/but-api/src/branch.rs:1273
branch_rename_with_perm(ctx, ref_name, new_name, guard.write_permission())
}
/// Rename the local branch `ref_name` to `new_name` under caller-held exclusive
/// repository access and record an oplog snapshot on success.
///
/// See [`branch_rename()`] for the higher-level behaviour.
pub fn branch_rename_with_perm(
ctx: &mut but_ctx::Context,
ref_name: gix::refs::FullName,
new_name: String,
perm: &mut RepoExclusive,
) -> anyhow::Result<BranchRenameResult> {
// This only renames local branches. Reject anything else up front (mirroring
// `branch_checkout_with_perm`) so an SDK/server caller can't pass e.g.
// `refs/remotes/origin/foo` and have us create a local branch while deleting the
// remote-tracking ref.
if ref_name.category() != Some(gix::refs::Category::LocalBranch) {
bail!(
"Can only rename local branches under refs/heads, got '{}'",
ref_name.as_bstr()
);
}
// Normalize the requested name into a valid local branch reference. This is the non-legacy
// counterpart to the old `normalize_branch_name`, so any caller can pass a raw, human-entered
// name and get a valid ref.
let normalized = but_core::branch::normalize_short_name(new_name.as_str())?;
let new_ref = gix::refs::Category::LocalBranch.to_full_name(normalized.as_bstr())?;
// Renaming onto the same name is a no-op that still returns the current view.
if ref_name == new_ref {
let mut meta = ctx.meta()?;
let (repo, ws, mut db) = ctx.workspace_mut_and_db_mut_with_perm(perm)?;
repo.find_reference(ref_name.as_ref())
.with_context(|| format!("Branch '{}' does not exist", ref_name.shorten()))?;
let workspace = WorkspaceState::from_workspace_with_db(View on GitHub (pinned to 58e5313667)