feat(git): rename remote branches atomically via push

Adds a new command to rename remote branches and update tracking refs.
Renaming remote refs is performed atomically using a single push.
The push creates the new remote ref and deletes the old if it succeeds.
The frontend now invokes remote-rename when needed and shows labels.

- Atomic remote rename via push with create/ref and delete
- Frontend supports remote branch renames from the branch panel
- Compare UI now shows labels for remote refs in results
This commit is contained in:
Christoph Brandau
2026-08-13 18:32:16 +02:00
parent ca14fac90c
commit f43fe00873
11 changed files with 447 additions and 71 deletions
+166
View File
@@ -675,6 +675,99 @@ pub fn delete_remote_branch(
result
}
#[tauri::command]
pub async fn rename_remote_branch(
path: String,
remote: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || {
rename_remote_branch_core(path, remote, old_branch, new_branch)
})
.await
}
fn rename_remote_branch_core(
path: String,
remote: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
let old_branch = validate_branch_ref_name(old_branch.trim())?;
let new_branch = validate_branch_ref_name(new_branch.trim())?;
if old_branch == new_branch {
return Err("The new remote branch name is unchanged.".to_string());
}
let old_tracking_ref = format!("refs/remotes/{remote}/{old_branch}");
let new_tracking_ref = format!("refs/remotes/{remote}/{new_branch}");
if !ref_exists(&repo, &old_tracking_ref)? {
return Err(format!(
"Remote branch '{remote}/{old_branch}' was not found."
));
}
if ref_exists(&repo, &new_tracking_ref)? {
return Err(format!(
"Remote branch '{remote}/{new_branch}' already exists."
));
}
let old_hash = run_git(&repo, ["rev-parse", "--verify", old_tracking_ref.as_str()])?;
let old_hash = String::from_utf8_lossy(&old_hash).trim().to_string();
let old_remote_ref = format!("refs/heads/{old_branch}");
let new_remote_ref = format!("refs/heads/{new_branch}");
let source_lease = format!("--force-with-lease={old_remote_ref}:{old_hash}");
// An empty expected value means the destination must not exist on the remote.
let destination_lease = format!("--force-with-lease={new_remote_ref}:");
let create_refspec = format!("{old_tracking_ref}:{new_remote_ref}");
let delete_refspec = format!(":{old_remote_ref}");
// Git has no standalone remote-rename command. Create the new ref and delete
// the old one in a single atomic push so a rejected update leaves both untouched.
run_git(
&repo,
[
"push",
"--atomic",
source_lease.as_str(),
destination_lease.as_str(),
remote.as_str(),
create_refspec.as_str(),
delete_refspec.as_str(),
],
)?;
// Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups.
if !ref_exists(&repo, &new_tracking_ref)? {
if let Err(error) = run_git(
&repo,
["update-ref", new_tracking_ref.as_str(), old_hash.as_str()],
) {
log::warn!(target: "gitty::remote", "remote rename succeeded, but the new tracking ref could not be updated: {error}");
}
}
if ref_exists(&repo, &old_tracking_ref)? {
if let Err(error) = run_git(
&repo,
[
"update-ref",
"-d",
old_tracking_ref.as_str(),
old_hash.as_str(),
],
) {
log::warn!(target: "gitty::remote", "remote rename succeeded, but the old tracking ref could not be removed: {error}");
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
run_git_task("Could not load stashes", move || {
@@ -6686,6 +6779,31 @@ mod tests {
assert!(comparison.patch.contains("second line"));
}
#[test]
fn compare_commits_accepts_branch_refs_for_a_full_repository_diff() {
let repo = init_temp_repo("compare_branches");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "base"]);
fs::write(repo.path.join("branch-only.txt"), "only on feature\n")
.expect("branch file should be written");
run_git_test(&repo.path, ["add", "branch-only.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature change"]);
run_git_test(&repo.path, ["branch", "feature/complete-compare"]);
let comparison = compare_commits(
repo.path.to_string_lossy().to_string(),
"refs/heads/base".to_string(),
"refs/heads/feature/complete-compare".to_string(),
)
.unwrap();
assert!(comparison.files.iter().any(|file| {
file.path == "branch-only.txt" && file.status == FileStatusKind::Added
}));
assert!(comparison.patch.contains("only on feature"));
}
#[test]
fn compare_commits_includes_full_file_context() {
let repo = init_temp_repo("compare_full_context");
@@ -7295,6 +7413,54 @@ mod tests {
);
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)]
fn rename_remote_branch_moves_the_remote_ref_atomically() {
let repo = init_temp_repo("rename_remote_branch");
let remote = init_bare_temp_repo("rename_remote_branch_remote");
commit_initial_file(&repo.path);
let commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let remote_url = format!(
"file:///{}",
remote.path.to_string_lossy().replace('\\', "/")
);
run_git_test(&repo.path, ["remote", "add", "origin", remote_url.as_str()]);
run_git_test(
&repo.path,
["push", "-q", "origin", "HEAD:refs/heads/feature/old-name"],
);
run_git_test(&repo.path, ["fetch", "-q", "origin"]);
rename_remote_branch_core(
repo.path.to_string_lossy().to_string(),
"origin".to_string(),
"feature/old-name".to_string(),
"feature/new-name".to_string(),
)
.unwrap();
assert!(
!ref_exists(&remote.path, "refs/heads/feature/old-name").unwrap(),
"old remote branch should be gone"
);
assert_eq!(
git_output_test(&remote.path, ["rev-parse", "refs/heads/feature/new-name"]),
commit
);
assert!(
!ref_exists(&repo.path, "refs/remotes/origin/feature/old-name").unwrap(),
"old remote-tracking branch should be gone"
);
assert!(
ref_exists(&repo.path, "refs/remotes/origin/feature/new-name").unwrap(),
"new remote-tracking branch should exist"
);
}
#[test]
fn delete_branch_removes_local_branch_but_rejects_current_branch() {
let repo = init_temp_repo("delete_branch");
+7 -5
View File
@@ -24,11 +24,12 @@ use git::{
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
repair_worktree, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream,
set_commit_note, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote,
};
use tauri::Manager;
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
@@ -144,6 +145,7 @@ async fn main() {
checkout_branch,
create_branch,
rename_branch,
rename_remote_branch,
delete_branch,
list_worktrees,
add_worktree,