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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Optionally delete the remote branch when deleting a worktree.** The inline delete confirm strip
now offers an opt-in **_Also delete the remote branch `origin/<branch>`_** checkbox — shown only when
the branch exists on `origin`, and **unticked by default** so the remote is never removed unless you
ask. **An open pull request blocks it:** when the **GitHub CLI (`gh`)** reports a PR open for the
branch, the checkbox is disabled and the strip names the PR (`PR #42 · <title>`) with an **Open pull
request ↗** link to open it in the browser — close or merge it first. PR detection degrades
gracefully: if `gh` isn't installed, isn't authenticated, or the remote isn't GitHub, the option is
simply offered without a PR note. The confirmed delete still removes the worktree and the **local**
branch as before; the flight log notes the origin branch when it was deleted.

### Changed

- **The main screen was redesigned around inline discovery** (per the Claude Design handoff in
Expand Down
15 changes: 11 additions & 4 deletions Docs/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,16 @@ up a branch you're finished with:
backs out, and the destructive buttons sit outside the keyboard tab order so they
can't be fired by a stray keypress.
- On confirmation Fido **removes the linked worktree** and **deletes the local
branch** — and nothing else. **The branch on `origin` is never touched.** 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.
branch**. When the branch is also on `origin`, the confirm strip offers an **opt-in
checkbox — _Also delete the remote branch `origin/<branch>`_** — left **unticked by
default**, so the remote is never touched unless you ask. **An open pull request
blocks it:** when the **GitHub CLI (`gh`)** reports a PR open for the branch, the
checkbox is **disabled** and the strip names the PR (`PR #42 · <title>`) with an
**Open pull request ↗** link — close or merge it on GitHub first. PR detection
degrades gracefully: if `gh` isn't installed, isn't authenticated, or the remote
isn't GitHub, the option is simply offered without a PR note. 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.
- 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), or a git ref/index `.lock` left by a racing git
Expand Down Expand Up @@ -367,7 +374,7 @@ the next save writes to the new location.
| Multiple locations | Every checkout shown, labelled **worktree** / **main clone** — you choose which to act on |
| 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 (never the remote); retries transient failures; long-path aware with a Recycle-Bin-bypassing force-delete for **`filename too long`** |
| 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`** |
| 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ that branch, lists them right on the main screen — clearly labelled — and op
solution or folder in your editor. Set a **default tool** for the big Open button; every tool is a
**Ctrl+1 … Ctrl+9** away. It can also drop you into a **terminal** or open the folder in your
**file explorer** — on Windows, macOS, and Linux. Finished with a branch? Delete its worktree and
local branch from the same screen, with an inline confirm.
local branch — and, optionally, its remote branch too (unless an open PR says otherwise) — from the
same screen, with an inline confirm.

<p align="center">
<img src="Docs/screenshots/the-eagle-has-landed.png" alt="Fido — GO! WebStorm launched; “The Eagle has landed”" width="440">
Expand Down
7 changes: 7 additions & 0 deletions src/Models/PullRequestInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Fido.Models;

/// <summary>An open pull request found for a branch — enough to name it and link to it.</summary>
/// <param name="Number">The PR number (e.g. 42), shown as <c>#42</c>.</param>
/// <param name="Url">The PR's web URL, opened in the browser from the confirm strip.</param>
/// <param name="Title">The PR title, shown alongside its number.</param>
public sealed record PullRequestInfo(int Number, string Url, string Title);
14 changes: 11 additions & 3 deletions src/Models/WorktreeDeletion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ namespace Fido.Models;

/// <summary>
/// What a "delete this worktree" action removes: the linked worktree folder, its local branch, and —
/// when it exists — the branch on <c>origin</c>. Built from the located worktree in branch-only mode,
/// it feeds the delete-confirmation dialog and the git steps that carry the deletion out.
/// when it exists — the branch on <c>origin</c>. An open pull request for the branch blocks that remote
/// deletion. Built from the located worktree in branch-only mode, it feeds the delete-confirmation
/// dialog and the git steps that carry the deletion out.
/// </summary>
/// <param name="MainWorktreePath">The clone's main working tree — where the git commands run, so the
/// linked worktree can be dropped without standing inside it.</param>
Expand All @@ -15,14 +16,21 @@ namespace Fido.Models;
/// <param name="OrphanedCommits">Commits that live only on this branch — not pushed, not merged, not on any
/// other ref — and so would be lost when the branch is force-deleted. The dialog warns when this is above 0,
/// since neither an uncommitted-changes warning nor "not on origin" would otherwise flag the loss.</param>
/// <param name="OpenPullRequest">The open pull request for <paramref name="Branch"/>, if gh found one;
/// null when there is none or gh couldn't answer. See <see cref="RemoteDeletionBlocked"/>.</param>
public sealed record WorktreeDeletion(
string MainWorktreePath,
string WorktreePath,
string Branch,
bool RemoteBranchExists,
IReadOnlyList<string> OutstandingChanges,
int OrphanedCommits)
int OrphanedCommits,
PullRequestInfo? OpenPullRequest = null)
{
public bool HasOutstandingChanges => OutstandingChanges.Count > 0;
public bool HasOrphanedCommits => OrphanedCommits > 0;

/// <summary>True when an open pull request exists for the branch — the remote-branch delete is withheld
/// (deleting origin/&lt;branch&gt; would sever the PR); the confirm strip links to the PR instead.</summary>
public bool RemoteDeletionBlocked => OpenPullRequest is not null;
}
1 change: 1 addition & 0 deletions src/Services/FidoServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal sealed class FidoServices
public SolutionFinder Finder { get; init; } = new();
public WorkingTreeFinder WorkingTreeFinder { get; init; } = new();
public IEditorLauncher Launcher { get; init; } = new EditorLauncher();
public GitHubCli GitHub { get; init; } = new();

/// <summary>Dialog layer; when null the window installs a real <see cref="AvaloniaDialogService"/> owned by itself.</summary>
public IDialogService? Dialogs { get; init; }
Expand Down
91 changes: 91 additions & 0 deletions src/Services/GitHubCli.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Text.Json;
using System.Threading;
using Fido.Models;

namespace Fido.Services;

/// <summary>
/// Thin wrapper over the GitHub CLI (<c>gh</c>) for the one query Fido needs: is there an open pull
/// request for a branch? Mirrors <see cref="GitService"/>'s injectable-runner seam so tests can script
/// gh's output without a real gh install. Every failure mode — gh not installed, the repo isn't a GitHub
/// remote, the user isn't authenticated, malformed output — degrades to <c>null</c> (no PR known), never
/// an exception: the check is advisory, gating only whether the remote-branch delete is offered.
/// </summary>
public sealed class GitHubCli
{
/// <summary>Runs a <c>gh</c> command in <paramref name="workingDir"/> and returns its captured result.
/// The default shells out to the real <c>gh</c> CLI; tests inject a fake to script output.</summary>
public delegate Task<ProcessResult> CliRunner(string workingDir, IReadOnlyList<string> args, CancellationToken ct);

private readonly CliRunner _run;

/// <summary>How long to wait on <c>gh</c> before giving up and treating the answer as "no PR known" —
/// so a stalled network call can't freeze the delete-confirm UI.</summary>
private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(10);

public GitHubCli(CliRunner? run = null) => _run = run ?? DefaultRun;

private static async Task<ProcessResult> DefaultRun(string dir, IReadOnlyList<string> args, CancellationToken ct)
{
try
{
return await ProcessRunner.RunAsync("gh", args, dir, ct);
}
catch
{
// gh not on PATH (Win32Exception) or otherwise un-launchable — treated as "no PR known".
return new ProcessResult(127, "", "gh not available");
}
}

/// <summary>
/// The first <em>open</em> pull request whose head branch is <paramref name="branch"/>, or <c>null</c>
/// when there is none (or gh can't answer). Runs
/// <c>gh pr list --head &lt;branch&gt; --state open --json number,url,title --limit 1</c> in
/// <paramref name="dir"/> (the clone's main tree, so gh resolves the repo from its <c>origin</c> remote).
/// Never throws.
/// </summary>
public async Task<PullRequestInfo?> FindOpenPullRequestAsync(string dir, string branch, CancellationToken ct = default)
{
ProcessResult r;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(QueryTimeout);
r = await _run(dir, ["pr", "list", "--head", branch, "--state", "open", "--json", "number,url,title", "--limit", "1"], timeout.Token);
}
catch
{
// gh unavailable, cancelled, or timed out — treated as "no PR known".
return null;
}

if (!r.Success || string.IsNullOrWhiteSpace(r.StdOut)) return null;

try
{
using var doc = JsonDocument.Parse(r.StdOut);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
foreach (var el in doc.RootElement.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
// A PR always carries an integer number; treat a record without one as "not a PR" and skip.
if (!el.TryGetProperty("number", out var numEl) || !numEl.TryGetInt32(out var number)) continue;
// url/title are best-effort — read them only when they're actually strings, so an unexpected
// type degrades to empty rather than throwing (the number alone means a PR exists, which is
// what blocks the remote delete).
var url = el.TryGetProperty("url", out var urlEl) && urlEl.ValueKind == JsonValueKind.String
? urlEl.GetString() ?? "" : "";
var title = el.TryGetProperty("title", out var titleEl) && titleEl.ValueKind == JsonValueKind.String
? titleEl.GetString() ?? "" : "";
return new PullRequestInfo(number, url, title);
}
return null;
}
catch
{
// Contractually never throws — any unexpected parse failure means "no PR known".
return null;
}
}
}
13 changes: 10 additions & 3 deletions src/Services/OpenerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,22 @@ public sealed class OpenerService
private readonly WorkingTreeFinder _workingTreeFinder;
private readonly Action<string> _log;
private readonly Action<string> _liveLog;
private readonly GitHubCli _gitHub;

/// <summary>Retries the transient failures the worktree/branch deletion commands hit (locked files, ref
/// <c>.lock</c> races, network blips), narrating each retry into the flight log. See <see cref="GitRetry"/>.</summary>
private readonly ResiliencePipeline<ProcessResult> _deletionRetry;

public OpenerService(GitService git, SolutionFinder finder, WorkingTreeFinder workingTreeFinder,
Action<string>? log = null, Action<string>? liveLog = null, GitRetryOptions? deletionRetry = null)
Action<string>? log = null, Action<string>? liveLog = null, GitRetryOptions? deletionRetry = null,
GitHubCli? gitHub = null)
{
_git = git;
_finder = finder;
_workingTreeFinder = workingTreeFinder;
_log = log ?? (_ => { });
_liveLog = liveLog ?? (_ => { });
_gitHub = gitHub ?? new GitHubCli();

var retryOptions = deletionRetry ?? GitRetryOptions.Default;
_deletionRetry = GitRetry.BuildPipeline(retryOptions, attempt =>
Expand Down Expand Up @@ -526,7 +529,9 @@ public Task<bool> IsLinkedWorktreeAsync(string folder, CancellationToken ct = de
|| await _git.RemoteHasBranchAsync(mainPath, branch, ct);
var changes = await _git.GetStatusAsync(full, ct);
var orphaned = await _git.CountOrphanedCommitsAsync(mainPath, branch, ct);
return new WorktreeDeletion(mainPath, full, branch, remoteExists, changes, orphaned);
// Only worth asking gh when there's a remote branch to delete; an open PR blocks that deletion.
var openPr = remoteExists ? await _gitHub.FindOpenPullRequestAsync(mainPath, branch, ct) : null;
return new WorktreeDeletion(mainPath, full, branch, remoteExists, changes, orphaned, openPr);
}

/// <summary>
Expand Down Expand Up @@ -606,7 +611,9 @@ private async Task<WorktreeDeletionOutcome> DeleteBranchesAsync(
localDeleted = true;
}

if (choice.RemoteBranch && plan.RemoteBranchExists)
// An open pull request withholds the remote delete even when the caller ticked it — deleting
// origin/<branch> would sever the PR. The UI also gates this, but enforce it where git runs.
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,
Expand Down
25 changes: 25 additions & 0 deletions src/Services/UrlLauncher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Diagnostics;

namespace Fido.Services;

/// <summary>Opens a web URL in the OS default browser. Best-effort; returns false on failure.</summary>
public static class UrlLauncher
{
public static bool Open(string url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
try
{
var psi =
OperatingSystem.IsWindows() ? new ProcessStartInfo(url) { UseShellExecute = true }
: OperatingSystem.IsMacOS() ? new ProcessStartInfo("open", url)
: new ProcessStartInfo("xdg-open", url);
Process.Start(psi);
return true;
}
catch
{
return false;
}
}
}
23 changes: 23 additions & 0 deletions src/Theme/FidoStyles.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -543,4 +543,27 @@
<Setter Property="Background" Value="{DynamicResource FidoGearHoverBg}" />
</Style>

<!-- Remote-branch opt-in checkbox + PR link inside the delete confirm strip -->
<Style Selector="CheckBox.remoteopt">
<Setter Property="FontFamily" Value="{DynamicResource FidoMono}" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Foreground" Value="{DynamicResource FidoDangerConfirmText}" />
</Style>

<Style Selector="Button.prlink">
<Setter Property="FontFamily" Value="{DynamicResource FidoMono}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource FidoAccentText}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="4,2" />
</Style>
<Style Selector="Button.prlink /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Button.prlink:pointerover">
<Setter Property="Foreground" Value="{DynamicResource FidoAccentStrong}" />
</Style>

</Styles>
Loading
Loading