From 440904abcec039b3e85193a71c2247682363771c Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Tue, 23 Jun 2026 20:04:40 +0200 Subject: [PATCH] feat(branch): delete a branch on its remote Adds a "Delete branch on " action to the sidebar remote-branch menu, closing the gap where a branch could be pruned locally but not on origin. Core `Repo::delete_remote_branch` runs `git push --delete refs/heads/`, shelled out and streamed through the existing `run_git_streaming` (credentials/progress free), mirroring the tag-delete path. The ref is fully-qualified behind a `--` so a stray name cannot be read as a git option. The push also drops the local remote-tracking ref, so a refs refresh clears the sidebar row with no optimistic bookkeeping. Wires `repo_branch_delete_remote` IPC + `repoBranchDeleteRemote` wrapper + `deleteRemoteBranch` store action through to the confirm-gated menu item. Verified with cargo check, tsc, and an end-to-end bare-remote run. Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 14 ++++++++++++++ TASKS.md | 10 ++++++++-- crates/strand-core/src/network.rs | 17 +++++++++++++++++ crates/strand-tauri/src/commands.rs | 18 ++++++++++++++++++ crates/strand-tauri/src/main.rs | 1 + ui/src/components/Sidebar.tsx | 19 +++++++++++++++++++ ui/src/lib/tauri.ts | 12 ++++++++++++ ui/src/stores/repo.ts | 15 +++++++++++++++ 8 files changed, 104 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9586fc0..9f4aa26 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -995,6 +995,20 @@ band (the graph's hot scroll path stays cheap); the rail is `aria-hidden` (a redundant pointer nav aid over arrow-key list navigation + search). Verified with `tsc` + `vite build`. +**Delete a remote branch (2026-06-23):** The sidebar remote-branch menu gained +**Delete branch on ** (confirm, danger), closing the gap where a branch +could be pruned locally but not on `origin`. `network.rs` adds +`Repo::delete_remote_branch` — `git push --delete refs/heads/`, +shelled out + streamed through `run_git_streaming` like the tag-delete sibling +(credentials/progress free); the ref is fully-qualified to `refs/heads/` behind a +`--` so a stray name can't be read as an option. The push also drops the local +`refs/remotes//` tracking ref, so the `refreshRefs` after a +successful delete clears the row with no optimistic bookkeeping. New +`repo_branch_delete_remote` async IPC + `repoBranchDeleteRemote` wrapper + +`deleteRemoteBranch(remote, branch)` store action. Verified with `cargo check`, +`tsc`, and an end-to-end bare-remote run confirming both the remote branch and +the local tracking ref are removed. + --- ## 1.1+ — Post-1.0 diff --git a/TASKS.md b/TASKS.md index 01cd298..3b46c77 100644 --- a/TASKS.md +++ b/TASKS.md @@ -208,6 +208,10 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked - ☑ Push / delete tags on a remote (`Repo::push_tag` / `Repo::push_all_tags` — `git push [--delete] refs/tags/` and `git push --tags`, shelled out + streamed like the other net ops) +- ☑ Delete a branch on a remote (`Repo::delete_remote_branch` — + `git push --delete refs/heads/`, shelled out + streamed; the + push drops the local `refs/remotes//` tracking ref too, so a + refs refresh clears the sidebar row) - ☑ Remote tag listing (`Repo::remote_tags` via `git ls-remote --tags` — fetched tags share `refs/tags/`, so ls-remote is the only way to know which tags a remote has; used to gray out "delete on remote" for absent tags) @@ -237,7 +241,8 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked - ☑ Write commands: `repo_stage`, `repo_unstage`, `repo_stage_many`, `repo_unstage_many`, `repo_discard_many`, `repo_discard`, `repo_commit`, `repo_checkout`, `repo_checkout_commit`, `repo_branch_create`, - `repo_branch_delete`, `repo_branch_rename`, `repo_remote_add`, + `repo_branch_delete`, `repo_branch_delete_remote`, `repo_branch_rename`, + `repo_remote_add`, `repo_remote_remove`, `repo_remote_rename`, `repo_remote_set_url`, `repo_reset`, `repo_gitignore_add`, `repo_tag_create`, `repo_tag_delete`, @@ -351,7 +356,8 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked their local branch (`tracked` meta tag, disabled "Tracked by current branch" when it's HEAD); untracked leaves — or the menu's "Create local branch & track" — create + track locally (name collision → `remote/branch` - local name) + local name). Leaf menu also has "Delete branch on " (confirm, + danger) → `deleteRemoteBranch` → `git push --delete`. - ☑ Tags list (folder tree). Click checks out the tagged commit (detached HEAD via `checkoutCommit`); right-click menu = Checkout / Push to / Delete on (confirm) / Delete tag (confirm) — the two remote items diff --git a/crates/strand-core/src/network.rs b/crates/strand-core/src/network.rs index f380afc..d7d9837 100644 --- a/crates/strand-core/src/network.rs +++ b/crates/strand-core/src/network.rs @@ -127,6 +127,23 @@ impl Repo { run_git_streaming(&self.path, &args, on_progress, cancel) } + /// Delete `branch` on `remote` (`git push --delete refs/heads/`). + /// The push also drops the local `refs/remotes//` tracking + /// ref, so a refs refresh afterward reflects the deletion on both sides. The + /// ref is fully-qualified to `refs/heads/` (plus a `--` separator) so a stray + /// branch name can never be read as a git option. + pub fn delete_remote_branch( + &self, + remote: &str, + branch: &str, + on_progress: impl FnMut(Progress), + ) -> Result { + validate_remote_arg(remote, "remote")?; + let refspec = format!("refs/heads/{branch}"); + let args = vec!["push", "--progress", "--delete", "--", remote, refspec.as_str()]; + run_git_streaming(&self.path, &args, on_progress, None) + } + /// Push a single tag to `remote`, or delete it there when `delete` is set /// (`git push [--delete] refs/tags/`). Tags are local until /// pushed, and a tag deleted locally stays on the remote until deleted diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 8b04c29..b8c33a5 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -524,6 +524,24 @@ pub fn repo_branch_rename(path: String, old_name: String, new_name: String) -> C Ok(()) } +#[tauri::command(async)] +pub async fn repo_branch_delete_remote( + path: String, + remote: String, + branch: String, + on_event: Channel, +) -> CmdResult { + tokio::task::spawn_blocking(move || -> CmdResult { + let repo = Repo::discover(&path)?; + repo.delete_remote_branch(&remote, &branch, |p| { + let _ = on_event.send(p); + }) + .map_err(CmdError::from) + }) + .await + .map_err(|e| CmdError { message: format!("branch delete task failed: {e}") })? +} + #[tauri::command(async)] pub fn repo_remote_add(path: String, name: String, url: String) -> CmdResult<()> { Repo::discover(&path)?.add_remote(&name, &url)?; diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 4d09d59..90746c5 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -103,6 +103,7 @@ fn main() { commands::repo_branch_create, commands::repo_branch_delete, commands::repo_branch_rename, + commands::repo_branch_delete_remote, commands::repo_remote_add, commands::repo_remote_remove, commands::repo_remote_rename, diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 61e873c..9100654 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -138,6 +138,7 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, const revealInGraph = useRepo((s) => s.revealInGraph); const createBranch = useRepo((s) => s.createBranch); const deleteBranch = useRepo((s) => s.deleteBranch); + const deleteRemoteBranch = useRepo((s) => s.deleteRemoteBranch); const removeRemote = useRepo((s) => s.removeRemote); const fetchRemote = useRepo((s) => s.fetchRemote); const deleteTag = useRepo((s) => s.deleteTag); @@ -378,6 +379,17 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, })(); }; + const runRemoteBranchDelete = (rb: RemoteBranch) => { + void (async () => { + try { + await deleteRemoteBranch(rb.remote, rb.branch); + onToast(`Deleted ${rb.branch} on ${rb.remote}`); + } catch (e) { + onToast(`Remote delete failed: ${errMessage(e)}`); + } + })(); + }; + // ── per-row menus — every action a row supports lives here ── const localBranchName = (rb: RemoteBranch) => refs.branches.some((b) => b.name === rb.branch) ? `${rb.remote}/${rb.branch}` : rb.branch; @@ -463,6 +475,13 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, icon: 'plus', onSelect: () => onCreateBranch(rb.name, rb.name), }); + items.push({ + label: `Delete branch on ${rb.remote}`, + icon: 'trash', + danger: true, + confirm: true, + onSelect: () => runRemoteBranchDelete(rb), + }); return items; }; diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 3bf17b8..0c0e034 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -191,6 +191,18 @@ export const tauri = { invoke('repo_branch_delete', { path, name, force }), repoBranchRename: (path: string, oldName: string, newName: string) => invoke('repo_branch_rename', { path, oldName, newName }), + repoBranchDeleteRemote: ( + path: string, + remote: string, + branch: string, + onProgress?: (p: Progress) => void, + ) => + invoke('repo_branch_delete_remote', { + path, + remote, + branch, + onEvent: progressChannel(onProgress), + }), repoRemoteAdd: (path: string, name: string, url: string) => invoke('repo_remote_add', { path, name, url }), repoRemoteRemove: (path: string, name: string) => diff --git a/ui/src/stores/repo.ts b/ui/src/stores/repo.ts index 2dd0384..d2fd79c 100644 --- a/ui/src/stores/repo.ts +++ b/ui/src/stores/repo.ts @@ -365,6 +365,12 @@ export interface RepoState { checkoutCommit(rev: string): Promise; createBranch(name: string, startPoint: string | null, checkout: boolean): Promise; deleteBranch(name: string, force: boolean): Promise; + /** + * Delete a branch on its remote (`git push --delete`). The push also + * drops the local remote-tracking ref, so refs refresh afterward. Returns + * git's output. + */ + deleteRemoteBranch(remote: string, branch: string, onProgress?: (p: Progress) => void): Promise; /** Rename a local branch; its upstream config moves along, HEAD follows. */ renameBranch(oldName: string, newName: string): Promise; @@ -1418,6 +1424,15 @@ export const useRepo = create((set, get) => ({ await tauri.repoBranchDelete(path, name, force); await get().refreshRefs(); }, + async deleteRemoteBranch(remote, branch, onProgress) { + const path = get().activePath; + if (!path) throw new Error('no repo open'); + const res = await tauri.repoBranchDeleteRemote(path, remote, branch, onProgress); + // The push removes the local remote-tracking ref too, so the sidebar row + // disappears once refs reload. + await get().refreshRefs(); + return res.output; + }, async renameBranch(oldName, newName) { const path = get().activePath; if (!path) throw new Error('no repo open');