diff --git a/CHANGELOG.md b/CHANGELOG.md
index 569e4f2..9c70139 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Delete a worktree, its branch, and the remote branch — from the branch-folder chooser.** When
+ branch-only mode locates a **linked worktree** on a branch, the **"Open from branch folder"** dialog now
+ offers a **Delete worktree & branch** button beside the open choices (and it's reachable even when there's
+ nothing to open — a folder-only editor, or a worktree with no solution file). Clicking it shows a
+ confirmation dialog with a **checkbox for each present target** — the worktree, its local branch, and the
+ branch on `origin` — **ticked by default**, so you can untick any to keep it (keeping the worktree disables
+ deleting its branch, since a checked-out branch can't be removed). The dialog adds **explicit data-loss
+ warnings** when the worktree has **uncommitted changes** or the branch carries **commits that exist nowhere
+ else** (unpushed and unmerged — `git branch -D` would orphan them). Once confirmed, Fido carries out exactly
+ the ticked targets — **removing the linked worktree, deleting the local branch, and deleting the branch on
+ `origin`**. The work runs from the clone's main tree (so the worktree is dropped cleanly),
+ a dirty worktree is force-removed after the warning, and a failed remote delete leaves the completed local
+ cleanup in place and reports it. The button is offered **only for a linked worktree on a non-default branch**
+ — the clone's main working tree can't be worktree-removed, and `main`/`master` are deliberately never
+ offered. Nothing is deleted unless you confirm; Cancel, Enter, and Esc all back out safely, and the
+ destructive button is out of the keyboard tab order so it can't be triggered by a stray keypress.
+
- **Open the folder in a console or the file explorer.** Two new built-in open targets sit alongside the
editors: **Console** (slug `term`) opens a terminal at the resolved folder, and **File Explorer** (slug
`files`) reveals it in the OS file manager. Both work on **Windows, macOS, and Linux** — Console finds
diff --git a/Docs/Features.md b/Docs/Features.md
index 710d308..2743cb5 100644
--- a/Docs/Features.md
+++ b/Docs/Features.md
@@ -41,7 +41,8 @@ isn't on disk yet.
Enter just a **branch**. Fido first scans your roots for a working tree that is **currently
on that branch**, then lists the solution files in that folder (plus an "open the folder"
-option) for you to pick.
+option) for you to pick. When that folder is a **linked worktree**, the same dialog also offers to
+**delete it** — see [Deleting a worktree](#deleting-a-worktree).
If the branch isn't checked out anywhere, Fido falls back to your **new-branch repos** — the
repositories you tick in Settings. It keeps only those whose refs actually contain the branch
@@ -105,7 +106,34 @@ When a choice is needed, Fido shows a keyboard-navigable list with rich, two-lin
the **short HEAD commit**. On GitHub remotes the commit is a **clickable link** to the commit
page (plain text for other remotes) — handy for spotting when two checkouts have diverged.
- **Pick what to open** (branch-only mode): the folder's solutions, plus an "open this folder"
- entry.
+ entry. When the folder is a **linked worktree**, a **Delete worktree & branch** button appears too
+ (see [Deleting a worktree](#deleting-a-worktree)).
+
+### Deleting a worktree
+
+In branch-only mode, once Fido has located a **linked worktree** on the branch, the **"Open from branch
+folder"** chooser adds a **Delete worktree & branch** button alongside the open choices — reachable even when
+there's nothing to open (a folder-only editor, or a worktree with no solution file). It's a shortcut for
+tidying up a branch you're finished with, in one step:
+
+- A **confirmation dialog** offers a **checkbox for each present target** — the **worktree**, its **local
+ branch**, and the **branch on `origin`** — each **ticked by default**. Untick any to keep it, so you can (say)
+ drop just the remote branch, or remove the worktree while keeping its branches. Because a branch that stays
+ checked out can't be deleted, **keeping the worktree disables deleting its local branch**. The dialog adds
+ explicit **data-loss warnings** when the worktree has **uncommitted changes**, or when the branch carries
+ **commits that exist nowhere else** — unpushed and unmerged work that a force-delete would orphan. Nothing
+ happens unless you click **Delete** (disabled when nothing is ticked); **Cancel**, **Enter**, and **Esc** all
+ back out, and the destructive button sits outside the keyboard tab order so it can't be fired by a stray keypress.
+- On confirmation Fido carries out **exactly the ticked targets**: it **removes the linked worktree**,
+ **deletes the local branch**, and — when it exists — **deletes the branch on `origin`**. The git steps run
+ from the clone's **main working tree**, so the worktree is dropped cleanly; a dirty worktree is
+ force-removed after the warning.
+- If the remote delete fails (say you're offline), the completed **local** cleanup stays done and the
+ failure is reported in the flight log rather than rolled back.
+
+The button is offered **only for a linked worktree on a non-default branch** — a clone's **main working
+tree** can't be worktree-removed, and the default branches (`main`/`master`) are deliberately never offered
+for deletion. In those cases the button is hidden and a normal open proceeds.
### What gets opened: solution or folder
diff --git a/Docs/screenshots/README.md b/Docs/screenshots/README.md
index 340c10f..b3e8c7d 100644
--- a/Docs/screenshots/README.md
+++ b/Docs/screenshots/README.md
@@ -45,6 +45,40 @@ out with **Esc**.
---
+## Delete a worktree
+
+For a **linked worktree** on a non-default branch, the branch-folder chooser adds a **Delete worktree &
+branch** button beside the open choices.
+
+
+
+ | Dark |
+ Light |
+
+
+  |
+  |
+
+
+
+Clicking it opens a confirmation dialog with a **checkbox for each present target** — the worktree, its
+local branch, and the branch on `origin` — ticked by default. Untick any to keep it (keeping the worktree
+disables deleting its branch), and Fido warns in red about uncommitted changes or commits that exist only on
+the branch.
+
+
+
+ | Dark |
+ Light |
+
+
+  |
+  |
+
+
+
+---
+
## Settings
Configure **search roots**, your **editors** (each with a CLI slug, with the default marked
diff --git a/Docs/screenshots/delete-worktree-dialog-dark.png b/Docs/screenshots/delete-worktree-dialog-dark.png
new file mode 100644
index 0000000..aea7ca0
Binary files /dev/null and b/Docs/screenshots/delete-worktree-dialog-dark.png differ
diff --git a/Docs/screenshots/delete-worktree-dialog-light.png b/Docs/screenshots/delete-worktree-dialog-light.png
new file mode 100644
index 0000000..1396ba8
Binary files /dev/null and b/Docs/screenshots/delete-worktree-dialog-light.png differ
diff --git a/Docs/screenshots/open-dialog-delete-dark.png b/Docs/screenshots/open-dialog-delete-dark.png
new file mode 100644
index 0000000..54250b5
Binary files /dev/null and b/Docs/screenshots/open-dialog-delete-dark.png differ
diff --git a/Docs/screenshots/open-dialog-delete-light.png b/Docs/screenshots/open-dialog-delete-light.png
new file mode 100644
index 0000000..e52c0de
Binary files /dev/null and b/Docs/screenshots/open-dialog-delete-light.png differ
diff --git a/src/Models/WorktreeDeletion.cs b/src/Models/WorktreeDeletion.cs
new file mode 100644
index 0000000..7d5aa8d
--- /dev/null
+++ b/src/Models/WorktreeDeletion.cs
@@ -0,0 +1,28 @@
+namespace Fido.Models;
+
+///
+/// What a "delete this worktree" action removes: the linked worktree folder, its local branch, and —
+/// when it exists — the branch on origin. Built from the located worktree in branch-only mode,
+/// it feeds the delete-confirmation dialog and the git steps that carry the deletion out.
+///
+/// The clone's main working tree — where the git commands run, so the
+/// linked worktree can be dropped without standing inside it.
+/// Absolute path of the linked worktree to remove.
+/// The local branch checked out in the worktree, deleted after the worktree is gone.
+/// True when origin/<Branch> exists and should be deleted too.
+/// Uncommitted changes in the worktree (porcelain lines); empty when clean.
+/// A dirty worktree needs a forced removal and would lose these — the dialog warns about it.
+/// Commits that live only on this branch — not pushed, not merged, not on any
+/// other ref — and so would be lost when the branch is force-deleted. The dialog warns when this is above 0,
+/// since neither an uncommitted-changes warning nor "not on origin" would otherwise flag the loss.
+public sealed record WorktreeDeletion(
+ string MainWorktreePath,
+ string WorktreePath,
+ string Branch,
+ bool RemoteBranchExists,
+ IReadOnlyList OutstandingChanges,
+ int OrphanedCommits)
+{
+ public bool HasOutstandingChanges => OutstandingChanges.Count > 0;
+ public bool HasOrphanedCommits => OrphanedCommits > 0;
+}
diff --git a/src/Models/WorktreeDeletionChoice.cs b/src/Models/WorktreeDeletionChoice.cs
new file mode 100644
index 0000000..8a36eb5
--- /dev/null
+++ b/src/Models/WorktreeDeletionChoice.cs
@@ -0,0 +1,25 @@
+namespace Fido.Models;
+
+///
+/// Which parts of a located worktree the user chose to delete in the confirmation dialog. Each is ticked by
+/// default (when the target is present) and can be unticked to keep it. Deleting the local branch requires
+/// removing the worktree first — a checked-out branch can't be deleted — so the dialog keeps those coupled.
+///
+public sealed record WorktreeDeletionChoice(bool Worktree, bool LocalBranch, bool RemoteBranch)
+{
+ /// True when at least one target is selected — otherwise the delete would be a no-op.
+ public bool AnySelected => Worktree || LocalBranch || RemoteBranch;
+
+ /// Everything ticked — the default when all three targets are present.
+ public static WorktreeDeletionChoice All { get; } = new(true, true, true);
+}
+
+/// What a delete actually removed, so the caller can report it accurately.
+public sealed record WorktreeDeletionOutcome(
+ bool WorktreeRemoved,
+ bool LocalBranchDeleted,
+ bool RemoteBranchDeleted,
+ bool RemoteDeleteFailed)
+{
+ public bool AnyDeleted => WorktreeRemoved || LocalBranchDeleted || RemoteBranchDeleted;
+}
diff --git a/src/Services/AvaloniaDialogService.cs b/src/Services/AvaloniaDialogService.cs
index 889e660..cdcca8a 100644
--- a/src/Services/AvaloniaDialogService.cs
+++ b/src/Services/AvaloniaDialogService.cs
@@ -12,8 +12,11 @@ public sealed class AvaloniaDialogService : IDialogService
public AvaloniaDialogService(Window owner) => _owner = owner;
- public Task ShowChooserAsync(string title, string prompt, IReadOnlyList items)
- => new ChooserDialog(title, prompt, items).ShowDialog(_owner);
+ public Task ShowChooserAsync(string title, string prompt, IReadOnlyList items, string? deleteLabel = null)
+ => new ChooserDialog(title, prompt, items, deleteLabel).ShowDialog(_owner);
+
+ public Task ConfirmDeleteWorktreeAsync(WorktreeDeletion plan)
+ => new DeleteWorktreeDialog(plan).ShowDialog(_owner);
public Task ShowDecisionAsync(RepositoryInfo repo, string branch, MainContext context)
=> new DecisionDialog(repo, branch, context).ShowDialog(_owner);
diff --git a/src/Services/GitService.cs b/src/Services/GitService.cs
index dcfebb2..df96909 100644
--- a/src/Services/GitService.cs
+++ b/src/Services/GitService.cs
@@ -169,6 +169,60 @@ public Task WorktreeAddNewAsync(string dir, string path, string b
? Git(dir, ct, "worktree", "add", "-b", branch, path)
: Git(dir, ct, "worktree", "add", "-b", branch, path, startPoint);
+ // --- Worktree / branch deletion -----------------------------------------------------
+
+ ///
+ /// True when is a linked worktree rather than the clone's main tree.
+ /// Compares the worktree's own git dir with the shared common git dir — identical for the main tree, but
+ /// a linked worktree's git dir is …/worktrees/<name>. It never compares working-tree paths, so
+ /// it stays correct under a symlinked search root that would defeat a string path comparison. False on any
+ /// git error (so the destructive delete action is withheld when the topology can't be established).
+ ///
+ public async Task IsLinkedWorktreeAsync(string dir, CancellationToken ct = default)
+ {
+ var r = await Git(dir, ct, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir");
+ if (!r.Success) return false;
+ var lines = r.StdOut.Split('\n').Select(l => l.TrimEnd('\r')).Where(l => l.Length > 0).ToArray();
+ return lines.Length >= 2 && !string.Equals(lines[0], lines[1], StringComparison.Ordinal);
+ }
+
+ ///
+ /// Counts commits reachable from but from no other ref — no other local branch,
+ /// no remote-tracking branch, no tag. These are the commits that would be orphaned (lost to normal
+ /// use) if the branch were force-deleted: unpushed, unmerged work that exists nowhere else. Zero when the
+ /// branch is fully pushed or already merged. Returns 0 on any git error — it drives an advisory warning,
+ /// not a gate.
+ ///
+ public async Task CountOrphanedCommitsAsync(string dir, string branch, CancellationToken ct = default)
+ {
+ var r = await Git(dir, ct, "rev-list", "--count", branch,
+ "--not", "--exclude=" + branch, "--branches", "--tags", "--remotes");
+ return r.Success && int.TryParse(r.StdOut.Trim(), out var n) ? n : 0;
+ }
+
+ ///
+ /// Removes the linked worktree at . Run from the clone's main tree
+ /// so it can drop a worktree it isn't standing in. A clean worktree removes without
+ /// ; a dirty one (uncommitted or untracked files) needs it, and forcing
+ /// discards those changes.
+ ///
+ public Task WorktreeRemoveAsync(string dir, string worktreePath, bool force, CancellationToken ct = default)
+ => force
+ ? Git(dir, ct, "worktree", "remove", "--force", worktreePath)
+ : Git(dir, ct, "worktree", "remove", worktreePath);
+
+ ///
+ /// Force-deletes the local branch (git branch -D) — used once its worktree is gone, so the
+ /// branch is no longer checked out. -D deletes even when the branch isn't merged, matching the
+ /// user's explicit intent to remove it.
+ ///
+ public Task DeleteLocalBranchAsync(string dir, string branch, CancellationToken ct = default)
+ => Git(dir, ct, "branch", "-D", branch);
+
+ /// Deletes the branch on origin (git push origin --delete <branch>).
+ public Task DeleteRemoteBranchAsync(string dir, string branch, CancellationToken ct = default)
+ => Git(dir, ct, "push", "origin", "--delete", branch);
+
private static string StripRefsHeads(string reference)
=> reference.StartsWith(RefsHeads, StringComparison.Ordinal) ? reference[RefsHeads.Length..] : reference;
}
diff --git a/src/Services/IDialogService.cs b/src/Services/IDialogService.cs
index b5d3beb..21dfd01 100644
--- a/src/Services/IDialogService.cs
+++ b/src/Services/IDialogService.cs
@@ -10,8 +10,18 @@ namespace Fido.Services;
///
public interface IDialogService
{
- /// Single-select chooser; returns the chosen index, or null if cancelled.
- Task ShowChooserAsync(string title, string prompt, IReadOnlyList items);
+ ///
+ /// Single-select chooser; returns the chosen index, or null if cancelled. When
+ /// is supplied the dialog also shows a destructive action button, and a
+ /// result of means the user chose it.
+ ///
+ Task ShowChooserAsync(string title, string prompt, IReadOnlyList items, string? deleteLabel = null);
+
+ ///
+ /// Confirms the destructive delete of a located worktree. Returns the user's per-target selection (which
+ /// of the worktree, local branch, and origin branch to delete), or null if they backed out.
+ ///
+ Task ConfirmDeleteWorktreeAsync(WorktreeDeletion plan);
/// Branch-not-checked-out decision; returns the chosen action, or null if dismissed.
Task ShowDecisionAsync(RepositoryInfo repo, string branch, MainContext context);
diff --git a/src/Services/OpenerService.cs b/src/Services/OpenerService.cs
index 0a6a0f0..6f35b60 100644
--- a/src/Services/OpenerService.cs
+++ b/src/Services/OpenerService.cs
@@ -363,6 +363,94 @@ public async Task CreateWorktreeAsync(RepositoryInfo repo, string branch
return path;
}
+ // --- Worktree deletion --------------------------------------------------------------
+
+ ///
+ /// True when is a linked worktree (not its clone's main working
+ /// tree). Only a linked worktree can be removed with git worktree remove, so this gates whether
+ /// the branch-folder chooser offers the delete action. Cheap, offline, and symlink-proof (it compares
+ /// git dirs, not paths — see ).
+ ///
+ public Task IsLinkedWorktreeAsync(string folder, CancellationToken ct = default)
+ => _git.IsLinkedWorktreeAsync(folder, ct);
+
+ ///
+ /// Gathers what a "delete this worktree" action would remove: the clone's main tree (where the git steps
+ /// run), whether the branch is on origin (cached tracking ref, then a live check for a branch never
+ /// fetched), any outstanding changes in the worktree, and how many commits live only on the branch (so the
+ /// dialog can warn about losing unpushed, unmerged work). Returns null when is
+ /// the clone's main tree — that can't be removed as a worktree.
+ ///
+ public async Task BuildWorktreeDeletionAsync(string folder, string branch, CancellationToken ct = default)
+ {
+ if (!await _git.IsLinkedWorktreeAsync(folder, ct))
+ return null;
+
+ var full = Path.GetFullPath(folder);
+ var worktrees = await _git.ListWorktreesAsync(folder, ct);
+ var mainPath = Path.GetFullPath(worktrees.FirstOrDefault(w => w.IsMain)?.Path ?? folder);
+
+ var remoteExists = await _git.RemoteBranchExistsAsync(mainPath, branch, ct)
+ || await _git.RemoteHasBranchAsync(mainPath, branch, ct);
+ var changes = await _git.GetStatusAsync(full, ct);
+ var orphaned = await _git.CountOrphanedCommitsAsync(mainPath, branch, ct);
+ return new WorktreeDeletion(mainPath, full, branch, remoteExists, changes, orphaned);
+ }
+
+ ///
+ /// Carries out a limited to the targets the user ticked in
+ /// : removes the worktree (forcing when it's dirty), deletes the local branch, and
+ /// deletes the branch on origin — each only when selected (and the origin branch only when it exists).
+ /// Runs from the clone's main tree. A failed worktree removal or local-branch delete throws (nothing has
+ /// been lost yet, or the local cleanup couldn't proceed); a failed remote delete is logged and
+ /// reflected in the returned outcome rather than throwing, because any local cleanup is already done and
+ /// re-running wouldn't undo it.
+ ///
+ public async Task DeleteWorktreeAsync(
+ WorktreeDeletion plan, WorktreeDeletionChoice choice, CancellationToken ct = default)
+ {
+ var dir = plan.MainWorktreePath;
+ bool worktreeRemoved = false, localDeleted = false, remoteDeleted = false, remoteFailed = false;
+
+ if (choice.Worktree)
+ {
+ _log($"Removing worktree at {plan.WorktreePath}…");
+ var remove = await _git.WorktreeRemoveAsync(dir, plan.WorktreePath, force: plan.HasOutstandingChanges, ct);
+ if (!remove.Success)
+ throw new InvalidOperationException($"git worktree remove failed: {remove.Message}");
+ _log("Worktree removed.");
+ worktreeRemoved = true;
+ }
+
+ if (choice.LocalBranch)
+ {
+ _log($"Deleting local branch '{plan.Branch}'…");
+ var branchResult = await _git.DeleteLocalBranchAsync(dir, plan.Branch, ct);
+ if (!branchResult.Success)
+ throw new InvalidOperationException($"git branch -D failed: {branchResult.Message}");
+ _log($"Local branch '{plan.Branch}' deleted.");
+ localDeleted = true;
+ }
+
+ if (choice.RemoteBranch && plan.RemoteBranchExists)
+ {
+ _log($"Deleting remote branch origin/{plan.Branch}…");
+ var remoteResult = await _git.DeleteRemoteBranchAsync(dir, plan.Branch, ct);
+ if (remoteResult.Success)
+ {
+ _log($"Remote branch origin/{plan.Branch} deleted.");
+ remoteDeleted = true;
+ }
+ else
+ {
+ _log($"[!] Remote branch origin/{plan.Branch} could not be deleted: {remoteResult.Message}");
+ remoteFailed = true;
+ }
+ }
+
+ return new WorktreeDeletionOutcome(worktreeRemoved, localDeleted, remoteDeleted, remoteFailed);
+ }
+
///
/// Fetches origin/<branch> into the clone so a tracking branch or worktree can be created from
/// a branch that exists on the remote but hadn't been fetched yet (see ).
diff --git a/src/Theme/FidoStyles.axaml b/src/Theme/FidoStyles.axaml
index 4efa67e..b5771d0 100644
--- a/src/Theme/FidoStyles.axaml
+++ b/src/Theme/FidoStyles.axaml
@@ -118,6 +118,24 @@
+
+
+
+
+
+
+
+
+
+
+
+