Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <remote>** (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 <remote> --delete refs/heads/<branch>`,
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/<remote>/<branch>` 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
Expand Down
10 changes: 8 additions & 2 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <remote> [--delete] refs/tags/<tag>` and
`git push <remote> --tags`, shelled out + streamed like the other net ops)
- ☑ Delete a branch on a remote (`Repo::delete_remote_branch` —
`git push <remote> --delete refs/heads/<branch>`, shelled out + streamed; the
push drops the local `refs/remotes/<remote>/<branch>` 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)
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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 <remote>" (confirm,
danger) → `deleteRemoteBranch` → `git push <remote> --delete`.
- ☑ Tags list (folder tree). Click checks out the tagged commit (detached HEAD
via `checkoutCommit`); right-click menu = Checkout / Push to <remote> /
Delete on <remote> (confirm) / Delete tag (confirm) — the two remote items
Expand Down
17 changes: 17 additions & 0 deletions crates/strand-core/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ impl Repo {
run_git_streaming(&self.path, &args, on_progress, cancel)
}

/// Delete `branch` on `remote` (`git push <remote> --delete refs/heads/<branch>`).
/// The push also drops the local `refs/remotes/<remote>/<branch>` 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<NetworkOutcome> {
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 <remote> [--delete] refs/tags/<tag>`). Tags are local until
/// pushed, and a tag deleted locally stays on the remote until deleted
Expand Down
18 changes: 18 additions & 0 deletions crates/strand-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Progress>,
) -> CmdResult<NetworkOutcome> {
tokio::task::spawn_blocking(move || -> CmdResult<NetworkOutcome> {
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)?;
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
};

Expand Down
12 changes: 12 additions & 0 deletions ui/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,18 @@ export const tauri = {
invoke<void>('repo_branch_delete', { path, name, force }),
repoBranchRename: (path: string, oldName: string, newName: string) =>
invoke<void>('repo_branch_rename', { path, oldName, newName }),
repoBranchDeleteRemote: (
path: string,
remote: string,
branch: string,
onProgress?: (p: Progress) => void,
) =>
invoke<NetworkOutcome>('repo_branch_delete_remote', {
path,
remote,
branch,
onEvent: progressChannel(onProgress),
}),
repoRemoteAdd: (path: string, name: string, url: string) =>
invoke<void>('repo_remote_add', { path, name, url }),
repoRemoteRemove: (path: string, name: string) =>
Expand Down
15 changes: 15 additions & 0 deletions ui/src/stores/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,12 @@ export interface RepoState {
checkoutCommit(rev: string): Promise<void>;
createBranch(name: string, startPoint: string | null, checkout: boolean): Promise<void>;
deleteBranch(name: string, force: boolean): Promise<void>;
/**
* Delete a branch on its remote (`git push <remote> --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<string>;
/** Rename a local branch; its upstream config moves along, HEAD follows. */
renameBranch(oldName: string, newName: string): Promise<void>;

Expand Down Expand Up @@ -1418,6 +1424,15 @@ export const useRepo = create<RepoState>((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');
Expand Down
Loading