diff --git a/CHANGELOG.md b/CHANGELOG.md index d6af74a..3edbac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`_** 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 · `) 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 diff --git a/Docs/Features.md b/Docs/Features.md index 0f14159..8e16e44 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -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 @@ -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) | diff --git a/README.md b/README.md index 4caba77..4827d6d 100644 --- a/README.md +++ b/README.md @@ -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"> diff --git a/src/Models/PullRequestInfo.cs b/src/Models/PullRequestInfo.cs new file mode 100644 index 0000000..8e96069 --- /dev/null +++ b/src/Models/PullRequestInfo.cs @@ -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); diff --git a/src/Models/WorktreeDeletion.cs b/src/Models/WorktreeDeletion.cs index 7d5aa8d..57f15fd 100644 --- a/src/Models/WorktreeDeletion.cs +++ b/src/Models/WorktreeDeletion.cs @@ -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> @@ -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/<branch> would sever the PR); the confirm strip links to the PR instead.</summary> + public bool RemoteDeletionBlocked => OpenPullRequest is not null; } diff --git a/src/Services/FidoServices.cs b/src/Services/FidoServices.cs index ed552dc..93bd01d 100644 --- a/src/Services/FidoServices.cs +++ b/src/Services/FidoServices.cs @@ -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; } diff --git a/src/Services/GitHubCli.cs b/src/Services/GitHubCli.cs new file mode 100644 index 0000000..be9bd00 --- /dev/null +++ b/src/Services/GitHubCli.cs @@ -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 <branch> --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; + } + } +} diff --git a/src/Services/OpenerService.cs b/src/Services/OpenerService.cs index a1a5f8b..d974e90 100644 --- a/src/Services/OpenerService.cs +++ b/src/Services/OpenerService.cs @@ -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 => @@ -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> @@ -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, diff --git a/src/Services/UrlLauncher.cs b/src/Services/UrlLauncher.cs new file mode 100644 index 0000000..1d0c413 --- /dev/null +++ b/src/Services/UrlLauncher.cs @@ -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; + } + } +} diff --git a/src/Theme/FidoStyles.axaml b/src/Theme/FidoStyles.axaml index 823d611..8ea44a2 100644 --- a/src/Theme/FidoStyles.axaml +++ b/src/Theme/FidoStyles.axaml @@ -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> diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs index 3be3ba7..0a56f0b 100644 --- a/src/ViewModels/MainWindowViewModel.cs +++ b/src/ViewModels/MainWindowViewModel.cs @@ -235,6 +235,9 @@ public void SetEditors(IReadOnlyList<Editor> editors, int defaultIndex) private string _deleteConfirmPath = ""; private string _deleteConfirmBranch = ""; private string _deleteConfirmWarnings = ""; + private bool _remoteBranchExists; + private bool _deleteRemoteBranch; + private PullRequestInfo? _openPullRequest; /// <summary>True when the scanned branch is a configured default branch (main/master) — those are /// never deletable, even from a worktree. Set by the orchestrator when a scan completes.</summary> @@ -313,12 +316,63 @@ private set public bool HasDeleteConfirmWarnings => _deleteConfirmWarnings.Length > 0; + /// <summary>True when origin has this branch — the confirm strip then offers to delete it too.</summary> + public bool RemoteBranchExists + { + get => _remoteBranchExists; + private set + { + if (!SetField(ref _remoteBranchExists, value)) return; + OnPropertyChanged(nameof(ShowRemoteBranchOption)); + OnPropertyChanged(nameof(CanDeleteRemoteBranch)); + } + } + + /// <summary>The "also delete the remote branch" row shows only when there's a remote branch to delete.</summary> + public bool ShowRemoteBranchOption => _remoteBranchExists; + + /// <summary>The remote-branch delete opt-in — unticked by default (the safe choice), and forced off / + /// disabled while an open pull request blocks it.</summary> + public bool DeleteRemoteBranch + { + get => _deleteRemoteBranch; + set => SetField(ref _deleteRemoteBranch, value); + } + + /// <summary>The remote-branch checkbox is enabled only when there's a remote branch and no open PR.</summary> + public bool CanDeleteRemoteBranch => _remoteBranchExists && _openPullRequest is null; + + /// <summary>True when an open pull request blocks the remote-branch delete — the strip surfaces/links it.</summary> + public bool HasOpenPullRequest => _openPullRequest is not null; + + /// <summary>The blocking PR's caption, e.g. <c>PR #42 · Add the widget</c>; empty when none.</summary> + public string OpenPullRequestLabel => + _openPullRequest is null ? "" : $"PR #{_openPullRequest.Number} · {_openPullRequest.Title}"; + + /// <summary>The blocking PR's web URL, opened from the strip; empty when none.</summary> + public string OpenPullRequestUrl => _openPullRequest?.Url ?? ""; + + /// <summary>The remote-branch option's caption, naming the ref that would be deleted.</summary> + public string RemoteBranchOptionText => $"Also delete the remote branch origin/{_deleteConfirmBranch}"; + /// <summary>Swaps the delete button for the confirm strip, spelling out exactly what will happen.</summary> public void ArmDeleteConfirm(WorktreeDeletion plan) { DeleteConfirmPath = plan.WorktreePath; DeleteConfirmBranch = plan.Branch; + // Remote-branch opt-in + PR gate. Default the checkbox OFF (opt-in); it's disabled outright when a + // PR blocks it. RemoteBranchExists is set last so its change notifications see the final PR state. + _openPullRequest = plan.OpenPullRequest; + DeleteRemoteBranch = false; + RemoteBranchExists = plan.RemoteBranchExists; + OnPropertyChanged(nameof(RemoteBranchOptionText)); + OnPropertyChanged(nameof(HasOpenPullRequest)); + OnPropertyChanged(nameof(OpenPullRequestLabel)); + OnPropertyChanged(nameof(OpenPullRequestUrl)); + OnPropertyChanged(nameof(CanDeleteRemoteBranch)); + OnPropertyChanged(nameof(ShowRemoteBranchOption)); + var warnings = new List<string>(); if (plan.OutstandingChanges.Count > 0) warnings.Add($"⚠ {plan.OutstandingChanges.Count} uncommitted change(s) will be lost."); diff --git a/src/Views/MainWindow.axaml b/src/Views/MainWindow.axaml index fdf71a5..baf6047 100644 --- a/src/Views/MainWindow.axaml +++ b/src/Views/MainWindow.axaml @@ -394,6 +394,25 @@ Text="{Binding DeleteConfirmWarnings}" TextWrapping="Wrap" FontSize="12.5" FontWeight="SemiBold" Foreground="{DynamicResource FidoDangerEmphasis}" /> + <!-- Opt-in: also delete the remote branch — disabled, with a link, when a PR is open --> + <StackPanel IsVisible="{Binding ShowRemoteBranchOption}" Spacing="6"> + <CheckBox x:Name="DeleteRemoteCheck" Classes="remoteopt" + IsChecked="{Binding DeleteRemoteBranch, Mode=TwoWay}" + IsEnabled="{Binding CanDeleteRemoteBranch}" + Content="{Binding RemoteBranchOptionText}" /> + <StackPanel IsVisible="{Binding HasOpenPullRequest}" Orientation="Horizontal" + Spacing="8" Margin="26,0,0,0"> + <TextBlock VerticalAlignment="Center" FontSize="12" TextWrapping="Wrap" + Foreground="{DynamicResource FidoDangerEmphasis}" + Text="{Binding OpenPullRequestLabel}" /> + <Button x:Name="OpenPrButton" Classes="prlink" Click="OnOpenPullRequestClick" + Content="Open pull request ↗" ToolTip.Tip="{Binding OpenPullRequestUrl}" /> + </StackPanel> + <TextBlock IsVisible="{Binding HasOpenPullRequest}" Margin="26,0,0,0" + FontSize="11.5" TextWrapping="Wrap" + Foreground="{DynamicResource FidoTextMuted}" + Text="A pull request is open for this branch — close or merge it on GitHub before deleting origin." /> + </StackPanel> <StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"> <Button Classes="confirmcancel" Content="Cancel" Click="OnDeleteCancelClick" IsEnabled="{Binding !IsDeleting}" /> diff --git a/src/Views/MainWindow.axaml.cs b/src/Views/MainWindow.axaml.cs index 4ef24be..fae167e 100644 --- a/src/Views/MainWindow.axaml.cs +++ b/src/Views/MainWindow.axaml.cs @@ -100,7 +100,7 @@ internal MainWindow(FidoServices services) SystemMenu.EnableAltSpace(this); _dialogs = services.Dialogs ?? new AvaloniaDialogService(this); - _opener = new OpenerService(_git, services.Finder, services.WorkingTreeFinder, _vm.AppendLog, _vm.AppendLiveLog); + _opener = new OpenerService(_git, services.Finder, services.WorkingTreeFinder, _vm.AppendLog, _vm.AppendLiveLog, gitHub: services.GitHub); _vm.Log.CollectionChanged += (_, _) => Dispatcher.UIThread.Post(ScrollLogToEnd, DispatcherPriority.Background); var startup = ApplyStartupArgs(); @@ -409,24 +409,28 @@ internal async Task RequestDeleteAsync() } /// <summary> - /// The confirmed delete: removes the worktree and its local branch (never the remote — that lives - /// in Settings-free land now), drops the card from the results, and re-selects the next target. - /// When git can't remove the folder (typically a path too long for the OS) the user is offered the - /// permanent, Recycle-Bin-bypassing folder delete — still a modal, it's exceptional error recovery, - /// not part of the redesigned happy path. Internal for tests. + /// The confirmed delete: removes the worktree and its local branch always, and the branch on + /// <c>origin</c> too when the user ticked the opt-in and no open pull request blocks it — then drops + /// the card from the results and re-selects the next target. When git can't remove the folder + /// (typically a path too long for the OS) the user is offered the permanent, Recycle-Bin-bypassing + /// folder delete — still a modal, it's exceptional error recovery, not part of the redesigned happy + /// path. Internal for tests. /// </summary> internal async Task ConfirmDeleteAsync() { if (_pendingDeletePlan is not { } plan || _pendingDeleteCard is not { } card) return; - var choice = new WorktreeDeletionChoice(Worktree: true, LocalBranch: true, RemoteBranch: false); + // Remote delete only when the user ticked it, origin actually has the branch, and no open PR blocks it. + var deleteRemote = _vm.DeleteRemoteBranch && plan.RemoteBranchExists && !plan.RemoteDeletionBlocked; + var choice = new WorktreeDeletionChoice(Worktree: true, LocalBranch: true, RemoteBranch: deleteRemote); _vm.IsDeleting = true; _vm.AppendLog($"🗑 Deleting worktree at {plan.WorktreePath}…"); try { + WorktreeDeletionOutcome outcome; try { - await _opener.DeleteWorktreeAsync(plan, choice); + outcome = await _opener.DeleteWorktreeAsync(plan, choice); } catch (WorktreeRemovalException ex) { @@ -438,10 +442,16 @@ internal async Task ConfirmDeleteAsync() _vm.AppendLog($"⚠ Couldn't remove the worktree for '{plan.Branch}' — left in place."); return; } - await _opener.ForceDeleteWorktreeAsync(plan, choice); + outcome = await _opener.ForceDeleteWorktreeAsync(plan, choice); } - _vm.AppendLog($"✓ Removed worktree & branch '{plan.Branch}'."); + if (outcome.RemoteDeleteFailed) + _vm.AppendLog($"⚠ Removed worktree & branch '{plan.Branch}', but origin/{plan.Branch} could not be deleted — see log."); + else + { + var remoteNote = outcome.RemoteBranchDeleted ? $" + origin/{plan.Branch}" : ""; + _vm.AppendLog($"✓ Removed worktree & branch '{plan.Branch}'{remoteNote}."); + } // Only drop the card if it's still part of the current results — a scan that superseded // this delete owns the list (and the phase machine) now. if (_vm.Targets.Contains(card)) @@ -475,6 +485,14 @@ private void OnDeleteCancelClick(object? sender, RoutedEventArgs e) _pendingDeleteCard = null; } + private void OnOpenPullRequestClick(object? sender, RoutedEventArgs e) + { + var url = _vm.OpenPullRequestUrl; + if (string.IsNullOrWhiteSpace(url)) return; + if (!UrlLauncher.Open(url)) + _vm.AppendLog($"⚠ Couldn't open the pull request — {url}"); + } + // --- Default tool popover / settings --------------------------------------------------- /// <summary>Rebuilds the gear popover's radio list from config, ticking the persisted default.</summary> diff --git a/tests/Fido.Tests/E2E/DeleteWorktreeTests.cs b/tests/Fido.Tests/E2E/DeleteWorktreeTests.cs index a1334c8..3b9d28b 100644 --- a/tests/Fido.Tests/E2E/DeleteWorktreeTests.cs +++ b/tests/Fido.Tests/E2E/DeleteWorktreeTests.cs @@ -405,6 +405,109 @@ await Harness.WithWindow(services, async window => }); } + [Test] + public async Task Ticking_the_remote_option_deletes_origin_when_no_pull_request_blocks_it() + { + using var world = new TestRepoWorld(); + var origin = world.CreateOrigin("Foo", "Foo"); + var root = world.SearchRoot("root"); + var clone = world.Clone(origin, root, "Foo"); + var worktree = world.AddWorktree(clone, "feature/x"); + world.PushBranch(worktree, "feature/x"); // published to origin, no PR + + var rider = new FakeEditorLauncher(); + var dialogs = new FakeDialogService(); + var services = world.BuildServices([root], rider, dialogs, gitHub: FakeGitHub.None); + + await Harness.WithWindow(services, async window => + { + await window.Discover("feature/x"); + await window.RequestDeleteAsync(); + var vm = window.Vm(); + + // The remote option shows (origin has the branch) and is enabled (no PR blocks it). + await Assert.That(vm.ShowRemoteBranchOption).IsTrue(); + await Assert.That(vm.CanDeleteRemoteBranch).IsTrue(); + await Assert.That(vm.HasOpenPullRequest).IsFalse(); + + window.SetChecked("DeleteRemoteCheck", true); + await Assert.That(vm.DeleteRemoteBranch).IsTrue(); + + await window.ConfirmDeleteAsync(); + + var git = new GitService(); + await Assert.That(Directory.Exists(worktree)).IsFalse(); + await Assert.That(await git.LocalBranchExistsAsync(clone, "feature/x")).IsFalse(); + // The opt-in was honoured — the branch on origin is gone too. + await Assert.That(await git.RemoteHasBranchAsync(clone, "feature/x")).IsFalse(); + await Assert.That(window.LogText()).Contains("origin/feature/x"); + }); + } + + [Test] + public async Task An_open_pull_request_blocks_the_remote_delete_and_surfaces_a_link() + { + using var world = new TestRepoWorld(); + var origin = world.CreateOrigin("Foo", "Foo"); + var root = world.SearchRoot("root"); + var clone = world.Clone(origin, root, "Foo"); + var worktree = world.AddWorktree(clone, "feature/x"); + world.PushBranch(worktree, "feature/x"); + + var rider = new FakeEditorLauncher(); + var dialogs = new FakeDialogService(); + var gh = FakeGitHub.WithOpenPr(7, "https://github.com/acme/app/pull/7", "Add feature x"); + var services = world.BuildServices([root], rider, dialogs, gitHub: gh); + + await Harness.WithWindow(services, async window => + { + await window.Discover("feature/x"); + await window.RequestDeleteAsync(); + Screenshots.Save(window, "D-delete-remote-pr-blocked"); + var vm = window.Vm(); + + // The remote option shows but is disabled, and the PR is surfaced with its link. + await Assert.That(vm.ShowRemoteBranchOption).IsTrue(); + await Assert.That(vm.HasOpenPullRequest).IsTrue(); + await Assert.That(vm.CanDeleteRemoteBranch).IsFalse(); + await Assert.That(vm.OpenPullRequestLabel).Contains("#7"); + await Assert.That(vm.OpenPullRequestLabel).Contains("Add feature x"); + await Assert.That(vm.OpenPullRequestUrl).IsEqualTo("https://github.com/acme/app/pull/7"); + + // Even if the flag is forced on, the delete must not touch origin while a PR blocks it. + vm.DeleteRemoteBranch = true; + await window.ConfirmDeleteAsync(); + + var git = new GitService(); + await Assert.That(Directory.Exists(worktree)).IsFalse(); + await Assert.That(await git.LocalBranchExistsAsync(clone, "feature/x")).IsFalse(); + await Assert.That(await git.RemoteHasBranchAsync(clone, "feature/x")).IsTrue(); // origin untouched + }); + } + + [Test] + public async Task The_remote_option_is_hidden_when_the_branch_is_not_on_origin() + { + using var world = new TestRepoWorld(); + var origin = world.CreateOrigin("Foo", "Foo"); + var root = world.SearchRoot("root"); + var clone = world.Clone(origin, root, "Foo"); + world.AddWorktree(clone, "feature/x"); // never pushed — no remote branch + + var rider = new FakeEditorLauncher(); + var dialogs = new FakeDialogService(); + var services = world.BuildServices([root], rider, dialogs); + + await Harness.WithWindow(services, async window => + { + await window.Discover("feature/x"); + await window.RequestDeleteAsync(); + var vm = window.Vm(); + await Assert.That(vm.ShowRemoteBranchOption).IsFalse(); + await Assert.That(vm.CanDeleteRemoteBranch).IsFalse(); + }); + } + /// <summary>True when <paramref name="args"/> contains <paramref name="first"/> immediately followed by /// <paramref name="second"/> — used to spot the git subcommand under any leading <c>-c key=value</c> flags.</summary> private static bool HasSubcommand(IReadOnlyList<string> args, string first, string second) diff --git a/tests/Fido.Tests/Infrastructure/FakeGitHub.cs b/tests/Fido.Tests/Infrastructure/FakeGitHub.cs new file mode 100644 index 0000000..ae608b1 --- /dev/null +++ b/tests/Fido.Tests/Infrastructure/FakeGitHub.cs @@ -0,0 +1,27 @@ +using Fido.Services; + +namespace Fido.Tests.Infrastructure; + +/// <summary> +/// Scripts the <c>gh</c> CLI for tests without a real gh install — via the injectable runner on +/// <see cref="GitHubCli"/>. <see cref="None"/> mimics "no open PR"; <see cref="Unavailable"/> mimics gh +/// missing; <see cref="WithOpenPr"/> returns one open PR for any branch queried. +/// </summary> +internal static class FakeGitHub +{ + /// <summary>gh returning an empty PR list — the branch has no open pull request.</summary> + public static GitHubCli None { get; } = + new((_, _, _) => Task.FromResult(new ProcessResult(0, "[]", ""))); + + /// <summary>gh that fails to run (not installed / not a GitHub repo) — treated as "no PR known".</summary> + public static GitHubCli Unavailable { get; } = + new((_, _, _) => Task.FromResult(new ProcessResult(127, "", "gh: command not found"))); + + /// <summary>gh reporting one open PR with the given number/url/title for any branch queried.</summary> + public static GitHubCli WithOpenPr(int number, string url, string title) => + new((_, _, _) => Task.FromResult(new ProcessResult(0, + $"[{{\"number\":{number},\"title\":{JsonString(title)},\"url\":{JsonString(url)}}}]", ""))); + + private static string JsonString(string s) => + "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; +} diff --git a/tests/Fido.Tests/Infrastructure/TestRepoWorld.cs b/tests/Fido.Tests/Infrastructure/TestRepoWorld.cs index 5f23677..2978e66 100644 --- a/tests/Fido.Tests/Infrastructure/TestRepoWorld.cs +++ b/tests/Fido.Tests/Infrastructure/TestRepoWorld.cs @@ -158,7 +158,8 @@ internal FidoServices BuildServices( string? worktreeRoot = null, CloseAfterOpen closeAfterOpen = CloseAfterOpen.CommandLine, int closeAfterOpenDelaySeconds = 0, - GitService? git = null) + GitService? git = null, + GitHubCli? gitHub = null) { var config = new AppConfig { @@ -181,6 +182,7 @@ internal FidoServices BuildServices( Launcher = launcher, Dialogs = dialogs, Git = git ?? new GitService(), + GitHub = gitHub ?? FakeGitHub.None, }; } diff --git a/tests/Fido.Tests/Services/GitHubCliTests.cs b/tests/Fido.Tests/Services/GitHubCliTests.cs new file mode 100644 index 0000000..cbb9ea2 --- /dev/null +++ b/tests/Fido.Tests/Services/GitHubCliTests.cs @@ -0,0 +1,84 @@ +using Fido.Services; + +namespace Fido.Tests.Services; + +/// <summary>Parsing and failure-degradation of the gh-CLI wrapper, driven through its injectable runner.</summary> +public class GitHubCliTests +{ + [Test] + public async Task Parses_the_first_open_pull_request() + { + var gh = new GitHubCli((_, _, _) => Task.FromResult(new ProcessResult(0, + "[{\"number\":42,\"title\":\"Add the widget\",\"url\":\"https://github.com/acme/app/pull/42\"}]", ""))); + + var pr = await gh.FindOpenPullRequestAsync("/repo", "feature/x"); + + await Assert.That(pr).IsNotNull(); + await Assert.That(pr!.Number).IsEqualTo(42); + await Assert.That(pr.Title).IsEqualTo("Add the widget"); + await Assert.That(pr.Url).IsEqualTo("https://github.com/acme/app/pull/42"); + } + + [Test] + public async Task Returns_null_when_there_are_no_open_pull_requests() + { + var gh = new GitHubCli((_, _, _) => Task.FromResult(new ProcessResult(0, "[]", ""))); + await Assert.That(await gh.FindOpenPullRequestAsync("/repo", "feature/x")).IsNull(); + } + + [Test] + public async Task Returns_null_when_gh_fails_or_is_unavailable() + { + var gh = new GitHubCli((_, _, _) => Task.FromResult(new ProcessResult(127, "", "gh: command not found"))); + await Assert.That(await gh.FindOpenPullRequestAsync("/repo", "feature/x")).IsNull(); + } + + [Test] + public async Task Returns_null_on_malformed_output() + { + var gh = new GitHubCli((_, _, _) => Task.FromResult(new ProcessResult(0, "not json at all", ""))); + await Assert.That(await gh.FindOpenPullRequestAsync("/repo", "feature/x")).IsNull(); + } + + [Test] + public async Task Returns_the_pr_even_when_optional_fields_have_unexpected_types() + { + // A valid integer number means a PR exists (and must block the remote delete); a non-string + // url/title must degrade to empty rather than throw. + var gh = new GitHubCli((_, _, _) => Task.FromResult(new ProcessResult(0, + "[{\"number\":42,\"url\":123,\"title\":\"x\"}]", ""))); + + var pr = await gh.FindOpenPullRequestAsync("/repo", "feature/x"); + + await Assert.That(pr).IsNotNull(); + await Assert.That(pr!.Number).IsEqualTo(42); + await Assert.That(pr.Title).IsEqualTo("x"); + await Assert.That(pr.Url).IsEqualTo(""); + } + + [Test] + public async Task Returns_null_when_the_number_is_not_an_integer_instead_of_throwing() + { + var gh = new GitHubCli((_, _, _) => Task.FromResult(new ProcessResult(0, + "[{\"number\":42.5,\"url\":\"u\",\"title\":\"t\"}]", ""))); + + await Assert.That(await gh.FindOpenPullRequestAsync("/repo", "feature/x")).IsNull(); + } + + [Test] + public async Task Queries_gh_for_the_branch_head_in_the_open_state() + { + IReadOnlyList<string>? seen = null; + var gh = new GitHubCli((_, args, _) => { seen = args; return Task.FromResult(new ProcessResult(0, "[]", "")); }); + + await gh.FindOpenPullRequestAsync("/repo", "feature/x"); + + await Assert.That(seen).IsNotNull(); + await Assert.That(seen!.Contains("pr")).IsTrue(); + await Assert.That(seen!.Contains("list")).IsTrue(); + await Assert.That(seen!.Contains("--head")).IsTrue(); + await Assert.That(seen!.Contains("feature/x")).IsTrue(); + await Assert.That(seen!.Contains("--state")).IsTrue(); + await Assert.That(seen!.Contains("open")).IsTrue(); + } +} diff --git a/tests/Fido.Tests/Services/OpenerServiceTests.cs b/tests/Fido.Tests/Services/OpenerServiceTests.cs index c3f6f96..aa2c088 100644 --- a/tests/Fido.Tests/Services/OpenerServiceTests.cs +++ b/tests/Fido.Tests/Services/OpenerServiceTests.cs @@ -271,4 +271,29 @@ public async Task Create_worktree_fetches_then_tracks_an_unfetched_remote_branch await Assert.That(await git.GetCurrentBranchAsync(path)).IsEqualTo("feature/x"); // really on the branch await Assert.That(await git.RemoteBranchExistsAsync(clone, "feature/x")).IsTrue(); // fetched as a side effect } + + [Test] + public async Task An_open_pull_request_withholds_the_remote_delete_at_the_service_layer() + { + using var world = new TestRepoWorld(); + var origin = world.CreateOrigin("Foo", "Foo"); + var root = world.SearchRoot("root"); + var clone = world.Clone(origin, root, "Foo"); + var worktree = world.AddWorktree(clone, "feature/x"); + world.PushBranch(worktree, "feature/x"); + + var opener = new OpenerService(new GitService(), new SolutionFinder(), new WorkingTreeFinder()); + var plan = new WorktreeDeletion(clone, Path.GetFullPath(worktree), "feature/x", + RemoteBranchExists: true, OutstandingChanges: Array.Empty<string>(), OrphanedCommits: 0, + OpenPullRequest: new PullRequestInfo(3, "https://example/pull/3", "wip")); + + // Even asking to delete the remote (choice.All), an open PR must keep origin/feature/x intact. + var outcome = await opener.DeleteWorktreeAsync(plan, WorktreeDeletionChoice.All); + + await Assert.That(outcome.WorktreeRemoved).IsTrue(); + await Assert.That(outcome.LocalBranchDeleted).IsTrue(); + await Assert.That(outcome.RemoteBranchDeleted).IsFalse(); + await Assert.That(outcome.RemoteDeleteFailed).IsFalse(); + await Assert.That(await new GitService().RemoteHasBranchAsync(clone, "feature/x")).IsTrue(); + } }