feat(remote): support authenticated deletion of remote branches
Add support for authenticated deletion of remote branches and folders. The backend adds a batch delete command that queries the remote, performs an atomic push --delete when possible with a fallback, and prunes stale tracking refs; it accepts optional credentials. The frontend and JS API integrate credential handling and prompt users when authentication is required. - New batch deletion command with optional username/password for auth. - Uses ls-remote to scope deletions, tries --atomic then falls back. - Frontend updates: credential dialog/action, pendingRemoteDelete state.
This commit is contained in:
+119
-13
@@ -651,30 +651,136 @@ pub fn set_branch_upstream(
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_remote_branch(
|
||||
#[tauri::command]
|
||||
pub async fn delete_remote_branch(
|
||||
path: String,
|
||||
remote: String,
|
||||
branch: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}");
|
||||
let result = (|| {
|
||||
run_git_task("Could not delete remote branch", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let remote = validate_remote_name(&repo, &remote, true)?;
|
||||
delete_remote_branches_core(
|
||||
&repo,
|
||||
&remote,
|
||||
vec![branch],
|
||||
username.as_deref().zip(password.as_deref()),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_remote_branches(
|
||||
path: String,
|
||||
remote: String,
|
||||
branches: Vec<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not delete remote branch folder", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let remote = validate_remote_name(&repo, &remote, true)?;
|
||||
delete_remote_branches_core(
|
||||
&repo,
|
||||
&remote,
|
||||
branches,
|
||||
username.as_deref().zip(password.as_deref()),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn delete_remote_branches_core(
|
||||
repo: &Path,
|
||||
remote: &str,
|
||||
branches: Vec<String>,
|
||||
credentials: Option<(&str, &str)>,
|
||||
) -> Result<GitStatus, String> {
|
||||
let run_remote = |args: Vec<String>| match credentials {
|
||||
Some((user, pass)) if !user.is_empty() || !pass.is_empty() => {
|
||||
run_git_authenticated(repo, args, user, pass)
|
||||
}
|
||||
_ => run_git(repo, args),
|
||||
};
|
||||
|
||||
let mut branch_names = Vec::new();
|
||||
for branch in branches {
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() || branch.starts_with('-') {
|
||||
return Err("Invalid remote branch name.".to_string());
|
||||
}
|
||||
run_git(&repo, ["check-ref-format", "--branch", branch])?;
|
||||
log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}");
|
||||
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
|
||||
log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}");
|
||||
status_for_repo(&repo)
|
||||
})();
|
||||
if let Err(error) = &result {
|
||||
log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}");
|
||||
run_git(repo, ["check-ref-format", "--branch", branch])?;
|
||||
if !branch_names.iter().any(|existing| existing == branch) {
|
||||
branch_names.push(branch.to_string());
|
||||
}
|
||||
}
|
||||
result
|
||||
if branch_names.is_empty() {
|
||||
return Err("No remote branches were selected.".to_string());
|
||||
}
|
||||
|
||||
// Local remote-tracking refs can be stale when a branch was deleted by
|
||||
// another client. Query the server first so deletion remains idempotent.
|
||||
let mut query_args = vec![
|
||||
"ls-remote".to_string(),
|
||||
"--heads".to_string(),
|
||||
remote.to_string(),
|
||||
];
|
||||
query_args.extend(
|
||||
branch_names
|
||||
.iter()
|
||||
.map(|branch| format!("refs/heads/{branch}")),
|
||||
);
|
||||
let remote_refs = String::from_utf8_lossy(&run_remote(query_args)?).to_string();
|
||||
let existing_refs: BTreeSet<&str> = remote_refs
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once('\t').map(|(_, reference)| reference.trim()))
|
||||
.collect();
|
||||
branch_names.retain(|branch| existing_refs.contains(format!("refs/heads/{branch}").as_str()));
|
||||
|
||||
if !branch_names.is_empty() {
|
||||
let mut atomic_args = vec![
|
||||
"push".to_string(),
|
||||
"--atomic".to_string(),
|
||||
remote.to_string(),
|
||||
"--delete".to_string(),
|
||||
];
|
||||
atomic_args.extend(branch_names.iter().cloned());
|
||||
|
||||
if let Err(error) = run_remote(atomic_args) {
|
||||
let message = error.to_lowercase();
|
||||
let atomic_unsupported = message.contains("does not support --atomic")
|
||||
|| message.contains("atomic push is not supported")
|
||||
|| message.contains("does not support atomic push");
|
||||
if !atomic_unsupported {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
target: "gitty::remote",
|
||||
"remote {remote} does not support atomic pushes; retrying branch-folder deletion as one regular push"
|
||||
);
|
||||
let mut fallback_args = vec![
|
||||
"push".to_string(),
|
||||
remote.to_string(),
|
||||
"--delete".to_string(),
|
||||
];
|
||||
fallback_args.extend(branch_names);
|
||||
run_remote(fallback_args)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove stale tracking refs for branches that were already absent.
|
||||
if let Err(error) = run_remote(vec![
|
||||
"fetch".to_string(),
|
||||
"--prune".to_string(),
|
||||
remote.to_string(),
|
||||
]) {
|
||||
log::warn!(target: "gitty::remote", "remote branches were deleted, but tracking refs could not be pruned: {error}");
|
||||
}
|
||||
status_for_repo(repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -16,8 +16,8 @@ use git::{
|
||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||
delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note,
|
||||
get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
|
||||
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
|
||||
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
|
||||
last_commit_message, list_branches, list_commits, list_file_history,
|
||||
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
||||
@@ -141,6 +141,7 @@ async fn main() {
|
||||
remove_remote,
|
||||
set_branch_upstream,
|
||||
delete_remote_branch,
|
||||
delete_remote_branches,
|
||||
list_stashes,
|
||||
checkout_branch,
|
||||
create_branch,
|
||||
|
||||
Reference in New Issue
Block a user