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:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user