From 233c3d661819ca5386cdfe7b51f9d6ffd170f297 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 14:26:54 +0000 Subject: [PATCH 1/2] Add worktree/branch deletion to the branch-folder chooser When branch-only mode locates a linked worktree, the "Open from branch folder" dialog now offers a "Delete worktree & branch" button that removes the linked worktree, deletes the local branch, and deletes the branch on origin when present. The git steps run from the clone's main tree so the worktree is dropped cleanly; a dirty worktree is force-removed after a warning, and a failed remote delete leaves the completed local cleanup in place and reports it. A confirmation dialog spells out the worktree path, local branch, and origin state, with explicit data-loss warnings for uncommitted changes and for commits that exist only on the branch (unpushed/unmerged work that `git branch -D` would orphan). Safety: - Offered only for a linked worktree (main tree can't be worktree-removed), detected via git-dir comparison so it stays correct under symlinked roots. - Default branches (main/master) are never offered. - The destructive button is out of the tab order; Cancel/Enter/Esc back out. - Reachable even when there's nothing to open (folder-only editor or a worktree with no solution file). Covered by GitService, dialog-widget, and end-to-end tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019JuVApu68rjnvBUEY5NCBV --- CHANGELOG.md | 15 + Docs/Features.md | 29 +- src/Models/WorktreeDeletion.cs | 28 ++ src/Services/AvaloniaDialogService.cs | 7 +- src/Services/GitService.cs | 54 +++ src/Services/IDialogService.cs | 11 +- src/Services/OpenerService.cs | 73 ++++ src/Theme/FidoStyles.axaml | 24 ++ src/ViewModels/ChooserViewModel.cs | 11 +- .../DeleteWorktreeDialogViewModel.cs | 44 +++ src/Views/ChooserDialog.axaml | 20 +- src/Views/ChooserDialog.axaml.cs | 12 +- src/Views/DeleteWorktreeDialog.axaml | 57 ++++ src/Views/DeleteWorktreeDialog.axaml.cs | 27 ++ src/Views/MainWindow.axaml.cs | 127 +++++-- .../Fido.Tests/Dialogs/ChooserDialogTests.cs | 36 ++ .../Dialogs/DeleteWorktreeDialogTests.cs | 123 +++++++ tests/Fido.Tests/E2E/DeleteWorktreeTests.cs | 322 ++++++++++++++++++ .../Infrastructure/FakeDialogService.cs | 16 +- .../Infrastructure/TestRepoWorld.cs | 21 ++ tests/Fido.Tests/Services/GitServiceTests.cs | 98 ++++++ 21 files changed, 1112 insertions(+), 43 deletions(-) create mode 100644 src/Models/WorktreeDeletion.cs create mode 100644 src/ViewModels/DeleteWorktreeDialogViewModel.cs create mode 100644 src/Views/DeleteWorktreeDialog.axaml create mode 100644 src/Views/DeleteWorktreeDialog.axaml.cs create mode 100644 tests/Fido.Tests/Dialogs/DeleteWorktreeDialogTests.cs create mode 100644 tests/Fido.Tests/E2E/DeleteWorktreeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 569e4f2..dd44d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ 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 that spells out the worktree path, the local branch, and whether the branch exists on + `origin`, plus **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 **removes the linked worktree, deletes the local branch, and deletes the branch on + `origin`** when it's there. 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..2eb8874 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,31 @@ 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** first spells out exactly what will go: the **worktree path**, the **local + branch**, and whether the branch is **on `origin`**. It 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**; + **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 **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/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/Services/AvaloniaDialogService.cs b/src/Services/AvaloniaDialogService.cs index 889e660..acc56a5 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..22a3ebc 100644 --- a/src/Services/IDialogService.cs +++ b/src/Services/IDialogService.cs @@ -10,8 +10,15 @@ 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 true only when confirmed. + 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..99384e4 100644 --- a/src/Services/OpenerService.cs +++ b/src/Services/OpenerService.cs @@ -363,6 +363,79 @@ 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 : removes the worktree (forcing when it's dirty), deletes + /// the now-free local branch, and — when the branch is on origin — deletes it there too. 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 reported as + /// a partial success (false) rather than throwing, because the local worktree and branch are + /// already gone and re-running wouldn't undo that. + /// + public async Task DeleteWorktreeAsync(WorktreeDeletion plan, CancellationToken ct = default) + { + var dir = plan.MainWorktreePath; + + _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."); + + _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."); + + if (!plan.RemoteBranchExists) + return true; + + _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."); + return true; + } + + _log($"[!] Remote branch origin/{plan.Branch} could not be deleted: {remoteResult.Message}"); + return false; + } + /// /// 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..4975867 100644 --- a/src/Theme/FidoStyles.axaml +++ b/src/Theme/FidoStyles.axaml @@ -158,6 +158,30 @@ + + + + + + + + + + + +