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 @@ + + + + + + + + +