diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83d581d..18bfd21 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
+- **A worktree delete that goes part-way now says so honestly — and offers a Retry.** The three things a
+ delete removes (the worktree, the local branch, the branch on `origin`) are attempted and reported
+ **separately**: a step that fails no longer abandons the ones after it, and the flight log's closing line
+ names exactly what went and what didn't. A target that had **already gone** — a branch the server dropped
+ on merge, a folder cleared by hand, a worktree git no longer knows about — is reported as **done** rather
+ than as an error (*"✓ Removed worktree & branch `feature/x` — origin/feature/x was already gone."*); a `⚠`
+ is now spent only on something you asked for that is genuinely **still there**. Previously a
+ `git push --delete` that came back with *"remote ref does not exist"* was reported as a failure even
+ though the worktree and local branch had been removed cleanly and the branch was, in fact, gone from
+ `origin`. Anything that does survive the delete gets an inline **Retry** strip — what's left, git's own
+ words for why, and **Retry** / **Dismiss**. Retrying re-runs **only the outstanding step** (a worktree and
+ branch that already went are not touched again) and the report that follows covers the whole attempt. The
+ strip outlives the card you just deleted; **Esc** or **Dismiss** drops the offer without touching anything
+ on disk, and a fresh scan clears it as stale.
+
- **The flight log grows with the window, and its text can be copied or saved.** Drag Fido taller and
every spare pixel now goes to the **flight log** instead of to a gap above it — the upper section
keeps the room its content needs and the log takes the rest; shrink the window and the log falls back
diff --git a/Docs/Features.md b/Docs/Features.md
index 3b1cf02..e7df565 100644
--- a/Docs/Features.md
+++ b/Docs/Features.md
@@ -172,6 +172,22 @@ up a branch you're finished with:
process. Fido retries a few times with a short, backing-off wait — each attempt
narrated in the flight log — while **permanent** refusals still fail fast on the
first try.
+- **The three targets are independent, and nothing-to-do isn't failure.** The worktree,
+ the local branch and the branch on `origin` are each attempted and reported on
+ separately: one that fails no longer abandons the ones after it, and the flight log's
+ closing line says exactly what went and what didn't (*"✓ Removed worktree & branch
+ `feature/x` + origin/feature/x."*). A target that had **already gone** — a branch the
+ server dropped on merge, a folder cleared by hand, a worktree git no longer knows
+ about — is reported as **done**, not as an error: *"✓ Removed worktree & branch
+ `feature/x` — origin/feature/x was already gone."* A `⚠` is spent only on something
+ you asked for that is genuinely **still there**.
+- **Retry what's left.** When something does survive the delete, an inline **Retry**
+ strip appears with what's still standing, git's own words for why, and a **Retry** /
+ **Dismiss** pair. Retrying re-runs **only the outstanding step** — a worktree and
+ local branch that already went are not touched again — and the closing report then
+ covers the whole attempt. The strip sits outside the delete row so it outlives the
+ card you just deleted; **Dismiss** or **Esc** drops the offer without touching
+ anything on disk, and a fresh scan clears it as stale.
- **Long filenames & a force-delete fallback.** Deep worktrees can trip Windows'
**260-character `MAX_PATH`** limit — a `node_modules` tree or generated output whose
paths are too long — and a delete then fails with **`filename too long`** /
@@ -299,7 +315,8 @@ log (`📋 Copied 8 flight-log line(s) to the clipboard.`, `✓ Flight log saved
- **Ctrl+1 … Ctrl+9** open the selected target with the corresponding configured tool
(the same tools shown as buttons), gated — like the buttons — on discovery having
**found** the branch.
-- **Esc** backs out of a pending delete confirmation.
+- **Esc** backs out of a pending delete confirmation — or, once a delete has run,
+ dismisses the **Retry** strip a part-way delete left behind.
- **Settings dialog:** `Enter` saves, `Esc` cancels.
- **`Alt+Space`** opens the window's native **system menu** (Move, Size, Minimize, Maximize, Close)
on any window — the same menu reached from the title-bar icon or a title-bar right-click.
@@ -390,6 +407,7 @@ the next save writes to the new location.
| Open gate | Open & delete actions unlock only when discovery **finds** the branch |
| Open target | Rider / Visual Studio: the chosen `.sln` / `.slnx` / `.slnf` chip or the folder; every other tool: the folder |
| Delete worktree | Inline two-step confirm; removes the worktree + **local** branch, with an **opt-in to also delete the remote branch** (unticked by default, disabled while an open PR — via `gh` — blocks it, linking to the PR); retries transient failures; long-path aware with a Recycle-Bin-bypassing force-delete for **`filename too long`** |
+| Delete reporting | Each target reported separately — **already gone counts as done**, not as failure; anything genuinely left behind gets an inline **Retry** strip that re-runs just that step |
| Tools | Rider / WebStorm / VS Code / Visual Studio / Zed / Custom — hero default + Ctrl+1…9, or by CLI id |
| Folder targets | **Console** (`term`) opens a terminal, **File Explorer** (`files`) the OS file manager — Windows / macOS / Linux |
| Editor discovery | Explicit path → PATH → standard installs (per kind) |
diff --git a/src/Models/WorktreeDeletionChoice.cs b/src/Models/WorktreeDeletionChoice.cs
index 8a36eb5..3fff018 100644
--- a/src/Models/WorktreeDeletionChoice.cs
+++ b/src/Models/WorktreeDeletionChoice.cs
@@ -12,14 +12,13 @@ public sealed record WorktreeDeletionChoice(bool Worktree, bool LocalBranch, boo
/// 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;
+ /// True when is ticked — the selection read one target at a time.
+ public bool Includes(DeletionTarget target) => target switch
+ {
+ DeletionTarget.Worktree => Worktree,
+ DeletionTarget.LocalBranch => LocalBranch,
+ DeletionTarget.RemoteBranch => RemoteBranch,
+ _ => false,
+ };
}
diff --git a/src/Models/WorktreeDeletionOutcome.cs b/src/Models/WorktreeDeletionOutcome.cs
new file mode 100644
index 0000000..b9cf836
--- /dev/null
+++ b/src/Models/WorktreeDeletionOutcome.cs
@@ -0,0 +1,121 @@
+namespace Fido.Models;
+
+/// The three things a "delete this worktree" action can remove, each reported on separately so a
+/// part-way failure can be described — and retried — without redoing the parts that already went.
+public enum DeletionTarget
+{
+ /// The linked worktree folder.
+ Worktree,
+
+ /// The local branch the worktree had checked out.
+ LocalBranch,
+
+ /// The branch on origin.
+ RemoteBranch,
+}
+
+/// How one deletion step ended.
+public enum DeletionStepStatus
+{
+ /// Never attempted — the user didn't tick it, or there was nothing to act on.
+ Skipped,
+
+ /// git removed it.
+ Deleted,
+
+ /// There was nothing to remove: it had already gone (a branch deleted on the server, a folder
+ /// cleared by hand). The end state the user asked for, so this counts as success — not a failure.
+ AlreadyGone,
+
+ /// git couldn't remove it and it's still there. The only status worth retrying.
+ Failed,
+}
+
+/// One target's result, carrying git's message when it failed (or when it was already gone).
+/// Which of the three things this step acted on.
+/// How it ended.
+/// git's stderr/stdout for a failed or already-gone step; empty otherwise.
+public sealed record WorktreeDeletionStep(DeletionTarget Target, DeletionStepStatus Status, string Detail = "")
+{
+ /// True when the target is no longer there — whether this step removed it or found it gone.
+ public bool IsGone => Status is DeletionStepStatus.Deleted or DeletionStepStatus.AlreadyGone;
+
+ /// True when the target is still there and the step is worth retrying.
+ public bool IsFailed => Status is DeletionStepStatus.Failed;
+}
+
+///
+/// What a delete actually removed, step by step, so the caller can report it accurately and offer a retry
+/// limited to whatever is still standing. Each of the three targets is independent: a step that fails no
+/// longer abandons the ones after it, and a target that was already gone is reported as success
+/// rather than as a failure — deleting a branch the server no longer has leaves things exactly as asked.
+///
+/// One entry per target that was considered, in the order they ran.
+public sealed record WorktreeDeletionOutcome(IReadOnlyList Steps)
+{
+ /// An outcome that did nothing at all — the seed for merging, and the "declined" result.
+ public static WorktreeDeletionOutcome Nothing { get; } = new([]);
+
+ /// This run's step for , or null when it wasn't considered.
+ public WorktreeDeletionStep? StepFor(DeletionTarget target) => Steps.FirstOrDefault(s => s.Target == target);
+
+ /// How ended, treating "never considered" as .
+ public DeletionStepStatus StatusOf(DeletionTarget target) => StepFor(target)?.Status ?? DeletionStepStatus.Skipped;
+
+ /// True when is no longer there (deleted now, or already gone).
+ public bool IsGone(DeletionTarget target) => StepFor(target)?.IsGone == true;
+
+ /// True when the worktree folder is gone.
+ public bool WorktreeRemoved => IsGone(DeletionTarget.Worktree);
+
+ /// True when the local branch is gone.
+ public bool LocalBranchDeleted => IsGone(DeletionTarget.LocalBranch);
+
+ /// True when this run deleted the branch on origin.
+ public bool RemoteBranchDeleted => StatusOf(DeletionTarget.RemoteBranch) is DeletionStepStatus.Deleted;
+
+ /// True when origin had already lost the branch — nothing to delete, and no failure.
+ public bool RemoteBranchAlreadyGone => StatusOf(DeletionTarget.RemoteBranch) is DeletionStepStatus.AlreadyGone;
+
+ /// True when the branch on origin is still there because the delete failed.
+ public bool RemoteDeleteFailed => StatusOf(DeletionTarget.RemoteBranch) is DeletionStepStatus.Failed;
+
+ /// True when at least one target was actually removed by this run.
+ public bool AnyDeleted => Steps.Any(s => s.Status is DeletionStepStatus.Deleted);
+
+ /// Everything still standing that the run tried and failed to remove — what a retry would re-run.
+ public IReadOnlyList Failures => [.. Steps.Where(s => s.IsFailed)];
+
+ /// True when something the user asked for is still there.
+ public bool AnyFailed => Steps.Any(s => s.IsFailed);
+
+ ///
+ /// What a retry should run: everything for that isn't gone yet — the steps that
+ /// failed, plus any that never got to run (a worktree removal the user declined to force takes its branch
+ /// deletions down with it). Empty when the deletion is complete, which is how the caller knows to drop the
+ /// retry offer entirely.
+ ///
+ public WorktreeDeletionChoice Outstanding(WorktreeDeletionChoice asked) => new(
+ Worktree: asked.Worktree && !IsGone(DeletionTarget.Worktree),
+ LocalBranch: asked.LocalBranch && !IsGone(DeletionTarget.LocalBranch),
+ RemoteBranch: asked.RemoteBranch && !IsGone(DeletionTarget.RemoteBranch));
+
+ ///
+ /// Folds a later run (a retry) over this one so the report covers the whole attempt: a target the retry
+ /// acted on takes the retry's result, everything else keeps what the first run found. Skipped steps in
+ /// never overwrite — a retry that only re-ran the remote delete must not forget
+ /// that the worktree and local branch already went.
+ ///
+ public WorktreeDeletionOutcome Merge(WorktreeDeletionOutcome later)
+ {
+ var merged = new List(Steps);
+ foreach (var step in later.Steps)
+ {
+ if (step.Status is DeletionStepStatus.Skipped) continue;
+ var index = merged.FindIndex(s => s.Target == step.Target);
+ if (index >= 0) merged[index] = step;
+ else merged.Add(step);
+ }
+ return new WorktreeDeletionOutcome(merged);
+ }
+}
diff --git a/src/Services/DeletionReport.cs b/src/Services/DeletionReport.cs
new file mode 100644
index 0000000..e1553d9
--- /dev/null
+++ b/src/Services/DeletionReport.cs
@@ -0,0 +1,112 @@
+using System.Collections.Generic;
+using System.Linq;
+using Fido.Models;
+
+namespace Fido.Services;
+
+///
+/// Turns a into the words the user reads — the flight log's one-line
+/// summary and the retry strip's prompt. It lives in one place so the log line and the strip can never
+/// disagree about what actually happened.
+/// The rule that matters: the line only carries a ⚠ when something the user asked for is still
+/// there. A target that was already gone is reported plainly as part of a ✓ — a failed remote delete
+/// against a branch the server had already dropped used to read as a failure while the local cleanup had in
+/// fact succeeded, which is precisely the report this replaces.
+///
+public static class DeletionReport
+{
+ /// The flight-log line for a finished (or part-finished) delete: ✓ when everything asked for is
+ /// gone, ⚠ — with the retry offer — when something survived.
+ public static string Summary(WorktreeDeletionOutcome outcome, string branch)
+ {
+ var removed = Removed(outcome, branch);
+ var notes = AlreadyGoneNotes(outcome, branch);
+
+ if (!outcome.AnyFailed)
+ {
+ var line = removed.Length > 0 ? removed : "Nothing left to remove";
+ if (notes.Count > 0) line += " — " + string.Join("; ", notes);
+ return $"✓ {line}.";
+ }
+
+ var failed = Names(FailedTargets(outcome), branch);
+ var lead = removed.Length > 0
+ ? $"{removed}, but {failed} could not be deleted"
+ : $"Couldn't delete {failed}";
+ if (notes.Count > 0) lead += $" ({string.Join("; ", notes)})";
+ return $"⚠ {lead} — use Retry to run just that step again.";
+ }
+
+ ///
+ /// The retry strip's headline: what is still standing, and what already went, in one sentence. Named from
+ /// rather than from the failed steps, so a delete that fell over before it
+ /// could report anything (git refusing to start, an IO error mid-way) still describes what's left.
+ ///
+ public static string RetryHeadline(WorktreeDeletionOutcome outcome, WorktreeDeletionChoice outstanding, string branch)
+ {
+ var still = $"Couldn't delete {Names(outstanding, branch)}.";
+ var removed = Removed(outcome, branch);
+ var notes = AlreadyGoneNotes(outcome, branch);
+ if (removed.Length > 0) still += $" {removed} — that part is done.";
+ else if (notes.Count > 0) still += $" ({string.Join("; ", notes)}.)";
+ return still;
+ }
+
+ /// git's own words for the failed steps — the detail line under the retry strip's headline.
+ public static string RetryDetail(WorktreeDeletionOutcome outcome) =>
+ string.Join("\n", outcome.Failures.Select(f => f.Detail).Where(d => d.Length > 0));
+
+ /// "Removed worktree & branch 'x' + origin/x" for whatever this run actually deleted;
+ /// empty when it deleted nothing.
+ private static string Removed(WorktreeDeletionOutcome outcome, string branch)
+ {
+ var local = new List();
+ if (outcome.StatusOf(DeletionTarget.Worktree) is DeletionStepStatus.Deleted) local.Add("worktree");
+ if (outcome.StatusOf(DeletionTarget.LocalBranch) is DeletionStepStatus.Deleted) local.Add($"branch '{branch}'");
+
+ var text = local.Count > 0 ? "Removed " + string.Join(" & ", local) : "";
+ if (outcome.RemoteBranchDeleted)
+ text = text.Length > 0 ? $"{text} + origin/{branch}" : $"Removed origin/{branch}";
+ return text;
+ }
+
+ /// The "nothing to do here" notes — one per target that had already gone.
+ private static List AlreadyGoneNotes(WorktreeDeletionOutcome outcome, string branch)
+ {
+ var notes = new List();
+ foreach (var step in outcome.Steps)
+ {
+ if (step.Status is not DeletionStepStatus.AlreadyGone) continue;
+ notes.Add(step.Target switch
+ {
+ DeletionTarget.Worktree => "the worktree folder was already gone",
+ DeletionTarget.LocalBranch => $"branch '{branch}' was already gone",
+ _ => $"origin/{branch} was already gone",
+ });
+ }
+ return notes;
+ }
+
+ /// Just the targets that failed, as a selection — so failures and outstanding work are named
+ /// by the same code.
+ private static WorktreeDeletionChoice FailedTargets(WorktreeDeletionOutcome outcome) => new(
+ Worktree: outcome.StatusOf(DeletionTarget.Worktree) is DeletionStepStatus.Failed,
+ LocalBranch: outcome.StatusOf(DeletionTarget.LocalBranch) is DeletionStepStatus.Failed,
+ RemoteBranch: outcome.StatusOf(DeletionTarget.RemoteBranch) is DeletionStepStatus.Failed);
+
+ /// A selection named as the user knows it ("the worktree, branch 'x' and origin/x").
+ private static string Names(WorktreeDeletionChoice choice, string branch)
+ {
+ var names = new List();
+ if (choice.Worktree) names.Add("the worktree");
+ if (choice.LocalBranch) names.Add($"branch '{branch}'");
+ if (choice.RemoteBranch) names.Add($"origin/{branch}");
+
+ return names.Count switch
+ {
+ 0 => "",
+ 1 => names[0],
+ _ => string.Join(", ", names.Take(names.Count - 1)) + " and " + names[^1],
+ };
+ }
+}
diff --git a/src/Services/GitAlreadyGone.cs b/src/Services/GitAlreadyGone.cs
new file mode 100644
index 0000000..823d9c4
--- /dev/null
+++ b/src/Services/GitAlreadyGone.cs
@@ -0,0 +1,59 @@
+using System;
+
+namespace Fido.Services;
+
+///
+/// Tells apart the git deletion failures that mean "there was nothing to delete" from the ones that
+/// leave the target standing. A branch someone else already removed on the server, a worktree folder cleared
+/// by hand, a branch deleted in another window — git fails these with a non-zero exit, but the end state is
+/// exactly the one the user asked for, so reporting them as failures (and colouring the flight log red) is
+/// simply wrong. Callers map a match to and carry on.
+/// Matching is on git's own wording, case-insensitively, and deliberately narrow: anything not listed
+/// here still counts as a failure, which is the safe way round — a real failure reported as "already gone"
+/// would quietly leave a branch behind, while the reverse merely offers a retry that finds nothing to do.
+///
+public static class GitAlreadyGone
+{
+ ///
+ /// True when git worktree remove failed because the worktree isn't registered any more — usually
+ /// because it was already removed (git exits 0 when only the folder is missing, so this is the
+ /// "not a working tree" case). Callers should also treat a missing folder as already gone.
+ ///
+ public static bool Worktree(ProcessResult result) => MatchesAny(result,
+ [
+ "is not a working tree",
+ "no such file or directory",
+ ]);
+
+ /// True when git branch -D failed because the branch isn't there (error: branch 'x'
+ /// not found) — both words are required so an unrelated "not found" can't pass for it.
+ public static bool LocalBranch(ProcessResult result) => MatchesAll(result, ["branch", "not found"]);
+
+ /// True when git push origin --delete failed because origin no longer has the
+ /// branch (error: unable to delete 'x': remote ref does not exist) — the ref is gone either way.
+ public static bool RemoteBranch(ProcessResult result) => MatchesAny(result, ["remote ref does not exist"]);
+
+ /// True when any one of appears in a failed result's output.
+ private static bool MatchesAny(ProcessResult result, string[] markers)
+ {
+ if (result.Success) return false;
+ var text = Text(result);
+ foreach (var marker in markers)
+ if (text.Contains(marker, StringComparison.OrdinalIgnoreCase))
+ return true;
+ return false;
+ }
+
+ /// True when every marker appears in a failed result's output.
+ private static bool MatchesAll(ProcessResult result, string[] markers)
+ {
+ if (result.Success) return false;
+ var text = Text(result);
+ foreach (var marker in markers)
+ if (!text.Contains(marker, StringComparison.OrdinalIgnoreCase))
+ return false;
+ return true;
+ }
+
+ private static string Text(ProcessResult result) => result.StdErr + "\n" + result.StdOut;
+}
diff --git a/src/Services/OpenerService.cs b/src/Services/OpenerService.cs
index cdb2f17..7177ef2 100644
--- a/src/Services/OpenerService.cs
+++ b/src/Services/OpenerService.cs
@@ -556,29 +556,26 @@ public Task IsLinkedWorktreeAsync(string folder, CancellationToken ct = de
/// Runs from the clone's main tree. Each git step is wrapped in , so a
/// transient failure — a worktree file still locked by an editor, a racing ref .lock, a
/// network blip on the origin delete — is retried a few times (narrated in the log) before it counts; a
- /// permanent failure fails on the first attempt. A failed worktree removal throws a
- /// (so the caller can offer as
- /// a fallback); a failed local-branch delete throws (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.
+ /// permanent failure fails on the first attempt.
+ /// The three targets are independent and tolerant: a step that fails no longer abandons the
+ /// ones after it, and one whose target had already gone (a branch someone deleted on the server, a
+ /// folder cleared by hand) is reported as success — see . Everything lands in
+ /// the returned , whose
+ /// names just what's still standing, for a retry that
+ /// re-runs only those steps. The one exception is a genuinely failed worktree removal, which still
+ /// throws a so the caller can offer
+ /// as a fallback.
///
public async Task DeleteWorktreeAsync(
WorktreeDeletion plan, WorktreeDeletionChoice choice, CancellationToken ct = default)
{
- var worktreeRemoved = false;
+ var steps = new List();
if (choice.Worktree)
- {
- _log($"Removing worktree at {plan.WorktreePath}…");
- var remove = await GitRetry.ExecuteAsync(_deletionRetry, "worktree remove",
- token => _git.WorktreeRemoveAsync(plan.MainWorktreePath, plan.WorktreePath, force: plan.HasOutstandingChanges, token), ct);
- if (!remove.Success)
- throw new WorktreeRemovalException(plan.WorktreePath, remove.Message);
- _log("Worktree removed.");
- worktreeRemoved = true;
- }
+ steps.Add(await RemoveWorktreeAsync(plan, ct));
- return await DeleteBranchesAsync(plan, choice, worktreeRemoved, ct);
+ steps.AddRange(await DeleteBranchesAsync(plan, choice, ct));
+ return new WorktreeDeletionOutcome(steps);
}
///
@@ -587,43 +584,100 @@ public async Task DeleteWorktreeAsync(
/// recursive delete that bypasses the Recycle Bin and, on Windows, uses an extended-length path so
/// it isn't defeated by the same limit that stopped git), then prunes git's now-dangling worktree
/// registration so the branch is free to delete, and finishes the ticked branch deletions. The caller must
- /// have confirmed the destructive folder delete first.
+ /// have confirmed the destructive folder delete first. A folder that even this can't remove is reported as
+ /// a failed step rather than thrown — the branch deletions still run, and the outcome carries the retry.
///
public async Task ForceDeleteWorktreeAsync(
WorktreeDeletion plan, WorktreeDeletionChoice choice, CancellationToken ct = default)
+ {
+ var steps = new List { await ForceRemoveFolderAsync(plan, ct) };
+ steps.AddRange(await DeleteBranchesAsync(plan, choice, ct));
+ return new WorktreeDeletionOutcome(steps);
+ }
+
+ ///
+ /// Removes the linked worktree, retrying transient failures. A worktree git no longer knows about counts as
+ /// (its registration is pruned so the branch is free to
+ /// delete); anything else throws so the caller can offer the
+ /// disk-level fallback.
+ ///
+ private async Task RemoveWorktreeAsync(WorktreeDeletion plan, CancellationToken ct)
+ {
+ _log($"Removing worktree at {plan.WorktreePath}…");
+ var remove = await GitRetry.ExecuteAsync(_deletionRetry, "worktree remove",
+ token => _git.WorktreeRemoveAsync(plan.MainWorktreePath, plan.WorktreePath, force: plan.HasOutstandingChanges, token), ct);
+
+ if (remove.Success)
+ {
+ _log("Worktree removed.");
+ return new WorktreeDeletionStep(DeletionTarget.Worktree, DeletionStepStatus.Deleted);
+ }
+
+ // Nothing to remove isn't a failure. git knows a worktree by its registration, so "not a working tree"
+ // — or a folder that simply isn't on disk any more (removed by hand, or by an earlier attempt that
+ // stumbled later on) — means we're already where the user asked to be. Prune the stale registration so
+ // the branch is no longer considered checked out, and carry on to the branches.
+ if (GitAlreadyGone.Worktree(remove) || !Directory.Exists(plan.WorktreePath))
+ {
+ _log($"Worktree at {plan.WorktreePath} was already gone — pruning git's registration.");
+ await PruneAsync(plan.MainWorktreePath, ct);
+ return new WorktreeDeletionStep(DeletionTarget.Worktree, DeletionStepStatus.AlreadyGone, remove.Message);
+ }
+
+ throw new WorktreeRemovalException(plan.WorktreePath, remove.Message);
+ }
+
+ /// Deletes the worktree folder straight from disk and prunes git's registration, reporting a
+ /// folder that resisted even that as a failed step rather than throwing.
+ private async Task ForceRemoveFolderAsync(WorktreeDeletion plan, CancellationToken ct)
{
_log($"Force-deleting worktree folder {plan.WorktreePath} (bypassing the Recycle Bin)…");
- await Task.Run(() => ForceDeleteFolder(plan.WorktreePath), ct);
+ try
+ {
+ await Task.Run(() => ForceDeleteFolder(plan.WorktreePath), ct);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _log($"[!] The worktree folder {plan.WorktreePath} could not be deleted: {ex.Message}");
+ return new WorktreeDeletionStep(DeletionTarget.Worktree, DeletionStepStatus.Failed, ex.Message);
+ }
+
_log("Worktree folder deleted; pruning git's worktree registration…");
+ await PruneAsync(plan.MainWorktreePath, ct);
+ return new WorktreeDeletionStep(DeletionTarget.Worktree, DeletionStepStatus.Deleted);
+ }
- var prune = await _git.PruneWorktreesAsync(plan.MainWorktreePath, ct);
+ /// Drops git's registration of any worktree whose folder has gone. Advisory — a prune that
+ /// complains is narrated but never fails the deletion.
+ private async Task PruneAsync(string mainWorktreePath, CancellationToken ct)
+ {
+ var prune = await _git.PruneWorktreesAsync(mainWorktreePath, ct);
if (!prune.Success)
_log($"[!] git worktree prune reported: {prune.Message}");
-
- return await DeleteBranchesAsync(plan, choice, worktreeRemoved: true, ct);
}
///
/// Deletes the local branch and the branch on origin per (the shared tail
- /// of both and , run once the
- /// worktree is gone). A failed local delete throws; a failed remote delete is logged and flagged in the
- /// outcome rather than thrown. See for the retry semantics.
+ /// of both and ). Neither throws:
+ /// each reports its own step, and a failed local delete no longer withholds the remote one — they're
+ /// independent, and the outcome says exactly which of them is still standing.
///
- private async Task DeleteBranchesAsync(
- WorktreeDeletion plan, WorktreeDeletionChoice choice, bool worktreeRemoved, CancellationToken ct)
+ private async Task> DeleteBranchesAsync(
+ WorktreeDeletion plan, WorktreeDeletionChoice choice, CancellationToken ct)
{
+ var steps = new List();
var dir = plan.MainWorktreePath;
- bool localDeleted = false, remoteDeleted = false, remoteFailed = false;
if (choice.LocalBranch)
{
_log($"Deleting local branch '{plan.Branch}'…");
var branchResult = await GitRetry.ExecuteAsync(_deletionRetry, "local branch delete",
token => _git.DeleteLocalBranchAsync(dir, plan.Branch, token), ct);
- if (!branchResult.Success)
- throw new InvalidOperationException($"git branch -D failed: {branchResult.Message}");
- _log($"Local branch '{plan.Branch}' deleted.");
- localDeleted = true;
+ steps.Add(Report(DeletionTarget.LocalBranch, branchResult,
+ GitAlreadyGone.LocalBranch,
+ deleted: $"Local branch '{plan.Branch}' deleted.",
+ alreadyGone: $"Local branch '{plan.Branch}' was already gone — nothing to delete.",
+ failed: $"Local branch '{plan.Branch}' could not be deleted"));
}
// An open pull request withholds the remote delete even when the caller ticked it — deleting
@@ -631,27 +685,41 @@ private async Task DeleteBranchesAsync(
if (choice.RemoteBranch && plan.RemoteBranchExists && !plan.RemoteDeletionBlocked)
{
_log($"Deleting remote branch origin/{plan.Branch}…");
- // Retrying the push is safe — deleting an already-gone branch is a no-op in effect. One rare,
- // non-destructive wrinkle: if a transient drop happens *after* origin deleted the ref but before
- // git reads the ack, the retry sees "remote ref does not exist" (permanent) and reports failure
- // though the branch is in fact gone. We don't infer success from that message — on a first attempt
- // it legitimately means the ref was already gone, which callers surface as a NO-GO — so we accept
- // the occasional misleading report over guessing.
+ // Retrying the push is safe — deleting an already-gone branch is a no-op in effect, and git says so
+ // ("remote ref does not exist"), which lands as AlreadyGone rather than a failure. That also settles
+ // the one rare wrinkle: a transient drop *after* origin deleted the ref but before git read the ack
+ // used to report failure though the branch was in fact gone.
var remoteResult = await GitRetry.ExecuteAsync(_deletionRetry, "remote branch delete",
token => _git.DeleteRemoteBranchAsync(dir, plan.Branch, token), 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;
- }
+ steps.Add(Report(DeletionTarget.RemoteBranch, remoteResult,
+ GitAlreadyGone.RemoteBranch,
+ deleted: $"Remote branch origin/{plan.Branch} deleted.",
+ alreadyGone: $"Remote branch origin/{plan.Branch} was already gone — nothing to delete.",
+ failed: $"Remote branch origin/{plan.Branch} could not be deleted"));
+ }
+
+ return steps;
+ }
+
+ /// Narrates one branch-delete result into the flight log and turns it into its step: success,
+ /// nothing-to-do (per ), or a failure the caller can retry.
+ private WorktreeDeletionStep Report(DeletionTarget target, ProcessResult result,
+ Func isAlreadyGone, string deleted, string alreadyGone, string failed)
+ {
+ if (result.Success)
+ {
+ _log(deleted);
+ return new WorktreeDeletionStep(target, DeletionStepStatus.Deleted);
+ }
+
+ if (isAlreadyGone(result))
+ {
+ _log(alreadyGone);
+ return new WorktreeDeletionStep(target, DeletionStepStatus.AlreadyGone, result.Message);
}
- return new WorktreeDeletionOutcome(worktreeRemoved, localDeleted, remoteDeleted, remoteFailed);
+ _log($"[!] {failed}: {result.Message}");
+ return new WorktreeDeletionStep(target, DeletionStepStatus.Failed, result.Message);
}
///
diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs
index 46d6e48..d560b98 100644
--- a/src/ViewModels/MainWindowViewModel.cs
+++ b/src/ViewModels/MainWindowViewModel.cs
@@ -390,6 +390,57 @@ public void ArmDeleteConfirm(WorktreeDeletion plan)
/// Backs out of a pending confirm (Esc, Cancel, or the selection changing).
public void CancelDeleteConfirm() => IsConfirmingDelete = false;
+ // --- Delete retry strip -------------------------------------------------------------
+
+ private bool _isDeleteRetryPending;
+ private string _deleteRetryHeadline = "";
+ private string _deleteRetryDetail = "";
+
+ /// True when a delete left something standing (a branch on origin the push couldn't remove,
+ /// a folder still held open) and the retry strip is offering another go at just that step. It outlives the
+ /// deleted card — the strip sits outside the delete row, so it survives the results emptying.
+ public bool IsDeleteRetryPending
+ {
+ get => _isDeleteRetryPending;
+ private set => SetField(ref _isDeleteRetryPending, value);
+ }
+
+ /// What's still there, and what already went — the retry strip's one-line explanation.
+ public string DeleteRetryHeadline
+ {
+ get => _deleteRetryHeadline;
+ private set => SetField(ref _deleteRetryHeadline, value);
+ }
+
+ /// git's own words for the failure, shown under the headline; empty when it said nothing useful.
+ public string DeleteRetryDetail
+ {
+ get => _deleteRetryDetail;
+ private set
+ {
+ if (SetField(ref _deleteRetryDetail, value))
+ OnPropertyChanged(nameof(HasDeleteRetryDetail));
+ }
+ }
+
+ public bool HasDeleteRetryDetail => _deleteRetryDetail.Length > 0;
+
+ /// Offers a retry of whatever a delete left behind, spelling out what's outstanding.
+ public void ArmDeleteRetry(string headline, string detail)
+ {
+ DeleteRetryHeadline = headline;
+ DeleteRetryDetail = detail;
+ IsDeleteRetryPending = true;
+ }
+
+ /// Takes the retry offer away — it succeeded, was dismissed, or a fresh scan made it stale.
+ public void ClearDeleteRetry()
+ {
+ IsDeleteRetryPending = false;
+ DeleteRetryHeadline = "";
+ DeleteRetryDetail = "";
+ }
+
// --- Scan lifecycle (driven by the window orchestrator) ---------------------------
/// A fresh scan: clears the results, resets the log to the mission-control preamble, and
@@ -399,6 +450,7 @@ public void BeginScan(string branch)
ScannedBranch = branch;
OnPropertyChanged(nameof(NotFoundBody));
ScanningBody = $"Scanning working trees for '{branch}'…";
+ ClearDeleteRetry(); // a leftover from the last branch's delete has nothing to say about this scan
Targets.Clear();
SelectedTarget = null;
OnPropertyChanged(nameof(HasMultipleTargets));
diff --git a/src/Views/MainWindow.axaml b/src/Views/MainWindow.axaml
index e424204..08cb539 100644
--- a/src/Views/MainWindow.axaml
+++ b/src/Views/MainWindow.axaml
@@ -435,6 +435,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Views/MainWindow.axaml.cs b/src/Views/MainWindow.axaml.cs
index da92712..c5e3bfd 100644
--- a/src/Views/MainWindow.axaml.cs
+++ b/src/Views/MainWindow.axaml.cs
@@ -57,6 +57,14 @@ public partial class MainWindow : Window
private WorktreeDeletion? _pendingDeletePlan;
private TargetCard? _pendingDeleteCard;
+ /// What a part-way delete left behind, held while the retry strip offers another go: the same
+ /// plan, the steps still outstanding, and everything earlier passes already removed (so the report after
+ /// a successful retry describes the whole attempt).
+ private WorktreeDeletion? _retryPlan;
+ private TargetCard? _retryCard;
+ private WorktreeDeletionChoice _retryChoice = new(false, false, false);
+ private WorktreeDeletionOutcome _retryOutcome = WorktreeDeletionOutcome.Nothing;
+
/// Guards the popover radio handlers while the list itself is being rebuilt.
private bool _rebuildingToolChoices;
@@ -443,61 +451,141 @@ internal async Task ConfirmDeleteAsync()
// Remote delete only when the user ticked it, origin actually has the branch, and no open PR blocks it.
var deleteRemote = _vm.DeleteRemoteBranch && plan.RemoteBranchExists && !plan.RemoteDeletionBlocked;
var choice = new WorktreeDeletionChoice(Worktree: true, LocalBranch: true, RemoteBranch: deleteRemote);
- _vm.IsDeleting = true;
_vm.AppendLog($"🗑 Deleting worktree at {plan.WorktreePath}…");
try
{
- WorktreeDeletionOutcome outcome;
+ await RunDeletionAsync(plan, choice, card, WorktreeDeletionOutcome.Nothing);
+ }
+ finally
+ {
+ _vm.CancelDeleteConfirm();
+ _pendingDeletePlan = null;
+ _pendingDeleteCard = null;
+ }
+ }
+
+ ///
+ /// Another go at whatever the last delete left standing — the retry strip's button. Only the outstanding
+ /// steps re-run (a worktree and local branch that already went are not touched again), and the report that
+ /// follows covers the whole attempt, not just this pass. Internal for tests.
+ ///
+ internal async Task RetryDeleteAsync()
+ {
+ if (!_vm.IsDeleteRetryPending || _retryPlan is not { } plan) return;
+
+ var choice = _retryChoice;
+ var sofar = _retryOutcome;
+ _vm.AppendLog($"🗑 Retrying the delete for '{plan.Branch}'…");
+ await RunDeletionAsync(plan, choice, _retryCard, sofar);
+ }
+
+ ///
+ /// Runs one deletion pass — the first attempt or a retry — and settles what follows: the flight-log
+ /// report, the card, and the retry offer. Every step is reported rather than thrown (see
+ /// ), so a part-way failure lands here with an outcome
+ /// naming exactly what is still standing; carries what earlier passes already
+ /// removed so the summary describes the whole attempt.
+ ///
+ private async Task RunDeletionAsync(
+ WorktreeDeletion plan, WorktreeDeletionChoice choice, TargetCard? card, WorktreeDeletionOutcome sofar)
+ {
+ _vm.IsDeleting = true;
+ _vm.ClearDeleteRetry();
+ var outcome = sofar;
+ var unreported = "";
+ try
+ {
try
{
- outcome = await _opener.DeleteWorktreeAsync(plan, choice);
+ outcome = sofar.Merge(await _opener.DeleteWorktreeAsync(plan, choice));
+ _vm.AppendLog(DeletionReport.Summary(outcome, plan.Branch));
}
catch (WorktreeRemovalException ex)
{
// git gave up on the folder (usually a path too long). Offer to delete it straight from disk.
_vm.AppendLog($"⚠ git couldn't remove the worktree: {ex.Message}");
var force = await _dialogs.ConfirmForceDeleteWorktreeFolderAsync(new WorktreeForceDelete(ex.WorktreePath, ex.Message));
- if (!force)
+ if (force)
+ {
+ outcome = sofar.Merge(await _opener.ForceDeleteWorktreeAsync(plan, choice));
+ _vm.AppendLog(DeletionReport.Summary(outcome, plan.Branch));
+ }
+ else
{
+ // Declined — the worktree stays, and so does the offer to try again later.
_vm.AppendLog($"⚠ Couldn't remove the worktree for '{plan.Branch}' — left in place.");
- return;
+ outcome = sofar.Merge(new WorktreeDeletionOutcome(
+ [new WorktreeDeletionStep(DeletionTarget.Worktree, DeletionStepStatus.Failed, ex.Message)]));
}
- outcome = await _opener.ForceDeleteWorktreeAsync(plan, choice);
}
-
- if (outcome.RemoteDeleteFailed)
- _vm.AppendLog($"⚠ Removed worktree & branch '{plan.Branch}', but origin/{plan.Branch} could not be deleted — see log.");
- else
- {
- var remoteNote = outcome.RemoteBranchDeleted ? $" + origin/{plan.Branch}" : "";
- _vm.AppendLog($"✓ Removed worktree & branch '{plan.Branch}'{remoteNote}.");
- }
- // Only drop the card if it's still part of the current results — a scan that superseded
- // this delete owns the list (and the phase machine) now.
- if (_vm.Targets.Contains(card))
- _vm.RemoveTarget(card);
}
catch (Exception ex)
{
+ // Whatever the steps couldn't absorb — git failing to start, an IO error mid-delete. Report it and
+ // let the settle below offer a retry of everything that's still outstanding.
_vm.AppendLog($"⚠ {ex.Message}");
- // A part-way failure can still have removed the folder (e.g. the branch delete failed
- // after the worktree went) — don't leave a card pointing at nothing.
- if (!Directory.Exists(card.Target.Path) && _vm.Targets.Contains(card))
- _vm.RemoveTarget(card);
+ unreported = ex.Message;
}
finally
{
_vm.IsDeleting = false;
- _vm.CancelDeleteConfirm();
- _pendingDeletePlan = null;
- _pendingDeleteCard = null;
+ SettleDeletion(plan, choice, card, outcome, unreported);
+ }
+ }
+
+ ///
+ /// The aftermath of a deletion pass: drop the card once its folder has actually gone, then either clear the
+ /// retry offer (everything asked for is gone) or arm it against exactly what's left — remembering the plan
+ /// so can re-run just those steps.
+ ///
+ private void SettleDeletion(
+ WorktreeDeletion plan, WorktreeDeletionChoice choice, TargetCard? card, WorktreeDeletionOutcome outcome,
+ string unreportedFailure = "")
+ {
+ // Drop the card once the worktree it points at has gone — including when a later step failed, so a
+ // card is never left pointing at nothing. Only if it's still part of the current results, mind: a scan
+ // that superseded this delete owns the list (and the phase machine) now.
+ var worktreeGone = card is not null
+ && (outcome.IsGone(DeletionTarget.Worktree) || !Directory.Exists(card.Target.Path));
+ if (worktreeGone && _vm.Targets.Contains(card!))
+ _vm.RemoveTarget(card!);
+
+ var outstanding = outcome.Outstanding(choice);
+ if (!outstanding.AnySelected)
+ {
+ _retryPlan = null;
+ _retryCard = null;
+ _vm.ClearDeleteRetry();
+ return;
}
+
+ _retryPlan = plan;
+ _retryCard = card;
+ _retryChoice = outstanding;
+ _retryOutcome = outcome;
+
+ // git's own words where the steps produced them; the escaped exception's otherwise.
+ var detail = DeletionReport.RetryDetail(outcome);
+ if (detail.Length == 0) detail = unreportedFailure;
+ _vm.ArmDeleteRetry(DeletionReport.RetryHeadline(outcome, outstanding, plan.Branch), detail);
+ }
+
+ /// Drops the retry offer without touching anything on disk — the leftovers stay where they are.
+ internal void DismissDeleteRetry()
+ {
+ _retryPlan = null;
+ _retryCard = null;
+ _vm.ClearDeleteRetry();
}
private async void OnDeleteClick(object? sender, RoutedEventArgs e) => await RequestDeleteAsync();
private async void OnDeleteConfirmClick(object? sender, RoutedEventArgs e) => await ConfirmDeleteAsync();
+ private async void OnDeleteRetryClick(object? sender, RoutedEventArgs e) => await RetryDeleteAsync();
+
+ private void OnDeleteRetryDismissClick(object? sender, RoutedEventArgs e) => DismissDeleteRetry();
+
private void OnDeleteCancelClick(object? sender, RoutedEventArgs e)
{
_vm.CancelDeleteConfirm();
@@ -716,7 +804,8 @@ private async void OnAllSettingsClick(object? sender, RoutedEventArgs e)
// --- Keyboard -------------------------------------------------------------------------
// Ctrl+1…Ctrl+9 → open with tool index 0…8 (matching the accelerators on the buttons),
- // gated on discovery having found the branch. Esc backs out of a pending delete confirm.
+ // gated on discovery having found the branch. Esc backs out of a pending delete confirm, or —
+ // once the delete has run — dismisses a retry offer left over from it.
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape && _vm.IsConfirmingDelete)
@@ -726,6 +815,13 @@ private void OnWindowKeyDown(object? sender, KeyEventArgs e)
return;
}
+ if (e.Key == Key.Escape && _vm.IsDeleteRetryPending && !_vm.IsDeleting)
+ {
+ e.Handled = true;
+ DismissDeleteRetry();
+ return;
+ }
+
if (e.KeyModifiers != KeyModifiers.Control) return;
var index = DigitKeyToIndex(e.Key);
if (index is not { } i || i < 0 || i >= _config.Editors.Count) return;
diff --git a/tests/Fido.Tests/E2E/DeleteRetryTests.cs b/tests/Fido.Tests/E2E/DeleteRetryTests.cs
new file mode 100644
index 0000000..36c8bec
--- /dev/null
+++ b/tests/Fido.Tests/E2E/DeleteRetryTests.cs
@@ -0,0 +1,254 @@
+using Fido.Models;
+using Fido.Services;
+using Fido.Tests.Infrastructure;
+
+namespace Fido.Tests.E2E;
+
+///
+/// Scenario D2: what happens when a delete doesn't go perfectly. The three targets are independent, so a
+/// step that fails no longer condemns the report of the ones that worked — and a target that had
+/// already gone is reported as done rather than as a failure. Whatever is genuinely still standing
+/// is offered back as an inline Retry that re-runs only that step. Driven through the real window.
+///
+[NotInParallel]
+public class DeleteRetryTests
+{
+ [Test]
+ public async Task An_origin_branch_that_had_already_gone_reports_success_and_offers_no_retry()
+ {
+ using var world = new TestRepoWorld();
+ var origin = world.CreateOrigin("Foo", "Foo");
+ var root = world.SearchRoot("root");
+ var clone = world.Clone(origin, root, "Foo");
+ var worktree = world.AddWorktree(clone, "feature/x");
+ world.PushBranch(worktree, "feature/x");
+
+ // Someone already deleted the branch on the server between the scan and the delete — git fails the
+ // push, but origin no longer has the branch, which is exactly what was asked for.
+ var git = new GitService((dir, args, ct) =>
+ HasSubcommand(args, "push", "origin")
+ ? Task.FromResult(new ProcessResult(1, "",
+ "error: unable to delete 'feature/x': remote ref does not exist\n"
+ + "error: failed to push some refs to 'https://github.com/acme/app.git'"))
+ : ProcessRunner.RunAsync("git", args, dir, ct));
+
+ var rider = new FakeEditorLauncher();
+ var dialogs = new FakeDialogService();
+ var services = world.BuildServices([root], rider, dialogs, git: git, gitHub: FakeGitHub.None);
+
+ await Harness.WithWindow(services, async window =>
+ {
+ await window.Discover("feature/x");
+ await window.RequestDeleteAsync();
+ window.SetChecked("DeleteRemoteCheck", true);
+ await window.ConfirmDeleteAsync();
+ Screenshots.Save(window, "D-delete-remote-already-gone");
+ var vm = window.Vm();
+
+ // The local cleanup really happened, and the log says so with a ✓ — not the ⚠ this used to earn.
+ var check = new GitService();
+ await Assert.That(Directory.Exists(worktree)).IsFalse();
+ await Assert.That(await check.LocalBranchExistsAsync(clone, "feature/x")).IsFalse();
+ await Assert.That(window.LogText())
+ .Contains("✓ Removed worktree & branch 'feature/x' — origin/feature/x was already gone.");
+ await Assert.That(window.LogText()).DoesNotContain("could not be deleted");
+
+ // Nothing is outstanding, so no retry is offered.
+ await Assert.That(vm.IsDeleteRetryPending).IsFalse();
+ });
+ }
+
+ [Test]
+ public async Task A_failed_origin_delete_keeps_the_local_result_and_offers_a_retry_that_finishes_it()
+ {
+ using var world = new TestRepoWorld();
+ var origin = world.CreateOrigin("Foo", "Foo");
+ var root = world.SearchRoot("root");
+ var clone = world.Clone(origin, root, "Foo");
+ var worktree = world.AddWorktree(clone, "feature/x");
+ world.PushBranch(worktree, "feature/x");
+
+ // The first push --delete is refused; every later one runs for real, so the retry can succeed.
+ var pushes = 0;
+ var git = new GitService((dir, args, ct) =>
+ {
+ if (!HasSubcommand(args, "push", "origin")) return ProcessRunner.RunAsync("git", args, dir, ct);
+ return ++pushes == 1
+ ? Task.FromResult(new ProcessResult(1, "", "! [remote rejected] feature/x (pre-receive hook declined)"))
+ : ProcessRunner.RunAsync("git", args, dir, ct);
+ });
+
+ var rider = new FakeEditorLauncher();
+ var dialogs = new FakeDialogService();
+ var services = world.BuildServices([root], rider, dialogs, git: git, gitHub: FakeGitHub.None);
+
+ await Harness.WithWindow(services, async window =>
+ {
+ await window.Discover("feature/x");
+ await window.RequestDeleteAsync();
+ window.SetChecked("DeleteRemoteCheck", true);
+ await window.ConfirmDeleteAsync();
+ Screenshots.Save(window, "D-delete-retry-offered");
+ var vm = window.Vm();
+ var check = new GitService();
+
+ // The worktree and local branch went; only origin is still there — and the report says exactly that.
+ await Assert.That(Directory.Exists(worktree)).IsFalse();
+ await Assert.That(await check.LocalBranchExistsAsync(clone, "feature/x")).IsFalse();
+ await Assert.That(await check.RemoteHasBranchAsync(clone, "feature/x")).IsTrue();
+ await Assert.That(window.LogText()).Contains("Removed worktree & branch 'feature/x', but origin/feature/x could not be deleted");
+
+ // The retry strip is armed with what's left — and survives the results emptying, since deleting
+ // the only card dropped the delete row with it.
+ await Assert.That(vm.IsDeleteRetryPending).IsTrue();
+ await Assert.That(vm.ShowDeleteRow).IsFalse();
+ await Assert.That(vm.DeleteRetryHeadline).Contains("Couldn't delete origin/feature/x");
+ await Assert.That(vm.DeleteRetryHeadline).Contains("that part is done");
+ await Assert.That(vm.DeleteRetryDetail).Contains("pre-receive hook declined");
+
+ // Retrying re-runs only the outstanding step…
+ await window.RetryDeleteAsync();
+
+ await Assert.That(pushes).IsEqualTo(2);
+ await Assert.That(await check.RemoteHasBranchAsync(clone, "feature/x")).IsFalse();
+ // …and the report covers the whole attempt, not just this pass.
+ await Assert.That(window.LogText()).Contains("✓ Removed worktree & branch 'feature/x' + origin/feature/x.");
+ await Assert.That(vm.IsDeleteRetryPending).IsFalse();
+ });
+ }
+
+ [Test]
+ public async Task A_retry_that_fails_again_stays_on_offer()
+ {
+ using var world = new TestRepoWorld();
+ var origin = world.CreateOrigin("Foo", "Foo");
+ var root = world.SearchRoot("root");
+ var clone = world.Clone(origin, root, "Foo");
+ var worktree = world.AddWorktree(clone, "feature/x");
+ world.PushBranch(worktree, "feature/x");
+
+ var pushes = 0;
+ var git = new GitService((dir, args, ct) =>
+ {
+ if (!HasSubcommand(args, "push", "origin")) return ProcessRunner.RunAsync("git", args, dir, ct);
+ pushes++;
+ return Task.FromResult(new ProcessResult(1, "", "! [remote rejected] feature/x (pre-receive hook declined)"));
+ });
+
+ var rider = new FakeEditorLauncher();
+ var dialogs = new FakeDialogService();
+ var services = world.BuildServices([root], rider, dialogs, git: git, gitHub: FakeGitHub.None);
+
+ await Harness.WithWindow(services, async window =>
+ {
+ await window.Discover("feature/x");
+ await window.RequestDeleteAsync();
+ window.SetChecked("DeleteRemoteCheck", true);
+ await window.ConfirmDeleteAsync();
+ await window.RetryDeleteAsync();
+ var vm = window.Vm();
+
+ await Assert.That(pushes).IsEqualTo(2);
+ await Assert.That(vm.IsDeleteRetryPending).IsTrue(); // still there, still offered
+
+ // Dismissing takes the offer away and touches nothing on disk.
+ window.DismissDeleteRetry();
+ var check = new GitService();
+ await Assert.That(vm.IsDeleteRetryPending).IsFalse();
+ await Assert.That(vm.DeleteRetryHeadline).IsEqualTo("");
+ await Assert.That(await check.RemoteHasBranchAsync(clone, "feature/x")).IsTrue();
+ await Assert.That(Directory.Exists(worktree)).IsFalse();
+ });
+ }
+
+ [Test]
+ public async Task A_declined_force_delete_leaves_the_whole_delete_on_offer_for_a_retry()
+ {
+ using var world = new TestRepoWorld();
+ var origin = world.CreateOrigin("Foo", "Foo");
+ var root = world.SearchRoot("root");
+ var clone = world.Clone(origin, root, "Foo");
+ var worktree = world.AddWorktree(clone, "feature/x");
+
+ // git can't remove the folder on the first attempt (a path too long); later attempts run for real.
+ var removes = 0;
+ var git = new GitService((dir, args, ct) =>
+ {
+ if (!HasSubcommand(args, "worktree", "remove")) return ProcessRunner.RunAsync("git", args, dir, ct);
+ return ++removes == 1
+ ? Task.FromResult(new ProcessResult(128, "", "error: unable to unlink: Filename too long"))
+ : ProcessRunner.RunAsync("git", args, dir, ct);
+ });
+
+ var rider = new FakeEditorLauncher();
+ var dialogs = new FakeDialogService(); // declines the disk-level force delete
+ var services = world.BuildServices([root], rider, dialogs, git: git);
+
+ await Harness.WithWindow(services, async window =>
+ {
+ await window.Discover("feature/x");
+ await window.RequestDeleteAsync();
+ await window.ConfirmDeleteAsync();
+ var vm = window.Vm();
+ var check = new GitService();
+
+ // Declining left everything in place — and the delete is offered back rather than lost.
+ await Assert.That(dialogs.ForceDeleteConfirmations.Count).IsEqualTo(1);
+ await Assert.That(Directory.Exists(worktree)).IsTrue();
+ await Assert.That(vm.IsDeleteRetryPending).IsTrue();
+ await Assert.That(vm.DeleteRetryHeadline).Contains("Couldn't delete the worktree");
+
+ // The retry runs the whole thing again — worktree and local branch, since neither has gone.
+ await window.RetryDeleteAsync();
+
+ await Assert.That(Directory.Exists(worktree)).IsFalse();
+ await Assert.That(await check.LocalBranchExistsAsync(clone, "feature/x")).IsFalse();
+ await Assert.That(vm.IsDeleteRetryPending).IsFalse();
+ await Assert.That(window.LogText()).Contains("✓ Removed worktree & branch 'feature/x'.");
+ await Assert.That(vm.Targets.Count).IsEqualTo(0);
+ });
+ }
+
+ [Test]
+ public async Task A_fresh_scan_clears_a_stale_retry_offer()
+ {
+ using var world = new TestRepoWorld();
+ var origin = world.CreateOrigin("Foo", "Foo");
+ var root = world.SearchRoot("root");
+ var clone = world.Clone(origin, root, "Foo");
+ var worktree = world.AddWorktree(clone, "feature/x");
+ world.PushBranch(worktree, "feature/x");
+ world.AddWorktree(clone, "feature/y");
+
+ var git = new GitService((dir, args, ct) =>
+ HasSubcommand(args, "push", "origin")
+ ? Task.FromResult(new ProcessResult(1, "", "! [remote rejected] feature/x (pre-receive hook declined)"))
+ : ProcessRunner.RunAsync("git", args, dir, ct));
+
+ var rider = new FakeEditorLauncher();
+ var dialogs = new FakeDialogService();
+ var services = world.BuildServices([root], rider, dialogs, git: git, gitHub: FakeGitHub.None);
+
+ await Harness.WithWindow(services, async window =>
+ {
+ await window.Discover("feature/x");
+ await window.RequestDeleteAsync();
+ window.SetChecked("DeleteRemoteCheck", true);
+ await window.ConfirmDeleteAsync();
+ await Assert.That(window.Vm().IsDeleteRetryPending).IsTrue();
+
+ // A new branch on the screen has nothing to do with the last one's leftovers.
+ await window.Discover("feature/y");
+ await Assert.That(window.Vm().IsDeleteRetryPending).IsFalse();
+ });
+ }
+
+ /// True when contains immediately followed by
+ /// — used to spot the git subcommand under any leading -c key=value flags.
+ private static bool HasSubcommand(IReadOnlyList args, string first, string second)
+ {
+ for (var i = 0; i + 1 < args.Count; i++)
+ if (args[i] == first && args[i + 1] == second) return true;
+ return false;
+ }
+}
diff --git a/tests/Fido.Tests/Services/DeletionReportTests.cs b/tests/Fido.Tests/Services/DeletionReportTests.cs
new file mode 100644
index 0000000..622a35f
--- /dev/null
+++ b/tests/Fido.Tests/Services/DeletionReportTests.cs
@@ -0,0 +1,169 @@
+using Fido.Models;
+using Fido.Services;
+
+namespace Fido.Tests.Services;
+
+///
+/// The words a finished delete is reported with. The rule under test: a ⚠ is spent only on something the
+/// user asked for that is still there. A branch origin had already lost, or a worktree folder that
+/// had already gone, is part of a ✓ — the report that used to read as a failure while the local cleanup had
+/// in fact succeeded is what this replaces.
+///
+public class DeletionReportTests
+{
+ private const string Branch = "feature/x";
+
+ private static WorktreeDeletionOutcome Outcome(params WorktreeDeletionStep[] steps) => new(steps);
+
+ private static WorktreeDeletionStep Step(DeletionTarget target, DeletionStepStatus status, string detail = "") =>
+ new(target, status, detail);
+
+ [Test]
+ public async Task Reports_a_clean_local_delete()
+ {
+ var outcome = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Deleted));
+
+ await Assert.That(DeletionReport.Summary(outcome, Branch))
+ .IsEqualTo("✓ Removed worktree & branch 'feature/x'.");
+ }
+
+ [Test]
+ public async Task Reports_the_origin_branch_when_it_went_too()
+ {
+ var outcome = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.RemoteBranch, DeletionStepStatus.Deleted));
+
+ await Assert.That(DeletionReport.Summary(outcome, Branch))
+ .IsEqualTo("✓ Removed worktree & branch 'feature/x' + origin/feature/x.");
+ }
+
+ [Test]
+ public async Task An_origin_branch_that_was_already_gone_is_a_tick_not_a_warning()
+ {
+ // The reported case: `git push --delete` failed with "remote ref does not exist" while the worktree
+ // and local branch went perfectly. Nothing was left behind, so nothing is flagged.
+ var outcome = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.RemoteBranch, DeletionStepStatus.AlreadyGone, "remote ref does not exist"));
+
+ var summary = DeletionReport.Summary(outcome, Branch);
+
+ await Assert.That(summary)
+ .IsEqualTo("✓ Removed worktree & branch 'feature/x' — origin/feature/x was already gone.");
+ await Assert.That(summary).DoesNotContain("⚠");
+ }
+
+ [Test]
+ public async Task A_worktree_that_was_already_gone_is_noted_alongside_what_did_go()
+ {
+ var outcome = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.AlreadyGone, "is not a working tree"),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Deleted));
+
+ await Assert.That(DeletionReport.Summary(outcome, Branch))
+ .IsEqualTo("✓ Removed branch 'feature/x' — the worktree folder was already gone.");
+ }
+
+ [Test]
+ public async Task Nothing_left_to_remove_still_reads_as_success()
+ {
+ var outcome = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.AlreadyGone),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.AlreadyGone));
+
+ await Assert.That(DeletionReport.Summary(outcome, Branch)).StartsWith("✓ Nothing left to remove — ");
+ }
+
+ [Test]
+ public async Task A_real_failure_names_what_survived_what_went_and_offers_the_retry()
+ {
+ var outcome = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.RemoteBranch, DeletionStepStatus.Failed, "remote: permission denied"));
+
+ var summary = DeletionReport.Summary(outcome, Branch);
+
+ await Assert.That(summary).StartsWith("⚠ ");
+ await Assert.That(summary).Contains("Removed worktree & branch 'feature/x'");
+ await Assert.That(summary).Contains("origin/feature/x could not be deleted");
+ await Assert.That(summary).Contains("Retry");
+ }
+
+ [Test]
+ public async Task Two_failures_are_listed_together()
+ {
+ var outcome = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Failed, "still in use"),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Failed, "checked out"));
+
+ var summary = DeletionReport.Summary(outcome, Branch);
+
+ await Assert.That(summary).StartsWith("⚠ Couldn't delete the worktree and branch 'feature/x'");
+ }
+
+ [Test]
+ public async Task The_retry_strip_leads_with_what_is_still_there_and_credits_what_went()
+ {
+ var outcome = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.RemoteBranch, DeletionStepStatus.Failed, "remote: permission denied"));
+
+ var outstanding = outcome.Outstanding(WorktreeDeletionChoice.All);
+ await Assert.That(DeletionReport.RetryHeadline(outcome, outstanding, Branch))
+ .IsEqualTo("Couldn't delete origin/feature/x. Removed worktree & branch 'feature/x' — that part is done.");
+ await Assert.That(DeletionReport.RetryDetail(outcome)).IsEqualTo("remote: permission denied");
+ }
+
+ [Test]
+ public async Task A_delete_that_fell_over_before_reporting_anything_still_names_what_is_outstanding()
+ {
+ // Nothing ran — an exception escaped the first step — so there are no failed steps to name from;
+ // the headline has to come from what was asked for and isn't gone.
+ var outstanding = WorktreeDeletionOutcome.Nothing.Outstanding(WorktreeDeletionChoice.All);
+
+ await Assert.That(DeletionReport.RetryHeadline(WorktreeDeletionOutcome.Nothing, outstanding, Branch))
+ .IsEqualTo("Couldn't delete the worktree, branch 'feature/x' and origin/feature/x.");
+ }
+
+ [Test]
+ public async Task A_retry_that_finishes_the_job_reports_the_whole_attempt()
+ {
+ var first = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.RemoteBranch, DeletionStepStatus.Failed, "connection reset"));
+ var retry = Outcome(Step(DeletionTarget.RemoteBranch, DeletionStepStatus.Deleted));
+
+ var merged = first.Merge(retry);
+
+ // The retry only touched origin, but the summary still covers what the first pass removed.
+ await Assert.That(DeletionReport.Summary(merged, Branch))
+ .IsEqualTo("✓ Removed worktree & branch 'feature/x' + origin/feature/x.");
+ await Assert.That(merged.AnyFailed).IsFalse();
+ await Assert.That(merged.Outstanding(WorktreeDeletionChoice.All).AnySelected).IsFalse();
+ }
+
+ [Test]
+ public async Task A_skipped_step_in_a_retry_never_forgets_what_the_first_pass_did()
+ {
+ var first = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Deleted),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Failed, "checked out elsewhere"));
+ var retry = Outcome(
+ Step(DeletionTarget.Worktree, DeletionStepStatus.Skipped),
+ Step(DeletionTarget.LocalBranch, DeletionStepStatus.Deleted));
+
+ var merged = first.Merge(retry);
+
+ await Assert.That(merged.WorktreeRemoved).IsTrue();
+ await Assert.That(merged.StatusOf(DeletionTarget.Worktree)).IsEqualTo(DeletionStepStatus.Deleted);
+ await Assert.That(merged.LocalBranchDeleted).IsTrue();
+ }
+}
diff --git a/tests/Fido.Tests/Services/GitAlreadyGoneTests.cs b/tests/Fido.Tests/Services/GitAlreadyGoneTests.cs
new file mode 100644
index 0000000..99347bf
--- /dev/null
+++ b/tests/Fido.Tests/Services/GitAlreadyGoneTests.cs
@@ -0,0 +1,67 @@
+using Fido.Services;
+
+namespace Fido.Tests.Services;
+
+///
+/// Telling "there was nothing to delete" apart from "it's still there". The messages are git 2.43's own,
+/// captured from a real repository; the narrow-by-default rule means anything unlisted stays a failure.
+///
+public class GitAlreadyGoneTests
+{
+ private static ProcessResult Failed(string stderr) => new(1, "", stderr);
+
+ [Test]
+ public async Task A_remote_ref_that_does_not_exist_means_the_branch_had_already_gone()
+ {
+ var result = Failed(
+ "error: unable to delete 'claude/shine-compliance': remote ref does not exist\n"
+ + "error: failed to push some refs to 'https://github.com/acme/app.git'");
+
+ await Assert.That(GitAlreadyGone.RemoteBranch(result)).IsTrue();
+ }
+
+ [Test]
+ [Arguments("! [remote rejected] feature/x (protected branch hook declined)")]
+ [Arguments("fatal: could not read Username for 'https://github.com': terminal prompts disabled")]
+ [Arguments("fatal: 'origin' does not appear to be a git repository")]
+ public async Task A_remote_delete_that_really_failed_is_not_mistaken_for_an_absent_branch(string stderr)
+ {
+ await Assert.That(GitAlreadyGone.RemoteBranch(Failed(stderr))).IsFalse();
+ }
+
+ [Test]
+ public async Task A_branch_git_cannot_find_had_already_gone()
+ {
+ await Assert.That(GitAlreadyGone.LocalBranch(Failed("error: branch 'feature/x' not found"))).IsTrue();
+ }
+
+ [Test]
+ [Arguments("error: cannot delete branch 'feature/x' used by worktree at '/repo.worktrees/x'")]
+ [Arguments("fatal: Unable to create '/repo/.git/index.lock': File exists.")]
+ public async Task A_local_branch_delete_that_really_failed_is_not_mistaken_for_an_absent_branch(string stderr)
+ {
+ await Assert.That(GitAlreadyGone.LocalBranch(Failed(stderr))).IsFalse();
+ }
+
+ [Test]
+ public async Task A_path_git_does_not_know_as_a_worktree_had_already_gone()
+ {
+ await Assert.That(GitAlreadyGone.Worktree(Failed("fatal: '../wt' is not a working tree"))).IsTrue();
+ }
+
+ [Test]
+ public async Task A_worktree_git_refuses_to_remove_is_still_there()
+ {
+ await Assert.That(GitAlreadyGone.Worktree(
+ Failed("fatal: 'feature/x' contains modified or untracked files, use --force to delete it"))).IsFalse();
+ }
+
+ [Test]
+ public async Task A_successful_command_is_never_already_gone()
+ {
+ var ok = new ProcessResult(0, "", "");
+ await Assert.That(GitAlreadyGone.Worktree(ok)).IsFalse();
+ await Assert.That(GitAlreadyGone.LocalBranch(ok)).IsFalse();
+ await Assert.That(GitAlreadyGone.RemoteBranch(ok)).IsFalse();
+ }
+}
diff --git a/tests/Fido.Tests/Services/OpenerDeletionRetryTests.cs b/tests/Fido.Tests/Services/OpenerDeletionRetryTests.cs
index 0c1f5a6..235559c 100644
--- a/tests/Fido.Tests/Services/OpenerDeletionRetryTests.cs
+++ b/tests/Fido.Tests/Services/OpenerDeletionRetryTests.cs
@@ -21,9 +21,9 @@ public class OpenerDeletionRetryTests
BackoffType = DelayBackoffType.Constant,
};
- private static WorktreeDeletion Plan() => new(
+ private static WorktreeDeletion Plan(string? worktreePath = null) => new(
MainWorktreePath: "/repo",
- WorktreePath: "/repo.worktrees/feature-x",
+ WorktreePath: worktreePath ?? "/repo.worktrees/feature-x",
Branch: "feature/x",
RemoteBranchExists: true,
OutstandingChanges: Array.Empty(),
@@ -101,6 +101,8 @@ public async Task Retries_a_transient_remote_delete_then_reports_the_remote_gone
[Test]
public async Task Does_not_retry_a_permanent_worktree_removal_failure()
{
+ // A real folder: git refusing to remove a worktree that's still on disk is the failure that throws.
+ var worktree = Directory.CreateTempSubdirectory("fido-wt-");
var removeCalls = 0;
GitService.GitCommandRunner runner = (_, args, _) =>
{
@@ -116,15 +118,132 @@ public async Task Does_not_retry_a_permanent_worktree_removal_failure()
WorktreeRemovalException? thrown = null;
try
{
- await Opener(runner).DeleteWorktreeAsync(Plan(), WorktreeDeletionChoice.All);
+ await Opener(runner).DeleteWorktreeAsync(Plan(worktree.FullName), WorktreeDeletionChoice.All);
}
catch (WorktreeRemovalException ex)
{
thrown = ex; // a permanent worktree-remove failure throws so the caller can offer a force-delete
}
+ finally
+ {
+ worktree.Delete(recursive: true);
+ }
await Assert.That(thrown).IsNotNull();
- await Assert.That(thrown!.WorktreePath).IsEqualTo("/repo.worktrees/feature-x");
+ await Assert.That(thrown!.WorktreePath).IsEqualTo(worktree.FullName);
await Assert.That(removeCalls).IsEqualTo(1); // one attempt, no wasted retries
}
+
+ [Test]
+ public async Task A_remote_branch_that_was_already_gone_counts_as_success_not_failure()
+ {
+ // Someone (a teammate, GitHub's delete-on-merge) already removed the branch on origin. git fails the
+ // push, but the end state is exactly the one asked for — reporting it as a failure was the bug.
+ var pushCalls = 0;
+ var log = new List();
+ GitService.GitCommandRunner runner = (_, args, _) =>
+ {
+ if (!Matches(args, "push")) return Task.FromResult(new ProcessResult(0, "", ""));
+ pushCalls++;
+ return Task.FromResult(new ProcessResult(1, "",
+ "error: unable to delete 'feature/x': remote ref does not exist\n"
+ + "error: failed to push some refs to 'https://github.com/acme/app.git'"));
+ };
+
+ var outcome = await Opener(runner, log).DeleteWorktreeAsync(Plan(), WorktreeDeletionChoice.All);
+
+ await Assert.That(pushCalls).IsEqualTo(1); // permanent — never retried
+ await Assert.That(outcome.RemoteDeleteFailed).IsFalse();
+ await Assert.That(outcome.RemoteBranchAlreadyGone).IsTrue();
+ await Assert.That(outcome.AnyFailed).IsFalse();
+ await Assert.That(outcome.Outstanding(WorktreeDeletionChoice.All).AnySelected).IsFalse();
+ // Narrated plainly — no [!] marker, which the flight log would colour as a failure.
+ await Assert.That(log.Any(l => l.Contains("origin/feature/x was already gone"))).IsTrue();
+ await Assert.That(log.Any(l => l.StartsWith("[!]"))).IsFalse();
+ }
+
+ [Test]
+ public async Task A_local_branch_that_was_already_gone_counts_as_success_not_failure()
+ {
+ GitService.GitCommandRunner runner = (_, args, _) => Task.FromResult(
+ Matches(args, "branch", "-D")
+ ? new ProcessResult(1, "", "error: branch 'feature/x' not found")
+ : new ProcessResult(0, "", ""));
+
+ var outcome = await Opener(runner).DeleteWorktreeAsync(Plan(), WorktreeDeletionChoice.All);
+
+ await Assert.That(outcome.StatusOf(DeletionTarget.LocalBranch)).IsEqualTo(DeletionStepStatus.AlreadyGone);
+ await Assert.That(outcome.LocalBranchDeleted).IsTrue(); // gone is gone
+ await Assert.That(outcome.AnyFailed).IsFalse();
+ }
+
+ [Test]
+ public async Task A_failed_local_branch_delete_no_longer_withholds_the_remote_one()
+ {
+ var pushCalls = 0;
+ GitService.GitCommandRunner runner = (_, args, _) =>
+ {
+ if (Matches(args, "branch", "-D"))
+ return Task.FromResult(new ProcessResult(1, "",
+ "error: cannot delete branch 'feature/x' used by worktree at '/elsewhere'"));
+ if (Matches(args, "push")) pushCalls++;
+ return Task.FromResult(new ProcessResult(0, "", ""));
+ };
+
+ var outcome = await Opener(runner).DeleteWorktreeAsync(Plan(), WorktreeDeletionChoice.All);
+
+ await Assert.That(pushCalls).IsEqualTo(1); // the remote delete still ran…
+ await Assert.That(outcome.RemoteBranchDeleted).IsTrue();
+ await Assert.That(outcome.WorktreeRemoved).IsTrue();
+ await Assert.That(outcome.StatusOf(DeletionTarget.LocalBranch)).IsEqualTo(DeletionStepStatus.Failed);
+
+ // …and only the branch that's still there is offered for retry.
+ var outstanding = outcome.Outstanding(WorktreeDeletionChoice.All);
+ await Assert.That(outstanding.LocalBranch).IsTrue();
+ await Assert.That(outstanding.Worktree).IsFalse();
+ await Assert.That(outstanding.RemoteBranch).IsFalse();
+ }
+
+ [Test]
+ public async Task A_worktree_git_no_longer_knows_about_is_pruned_and_counts_as_already_gone()
+ {
+ var pruned = false;
+ GitService.GitCommandRunner runner = (_, args, _) =>
+ {
+ if (Matches(args, "worktree", "remove"))
+ return Task.FromResult(new ProcessResult(128, "", "fatal: '/repo.worktrees/feature-x' is not a working tree"));
+ if (Matches(args, "worktree", "prune")) pruned = true;
+ return Task.FromResult(new ProcessResult(0, "", ""));
+ };
+
+ var outcome = await Opener(runner).DeleteWorktreeAsync(Plan(), WorktreeDeletionChoice.All);
+
+ await Assert.That(pruned).IsTrue(); // the stale registration was cleared…
+ await Assert.That(outcome.StatusOf(DeletionTarget.Worktree)).IsEqualTo(DeletionStepStatus.AlreadyGone);
+ await Assert.That(outcome.LocalBranchDeleted).IsTrue(); // …so the branch deletions still ran
+ await Assert.That(outcome.RemoteBranchDeleted).IsTrue();
+ await Assert.That(outcome.AnyFailed).IsFalse();
+ }
+
+ [Test]
+ public async Task A_remote_delete_that_genuinely_fails_leaves_only_that_step_outstanding()
+ {
+ GitService.GitCommandRunner runner = (_, args, _) => Task.FromResult(
+ Matches(args, "push")
+ ? new ProcessResult(1, "", "! [remote rejected] feature/x (protected branch hook declined)")
+ : new ProcessResult(0, "", ""));
+
+ var outcome = await Opener(runner).DeleteWorktreeAsync(Plan(), WorktreeDeletionChoice.All);
+
+ await Assert.That(outcome.RemoteDeleteFailed).IsTrue();
+ await Assert.That(outcome.WorktreeRemoved).IsTrue();
+ await Assert.That(outcome.LocalBranchDeleted).IsTrue();
+ await Assert.That(outcome.Failures.Count).IsEqualTo(1);
+ await Assert.That(outcome.Failures[0].Detail).Contains("protected branch hook declined");
+
+ var outstanding = outcome.Outstanding(WorktreeDeletionChoice.All);
+ await Assert.That(outstanding.RemoteBranch).IsTrue();
+ await Assert.That(outstanding.Worktree).IsFalse();
+ await Assert.That(outstanding.LocalBranch).IsFalse();
+ }
}