From c91302dff6eabd6115610e659d6180f8fd575f76 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 14:31:09 +0000 Subject: [PATCH] Report a part-way worktree delete honestly, and offer a retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a worktree whose branch had already gone from origin reported a failure. The flight log showed the red `[!] Remote branch origin/ could not be deleted: ... remote ref does not exist` followed by `⚠ Removed worktree & branch '', but origin/ could not be deleted — see log.`, though the worktree and the local branch had been removed cleanly and origin no longer had the branch either. Everything the user asked for had happened; only the report said otherwise. The three targets a delete removes — the worktree, the local branch, the branch on origin — are now attempted and reported on separately. Each step returns a WorktreeDeletionStep (Deleted / AlreadyGone / Failed / Skipped) rather than a bool, so: - A target that had already gone counts as success. GitAlreadyGone recognises git's own wording for "there was nothing to delete" ("remote ref does not exist", "branch 'x' not found", "is not a working tree"); a stale worktree registration is pruned so the branch is still free to delete. Deliberately narrow — anything unlisted stays a failure, which is the safe way round. - A failed step no longer abandons the ones after it. A local branch delete that fails used to throw and take the origin delete with it; both now run and report independently. - DeletionReport composes the closing line from the steps, so a ⚠ is spent only on something asked for that is genuinely still there. Whatever is still standing is offered back as an inline Retry strip: what's left, git's words for why, and Retry / Dismiss. Retrying re-runs only the outstanding steps (Outstanding(choice) over the merged outcome), and the report that follows covers the whole attempt rather than the last pass. The strip sits outside the delete row so it outlives the card just deleted; Esc or Dismiss drops it, and a fresh scan clears it as stale. Declining the force-delete fallback now leaves the delete on offer instead of losing it. The one throw kept is a genuinely failed worktree removal — the caller still needs it to offer the Recycle-Bin-bypassing folder delete. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015S6Vfx6cChWYBdnmnV1pzH --- CHANGELOG.md | 15 ++ Docs/Features.md | 20 +- src/Models/WorktreeDeletionChoice.cs | 17 +- src/Models/WorktreeDeletionOutcome.cs | 121 +++++++++ src/Services/DeletionReport.cs | 112 ++++++++ src/Services/GitAlreadyGone.cs | 59 ++++ src/Services/OpenerService.cs | 164 +++++++---- src/ViewModels/MainWindowViewModel.cs | 52 ++++ src/Views/MainWindow.axaml | 23 ++ src/Views/MainWindow.axaml.cs | 148 ++++++++-- tests/Fido.Tests/E2E/DeleteRetryTests.cs | 254 ++++++++++++++++++ .../Services/DeletionReportTests.cs | 169 ++++++++++++ .../Services/GitAlreadyGoneTests.cs | 67 +++++ .../Services/OpenerDeletionRetryTests.cs | 127 ++++++++- 14 files changed, 1260 insertions(+), 88 deletions(-) create mode 100644 src/Models/WorktreeDeletionOutcome.cs create mode 100644 src/Services/DeletionReport.cs create mode 100644 src/Services/GitAlreadyGone.cs create mode 100644 tests/Fido.Tests/E2E/DeleteRetryTests.cs create mode 100644 tests/Fido.Tests/Services/DeletionReportTests.cs create mode 100644 tests/Fido.Tests/Services/GitAlreadyGoneTests.cs 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 @@ + + + + + + + + +