Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion Docs/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`** /
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) |
Expand Down
17 changes: 8 additions & 9 deletions src/Models/WorktreeDeletionChoice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@ public sealed record WorktreeDeletionChoice(bool Worktree, bool LocalBranch, boo

/// <summary>Everything ticked — the default when all three targets are present.</summary>
public static WorktreeDeletionChoice All { get; } = new(true, true, true);
}

/// <summary>What a delete actually removed, so the caller can report it accurately.</summary>
public sealed record WorktreeDeletionOutcome(
bool WorktreeRemoved,
bool LocalBranchDeleted,
bool RemoteBranchDeleted,
bool RemoteDeleteFailed)
{
public bool AnyDeleted => WorktreeRemoved || LocalBranchDeleted || RemoteBranchDeleted;
/// <summary>True when <paramref name="target"/> is ticked — the selection read one target at a time.</summary>
public bool Includes(DeletionTarget target) => target switch
{
DeletionTarget.Worktree => Worktree,
DeletionTarget.LocalBranch => LocalBranch,
DeletionTarget.RemoteBranch => RemoteBranch,
_ => false,
};
}
121 changes: 121 additions & 0 deletions src/Models/WorktreeDeletionOutcome.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
namespace Fido.Models;

/// <summary>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.</summary>
public enum DeletionTarget
{
/// <summary>The linked worktree folder.</summary>
Worktree,

/// <summary>The local branch the worktree had checked out.</summary>
LocalBranch,

/// <summary>The branch on <c>origin</c>.</summary>
RemoteBranch,
}

/// <summary>How one deletion step ended.</summary>
public enum DeletionStepStatus
{
/// <summary>Never attempted — the user didn't tick it, or there was nothing to act on.</summary>
Skipped,

/// <summary>git removed it.</summary>
Deleted,

/// <summary>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.</summary>
AlreadyGone,

/// <summary>git couldn't remove it and it's still there. The only status worth retrying.</summary>
Failed,
}

/// <summary>One target's result, carrying git's message when it failed (or when it was already gone).</summary>
/// <param name="Target">Which of the three things this step acted on.</param>
/// <param name="Status">How it ended.</param>
/// <param name="Detail">git's stderr/stdout for a failed or already-gone step; empty otherwise.</param>
public sealed record WorktreeDeletionStep(DeletionTarget Target, DeletionStepStatus Status, string Detail = "")
{
/// <summary>True when the target is no longer there — whether this step removed it or found it gone.</summary>
public bool IsGone => Status is DeletionStepStatus.Deleted or DeletionStepStatus.AlreadyGone;

/// <summary>True when the target is still there and the step is worth retrying.</summary>
public bool IsFailed => Status is DeletionStepStatus.Failed;
}

/// <summary>
/// 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 <em>already</em> gone is reported as success
/// rather than as a failure — deleting a branch the server no longer has leaves things exactly as asked.
/// </summary>
/// <param name="Steps">One entry per target that was considered, in the order they ran.</param>
public sealed record WorktreeDeletionOutcome(IReadOnlyList<WorktreeDeletionStep> Steps)
{
/// <summary>An outcome that did nothing at all — the seed for merging, and the "declined" result.</summary>
public static WorktreeDeletionOutcome Nothing { get; } = new([]);

/// <summary>This run's step for <paramref name="target"/>, or null when it wasn't considered.</summary>
public WorktreeDeletionStep? StepFor(DeletionTarget target) => Steps.FirstOrDefault(s => s.Target == target);

/// <summary>How <paramref name="target"/> ended, treating "never considered" as <see cref="DeletionStepStatus.Skipped"/>.</summary>
public DeletionStepStatus StatusOf(DeletionTarget target) => StepFor(target)?.Status ?? DeletionStepStatus.Skipped;

/// <summary>True when <paramref name="target"/> is no longer there (deleted now, or already gone).</summary>
public bool IsGone(DeletionTarget target) => StepFor(target)?.IsGone == true;

/// <summary>True when the worktree folder is gone.</summary>
public bool WorktreeRemoved => IsGone(DeletionTarget.Worktree);

/// <summary>True when the local branch is gone.</summary>
public bool LocalBranchDeleted => IsGone(DeletionTarget.LocalBranch);

/// <summary>True when <em>this</em> run deleted the branch on <c>origin</c>.</summary>
public bool RemoteBranchDeleted => StatusOf(DeletionTarget.RemoteBranch) is DeletionStepStatus.Deleted;

/// <summary>True when <c>origin</c> had already lost the branch — nothing to delete, and no failure.</summary>
public bool RemoteBranchAlreadyGone => StatusOf(DeletionTarget.RemoteBranch) is DeletionStepStatus.AlreadyGone;

/// <summary>True when the branch on <c>origin</c> is still there because the delete failed.</summary>
public bool RemoteDeleteFailed => StatusOf(DeletionTarget.RemoteBranch) is DeletionStepStatus.Failed;

/// <summary>True when at least one target was actually removed by this run.</summary>
public bool AnyDeleted => Steps.Any(s => s.Status is DeletionStepStatus.Deleted);

/// <summary>Everything still standing that the run tried and failed to remove — what a retry would re-run.</summary>
public IReadOnlyList<WorktreeDeletionStep> Failures => [.. Steps.Where(s => s.IsFailed)];

/// <summary>True when something the user asked for is still there.</summary>
public bool AnyFailed => Steps.Any(s => s.IsFailed);

/// <summary>
/// What a retry should run: everything <paramref name="asked"/> 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.
/// </summary>
public WorktreeDeletionChoice Outstanding(WorktreeDeletionChoice asked) => new(
Worktree: asked.Worktree && !IsGone(DeletionTarget.Worktree),
LocalBranch: asked.LocalBranch && !IsGone(DeletionTarget.LocalBranch),
RemoteBranch: asked.RemoteBranch && !IsGone(DeletionTarget.RemoteBranch));

/// <summary>
/// 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
/// <paramref name="later"/> never overwrite — a retry that only re-ran the remote delete must not forget
/// that the worktree and local branch already went.
/// </summary>
public WorktreeDeletionOutcome Merge(WorktreeDeletionOutcome later)
{
var merged = new List<WorktreeDeletionStep>(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);
}
}
112 changes: 112 additions & 0 deletions src/Services/DeletionReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Collections.Generic;
using System.Linq;
using Fido.Models;

namespace Fido.Services;

/// <summary>
/// Turns a <see cref="WorktreeDeletionOutcome"/> 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.
/// <para>The rule that matters: the line only carries a ⚠ when something the user asked for is <em>still
/// there</em>. 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.</para>
/// </summary>
public static class DeletionReport
{
/// <summary>The flight-log line for a finished (or part-finished) delete: ✓ when everything asked for is
/// gone, ⚠ — with the retry offer — when something survived.</summary>
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.";
}

/// <summary>
/// The retry strip's headline: what is still standing, and what already went, in one sentence. Named from
/// <paramref name="outstanding"/> 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.
/// </summary>
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;
}

/// <summary>git's own words for the failed steps — the detail line under the retry strip's headline.</summary>
public static string RetryDetail(WorktreeDeletionOutcome outcome) =>
string.Join("\n", outcome.Failures.Select(f => f.Detail).Where(d => d.Length > 0));

/// <summary>"Removed worktree &amp; branch 'x' + origin/x" for whatever this run actually deleted;
/// empty when it deleted nothing.</summary>
private static string Removed(WorktreeDeletionOutcome outcome, string branch)
{
var local = new List<string>();
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;
}

/// <summary>The "nothing to do here" notes — one per target that had already gone.</summary>
private static List<string> AlreadyGoneNotes(WorktreeDeletionOutcome outcome, string branch)
{
var notes = new List<string>();
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;
}

/// <summary>Just the targets that failed, as a selection — so failures and outstanding work are named
/// by the same code.</summary>
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);

/// <summary>A selection named as the user knows it ("the worktree, branch 'x' and origin/x").</summary>
private static string Names(WorktreeDeletionChoice choice, string branch)
{
var names = new List<string>();
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],
};
}
}
Loading
Loading