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. + + + + + + + + + + +
DarkLight
Branch-folder chooser with the delete button, dark themeBranch-folder chooser with the delete button, light theme
+ +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. + + + + + + + + + + +
DarkLight
Delete-worktree confirmation dialog, dark themeDelete-worktree confirmation dialog, light theme
+ +--- + ## 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 @@ + + + + + + + + + + + +