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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions Docs/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions Docs/screenshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<table>
<tr>
<td align="center"><strong>Dark</strong></td>
<td align="center"><strong>Light</strong></td>
</tr>
<tr>
<td><img src="open-dialog-delete-dark.png" alt="Branch-folder chooser with the delete button, dark theme" width="400"></td>
<td><img src="open-dialog-delete-light.png" alt="Branch-folder chooser with the delete button, light theme" width="400"></td>
</tr>
</table>

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.

<table>
<tr>
<td align="center"><strong>Dark</strong></td>
<td align="center"><strong>Light</strong></td>
</tr>
<tr>
<td><img src="delete-worktree-dialog-dark.png" alt="Delete-worktree confirmation dialog, dark theme" width="400"></td>
<td><img src="delete-worktree-dialog-light.png" alt="Delete-worktree confirmation dialog, light theme" width="400"></td>
</tr>
</table>

---

## Settings

Configure **search roots**, your **editors** (each with a CLI slug, with the default marked
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Docs/screenshots/delete-worktree-dialog-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Docs/screenshots/open-dialog-delete-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Docs/screenshots/open-dialog-delete-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions src/Models/WorktreeDeletion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Fido.Models;

/// <summary>
/// What a "delete this worktree" action removes: the linked worktree folder, its local branch, and —
/// when it exists — the branch on <c>origin</c>. Built from the located worktree in branch-only mode,
/// it feeds the delete-confirmation dialog and the git steps that carry the deletion out.
/// </summary>
/// <param name="MainWorktreePath">The clone's main working tree — where the git commands run, so the
/// linked worktree can be dropped without standing inside it.</param>
/// <param name="WorktreePath">Absolute path of the linked worktree to remove.</param>
/// <param name="Branch">The local branch checked out in the worktree, deleted after the worktree is gone.</param>
/// <param name="RemoteBranchExists">True when <c>origin/&lt;Branch&gt;</c> exists and should be deleted too.</param>
/// <param name="OutstandingChanges">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.</param>
/// <param name="OrphanedCommits">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.</param>
public sealed record WorktreeDeletion(
string MainWorktreePath,
string WorktreePath,
string Branch,
bool RemoteBranchExists,
IReadOnlyList<string> OutstandingChanges,
int OrphanedCommits)
{
public bool HasOutstandingChanges => OutstandingChanges.Count > 0;
public bool HasOrphanedCommits => OrphanedCommits > 0;
}
25 changes: 25 additions & 0 deletions src/Models/WorktreeDeletionChoice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Fido.Models;

/// <summary>
/// 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.
/// </summary>
public sealed record WorktreeDeletionChoice(bool Worktree, bool LocalBranch, bool RemoteBranch)
{
/// <summary>True when at least one target is selected — otherwise the delete would be a no-op.</summary>
public bool AnySelected => Worktree || LocalBranch || RemoteBranch;

/// <summary>Everything ticked — the default when all three targets are present.</summary>
public static WorktreeDeletionChoice All { get; } = new(true, true, true);
}

/// <summary>What a delete actually removed, so the caller can report it accurately.</summary>
public sealed record WorktreeDeletionOutcome(
bool WorktreeRemoved,
bool LocalBranchDeleted,
bool RemoteBranchDeleted,
bool RemoteDeleteFailed)
{
public bool AnyDeleted => WorktreeRemoved || LocalBranchDeleted || RemoteBranchDeleted;
}
7 changes: 5 additions & 2 deletions src/Services/AvaloniaDialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ public sealed class AvaloniaDialogService : IDialogService

public AvaloniaDialogService(Window owner) => _owner = owner;

public Task<int?> ShowChooserAsync(string title, string prompt, IReadOnlyList<ChooserItem> items)
=> new ChooserDialog(title, prompt, items).ShowDialog<int?>(_owner);
public Task<int?> ShowChooserAsync(string title, string prompt, IReadOnlyList<ChooserItem> items, string? deleteLabel = null)
=> new ChooserDialog(title, prompt, items, deleteLabel).ShowDialog<int?>(_owner);

public Task<WorktreeDeletionChoice?> ConfirmDeleteWorktreeAsync(WorktreeDeletion plan)
=> new DeleteWorktreeDialog(plan).ShowDialog<WorktreeDeletionChoice?>(_owner);

public Task<OpenDecision?> ShowDecisionAsync(RepositoryInfo repo, string branch, MainContext context)
=> new DecisionDialog(repo, branch, context).ShowDialog<OpenDecision?>(_owner);
Expand Down
54 changes: 54 additions & 0 deletions src/Services/GitService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,60 @@ public Task<ProcessResult> 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 -----------------------------------------------------

/// <summary>
/// True when <paramref name="dir"/> is a <em>linked</em> 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 <c>…/worktrees/&lt;name&gt;</c>. 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).
/// </summary>
public async Task<bool> 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);
}

/// <summary>
/// Counts commits reachable from <paramref name="branch"/> but from no other ref — no other local branch,
/// no remote-tracking branch, no tag. These are the commits that would be <em>orphaned</em> (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.
/// </summary>
public async Task<int> 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;
}

/// <summary>
/// Removes the linked worktree at <paramref name="worktreePath"/>. Run from the clone's main tree
/// so it can drop a worktree it isn't standing in. A clean worktree removes without
/// <paramref name="force"/>; a dirty one (uncommitted or untracked files) needs it, and forcing
/// discards those changes.
/// </summary>
public Task<ProcessResult> WorktreeRemoveAsync(string dir, string worktreePath, bool force, CancellationToken ct = default)
=> force
? Git(dir, ct, "worktree", "remove", "--force", worktreePath)
: Git(dir, ct, "worktree", "remove", worktreePath);

/// <summary>
/// Force-deletes the local branch (<c>git branch -D</c>) — used once its worktree is gone, so the
/// branch is no longer checked out. <c>-D</c> deletes even when the branch isn't merged, matching the
/// user's explicit intent to remove it.
/// </summary>
public Task<ProcessResult> DeleteLocalBranchAsync(string dir, string branch, CancellationToken ct = default)
=> Git(dir, ct, "branch", "-D", branch);

/// <summary>Deletes the branch on <c>origin</c> (<c>git push origin --delete &lt;branch&gt;</c>).</summary>
public Task<ProcessResult> 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;
}
14 changes: 12 additions & 2 deletions src/Services/IDialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,18 @@ namespace Fido.Services;
/// </summary>
public interface IDialogService
{
/// <summary>Single-select chooser; returns the chosen index, or null if cancelled.</summary>
Task<int?> ShowChooserAsync(string title, string prompt, IReadOnlyList<ChooserItem> items);
/// <summary>
/// Single-select chooser; returns the chosen index, or null if cancelled. When
/// <paramref name="deleteLabel"/> is supplied the dialog also shows a destructive action button, and a
/// result of <see cref="Views.ChooserDialog.DeleteRequested"/> means the user chose it.
/// </summary>
Task<int?> ShowChooserAsync(string title, string prompt, IReadOnlyList<ChooserItem> items, string? deleteLabel = null);

/// <summary>
/// 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.
/// </summary>
Task<WorktreeDeletionChoice?> ConfirmDeleteWorktreeAsync(WorktreeDeletion plan);

/// <summary>Branch-not-checked-out decision; returns the chosen action, or null if dismissed.</summary>
Task<OpenDecision?> ShowDecisionAsync(RepositoryInfo repo, string branch, MainContext context);
Expand Down
Loading
Loading