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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the ticked targets — **removing the linked worktree, deleting the local branch, and deleting the branch on
`origin`**. The work runs from the clone's main tree (so the worktree is dropped cleanly),
a dirty worktree is force-removed after the warning, and a failed remote delete leaves the completed local
cleanup in place and reports it. The button is offered **only for a linked worktree on a non-default branch**
cleanup in place and reports it. Each git step is **retried on transient failures** — a worktree file still
held by an editor or antivirus scan, a racing git ref `.lock`, or a network blip deleting the branch on
`origin` — a few times with backoff (narrated in the flight log) before it counts; permanent refusals
(`use --force`, `remote ref does not exist`) still fail fast. Git's worktree commands run with **long-path
support** (`core.longpaths`) so a deep tree that crosses Windows' 260-character `MAX_PATH` limit (deep
`node_modules`, generated output) can still be created and removed. If git **still** can't delete the folder,
Fido **offers to delete it permanently from disk** — a recursive removal that **bypasses the Recycle Bin**
(using an extended-length path so it isn't stopped by the same limit) — and then prunes git's now-dangling
worktree registration so the branch can be deleted too. The button is offered **only for a linked worktree on a non-default branch**
— the clone's main working tree can't be worktree-removed, and `main`/`master` are deliberately never
offered. Nothing is deleted unless you confirm; Cancel, Enter, and Esc all back out safely, and the
destructive button is out of the keyboard tab order so it can't be triggered by a stray keypress.
Expand Down
18 changes: 17 additions & 1 deletion Docs/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,22 @@ tidying up a branch you're finished with, in one step:
**deletes the local branch**, and — when it exists — **deletes the branch on `origin`**. The git steps run
from the clone's **main working tree**, so the worktree is dropped cleanly; a dirty worktree is
force-removed after the warning.
- If the remote delete fails (say you're offline), the completed **local** cleanup stays done and the
- Each git step is **retried on transient failures** so a fleeting hiccup doesn't leave a half-tidied branch:
a worktree file still held open by an editor or antivirus scan (common on Windows), a git ref/index `.lock`
left by a racing git process, or a network blip while deleting the branch on `origin`. Fido retries a few
times with a short, backing-off wait — each attempt narrated in the flight log — while **permanent** refusals
(`use --force to delete`, `remote ref does not exist`, "not fully merged") still fail fast on the first try.
- **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`** / **`unable to unlink … Filename too long`**, leaving the worktree stuck. Fido guards
against this two ways. First, git's worktree commands run with **long-path support** (`core.longpaths`) so
git's own file operations use the Windows extended-length API and can remove those files. Second, if git
**still** can't delete the folder (a path too long even for that), Fido **offers to delete it straight from
disk**: a recursive removal that **bypasses the Recycle Bin** and uses an extended-length (`\\?\`) path so it
isn't defeated by the same limit. Once the folder is gone Fido **prunes** git's dangling worktree registration
and carries on with the branch deletions. It's an explicit, clearly-labelled confirmation — nothing is
force-deleted unless you choose it, and backing out leaves everything in place.
- If the remote delete fails for good (say you're offline), the completed **local** cleanup stays done and the
failure is reported in the flight log rather than rolled back.

The button is offered **only for a linked worktree on a non-default branch** — a clone's **main working
Expand Down Expand Up @@ -322,6 +337,7 @@ the next save writes to the new location.
| Branch-only mode | Open from an existing checkout, or place the branch into a configured repo that already has it |
| Cross-clone reuse | Never creates a second worktree for a branch already checked out |
| Placement | Switch main tree **or** create a linked worktree |
| Delete worktree | Remove worktree + local/`origin` branch; retries transient failures; long-path aware with a Recycle-Bin-bypassing force-delete for **`filename too long`** |
| Open target | `.sln` / `.slnx` / `.slnf` solution, or the repo folder |
| Editors | Rider / WebStorm / VS Code / Visual Studio / Zed / Custom — default + Ctrl+1…9, or by CLI slug |
| Folder targets | **Console** (`term`) opens a terminal, **File Explorer** (`files`) the OS file manager — Windows / macOS / Linux |
Expand Down
4 changes: 4 additions & 0 deletions src/Fido.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<!-- Polly.Core is the lean, Native-AOT/trim-safe core (no DI/reflection) — used to retry the
transient failures git's deletion commands hit (a still-locked worktree file, a racing ref
.lock, a network blip deleting the origin branch). -->
<PackageReference Include="Polly.Core" Version="8.7.0" />
</ItemGroup>

</Project>
10 changes: 10 additions & 0 deletions src/Models/WorktreeForceDelete.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Fido.Models;

/// <summary>
/// A prompt to permanently delete a worktree folder that <c>git worktree remove</c> couldn't — most often
/// because a path inside it is too long for the OS. The delete is a direct, recursive removal from disk: it
/// <em>bypasses the Recycle Bin</em> and can't be undone.
/// </summary>
/// <param name="WorktreePath">Absolute path of the worktree folder to delete.</param>
/// <param name="Reason">The git failure that prompted the offer, shown so the user knows why it's needed.</param>
public sealed record WorktreeForceDelete(string WorktreePath, string Reason);
3 changes: 3 additions & 0 deletions src/Services/AvaloniaDialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public sealed class AvaloniaDialogService : IDialogService
public Task<WorktreeDeletionChoice?> ConfirmDeleteWorktreeAsync(WorktreeDeletion plan)
=> new DeleteWorktreeDialog(plan).ShowDialog<WorktreeDeletionChoice?>(_owner);

public Task<bool> ConfirmForceDeleteWorktreeFolderAsync(WorktreeForceDelete request)
=> new ForceDeleteDialog(request).ShowDialog<bool>(_owner);

public Task<OpenDecision?> ShowDecisionAsync(RepositoryInfo repo, string branch, MainContext context)
=> new DecisionDialog(repo, branch, context).ShowDialog<OpenDecision?>(_owner);

Expand Down
179 changes: 179 additions & 0 deletions src/Services/GitRetry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
using System;
using System.Threading;
using Polly;
using Polly.Retry;

namespace Fido.Services;

/// <summary>Tuning for <see cref="GitRetry"/>'s deletion pipeline. The defaults suit an interactive desktop
/// cleanup — a few quick, backing-off retries — and tests dial the delay down to zero to stay fast.</summary>
public sealed record GitRetryOptions
{
/// <summary>How many times to re-run a transiently-failing command (on top of the first attempt).</summary>
public int MaxRetryAttempts { get; init; } = 3;

/// <summary>Base wait before the first retry; later waits grow per <see cref="BackoffType"/>.</summary>
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(250);

/// <summary>Growth of the wait between retries. Exponential by default (0.25s, 0.5s, 1s …).</summary>
public DelayBackoffType BackoffType { get; init; } = DelayBackoffType.Exponential;

/// <summary>Spread the waits a little so parallel callers don't all retry in lockstep.</summary>
public bool UseJitter { get; init; } = true;

/// <summary>The profile used for worktree/branch deletion in production.</summary>
public static GitRetryOptions Default { get; } = new();
}

/// <summary>One retry about to happen, surfaced so the caller can narrate the wait in the flight log.</summary>
/// <param name="Operation">Human label for the command being retried (e.g. <c>"worktree remove"</c>).</param>
/// <param name="AttemptNumber">Zero-based index of the attempt that just failed (0 = the first try).</param>
/// <param name="RetryDelay">How long the pipeline will wait before the next attempt.</param>
/// <param name="Failure">The failed result that triggered the retry, if one was produced.</param>
public readonly record struct GitRetryAttempt(string Operation, int AttemptNumber, TimeSpan RetryDelay, ProcessResult? Failure);

/// <summary>
/// A Polly retry pipeline for the <em>transient</em> failures git's deletion commands hit — a worktree file
/// still held by an editor or antivirus scan (Windows especially), a git ref/index <c>.lock</c> left by a
/// racing git process, or a network blip while deleting the branch on <c>origin</c>. Only failures that look
/// transient (see <see cref="IsTransient"/>) are retried; a permanent failure ("use --force", "remote ref
/// does not exist") is returned on the first attempt so the caller's own handling — and the tests — stay fast.
/// </summary>
public static class GitRetry
{
/// <summary>Carries the operation label into <c>OnRetry</c> so the retry narration can name the command.</summary>
private static readonly ResiliencePropertyKey<string> OperationKey = new("fido.git.operation");

/// <summary>
/// Builds a reusable pipeline that retries transient git failures per <paramref name="options"/>. Each retry
/// invokes <paramref name="onRetry"/> (if given) so a UI can narrate the wait. The predicate never handles
/// exceptions, so cancellation propagates immediately rather than being retried.
/// </summary>
public static ResiliencePipeline<ProcessResult> BuildPipeline(GitRetryOptions options, Action<GitRetryAttempt>? onRetry = null)
{
return new ResiliencePipelineBuilder<ProcessResult>()
.AddRetry(new RetryStrategyOptions<ProcessResult>
{
ShouldHandle = static args =>
{
var result = args.Outcome.Result;
return new ValueTask<bool>(result is not null && !result.Success && IsTransient(result));
},
MaxRetryAttempts = options.MaxRetryAttempts,
Delay = options.Delay,
BackoffType = options.BackoffType,
UseJitter = options.UseJitter,
OnRetry = args =>
{
if (onRetry is not null)
{
var operation = args.Context.Properties.GetValue(OperationKey, "git");
onRetry(new GitRetryAttempt(operation, args.AttemptNumber, args.RetryDelay, args.Outcome.Result));
}
return default;
},
})
.Build();
}

/// <summary>
/// Runs <paramref name="action"/> through <paramref name="pipeline"/>, tagging the flow with
/// <paramref name="operation"/> so retries can be narrated, and threading the caller's cancellation token
/// down to each attempt.
/// </summary>
public static async Task<ProcessResult> ExecuteAsync(
ResiliencePipeline<ProcessResult> pipeline,
string operation,
Func<CancellationToken, Task<ProcessResult>> action,
CancellationToken ct = default)
{
var context = ResilienceContextPool.Shared.Get(ct);
context.Properties.Set(OperationKey, operation);
try
{
return await pipeline.ExecuteAsync(
static (ctx, state) => new ValueTask<ProcessResult>(state(ctx.CancellationToken)),
context,
action);
}
finally
{
ResilienceContextPool.Shared.Return(context);
}
}

/// <summary>
/// True when a failed git result looks worth retrying — a filesystem/lock hiccup or a network blip —
/// rather than a permanent refusal. Matches a curated set of markers in the command's stderr/stdout,
/// case-insensitively. Always false for a successful result.
/// </summary>
public static bool IsTransient(ProcessResult result)
{
if (result.Success) return false;

var text = result.StdErr + "\n" + result.StdOut;
foreach (var marker in TransientMarkers)
if (text.Contains(marker, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}

/// <summary>
/// Substrings that flag a retryable failure. Two families:
/// <list type="bullet">
/// <item>Filesystem / lock contention — a worktree file still held open (an editor, an antivirus scan), a
/// file the OS still reports busy, or a git ref/index <c>.lock</c> left by a racing git process. On Windows
/// a locked unlink surfaces as "Permission denied" / "Access is denied" / "being used by another process";
/// these clear on their own, so a brief retry usually wins.</item>
/// <item>Network — deleting the branch on <c>origin</c> over a flaky connection.</item>
/// </list>
/// Deliberately narrow so permanent refusals ("use --force to delete", "remote ref does not exist",
/// "not fully merged") are <em>not</em> matched and fail fast on the first attempt. In particular, lock
/// contention is matched by git's lock-<em>creation</em> phrasing ("cannot lock ref", "unable to create …
/// … .lock", "another git process seems to be running") rather than a bare "<c>.lock</c>" — which would
/// also match a permanent failure that merely <em>echoes a worktree path</em> containing ".lock" (a branch
/// like <c>fix.lockfile-bug</c>).
/// <para>Known over-matches, all bounded and non-fatal: a permanent remote <em>auth</em> failure — HTTP 403
/// ("unable to access" / "could not read from remote repository") or SSH "Permission denied (publickey)" —
/// is retried the full budget before the same report. Accepted: remote-delete failures don't roll back the
/// local cleanup, and the cost is a couple of seconds against catching the far more common file-lock and
/// transient-network cases.</para>
/// </summary>
private static readonly string[] TransientMarkers =
[
// Filesystem / lock contention.
"being used by another process",
"access is denied",
"permission denied",
"resource temporarily unavailable",
"device or resource busy",
"directory not empty",
"cannot lock ref",
"unable to lock",
"unable to create",
"could not lock",
"another git process seems to be running",
// Network.
"could not resolve host",
"couldn't resolve host",
"connection timed out",
"connection reset",
"connection refused",
"failed to connect",
"unable to access",
"could not read from remote repository",
"the remote end hung up unexpectedly",
"rpc failed",
"early eof",
"unexpected disconnect while reading sideband packet",
"operation timed out",
"temporary failure in name resolution",
"network is unreachable",
"no route to host",
"ssh: connect to host",
"the requested url returned error: 5",
"gnutls_handshake",
"openssl ssl_read",
"schannel: failed",
];
}
37 changes: 31 additions & 6 deletions src/Services/GitService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,21 @@ public sealed class GitService
{
private const string RefsHeads = "refs/heads/";

private static Task<ProcessResult> Git(string dir, CancellationToken ct, params string[] args)
/// <summary>Runs a git command in <paramref name="workingDir"/> and returns its captured result. The
/// default shells out to the real <c>git</c> CLI; tests inject a fake to script output (e.g. a transient
/// failure that then clears) without needing to provoke one from a real repository.</summary>
public delegate Task<ProcessResult> GitCommandRunner(string workingDir, IReadOnlyList<string> args, CancellationToken ct);

private readonly GitCommandRunner _run;

public GitService(GitCommandRunner? run = null) => _run = run ?? new GitCommandRunner(DefaultRun);

private static Task<ProcessResult> DefaultRun(string dir, IReadOnlyList<string> args, CancellationToken ct)
=> ProcessRunner.RunAsync("git", args, dir, ct);

private Task<ProcessResult> Git(string dir, CancellationToken ct, params string[] args)
=> _run(dir, args, ct);

public async Task<bool> IsInsideWorkTreeAsync(string dir, CancellationToken ct = default)
{
var r = await Git(dir, ct, "rev-parse", "--is-inside-work-tree");
Expand Down Expand Up @@ -161,13 +173,18 @@ public Task<ProcessResult> SwitchNewFromAsync(string dir, string branch, string?

// --- Worktree creation --------------------------------------------------------------

// `-c core.longpaths=true` lets git's own file operations use the Windows extended-length API, so a
// worktree whose checked-out files cross the 260-char MAX_PATH limit (deep node_modules, generated output)
// can still be created and removed. Harmless on other platforms and when a shorter path is used.
private const string LongPaths = "core.longpaths=true";

public Task<ProcessResult> WorktreeAddExistingAsync(string dir, string path, string branch, CancellationToken ct = default)
=> Git(dir, ct, "worktree", "add", path, branch);
=> Git(dir, ct, "-c", LongPaths, "worktree", "add", path, branch);

public Task<ProcessResult> WorktreeAddNewAsync(string dir, string path, string branch, string? startPoint, CancellationToken ct = default)
=> startPoint is null
? Git(dir, ct, "worktree", "add", "-b", branch, path)
: Git(dir, ct, "worktree", "add", "-b", branch, path, startPoint);
? Git(dir, ct, "-c", LongPaths, "worktree", "add", "-b", branch, path)
: Git(dir, ct, "-c", LongPaths, "worktree", "add", "-b", branch, path, startPoint);

// --- Worktree / branch deletion -----------------------------------------------------

Expand Down Expand Up @@ -208,8 +225,16 @@ public async Task<int> CountOrphanedCommitsAsync(string dir, string branch, Canc
/// </summary>
public Task<ProcessResult> WorktreeRemoveAsync(string dir, string worktreePath, bool force, CancellationToken ct = default)
=> force
? Git(dir, ct, "worktree", "remove", "--force", worktreePath)
: Git(dir, ct, "worktree", "remove", worktreePath);
? Git(dir, ct, "-c", LongPaths, "worktree", "remove", "--force", worktreePath)
: Git(dir, ct, "-c", LongPaths, "worktree", "remove", worktreePath);

/// <summary>
/// Drops git's registration of any worktree whose directory has gone missing (<c>git worktree prune</c>).
/// Used after a worktree folder is deleted out-of-band — e.g. the force-delete fallback — so the branch it
/// held is no longer considered checked out and can be deleted.
/// </summary>
public Task<ProcessResult> PruneWorktreesAsync(string dir, CancellationToken ct = default)
=> Git(dir, ct, "worktree", "prune");

/// <summary>
/// Force-deletes the local branch (<c>git branch -D</c>) — used once its worktree is gone, so the
Expand Down
Loading
Loading