From 992f8aabf73364d413451282fdb5cd0b52a4f2f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 13:33:13 +0000 Subject: [PATCH 1/2] Add opt-in remote-branch deletion with open-PR guard The inline worktree-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 touched unless asked. When the GitHub CLI (gh) reports an open pull request for the branch, the checkbox is disabled and the strip names the PR with a link to open it in the browser; the confirmed delete is guarded so origin is left alone while a PR is open, even if the flag is forced on. PR detection is a thin wrapper over `gh pr list` (GitHubCli) mirroring the existing injectable-runner seam, degrading to "no PR known" whenever gh is missing, unauthenticated, or the remote isn't GitHub. - New: GitHubCli, PullRequestInfo, UrlLauncher - WorktreeDeletion carries the open PR (RemoteDeletionBlocked); OpenerService queries gh when a remote branch exists - MainWindowViewModel + confirm-strip XAML expose the checkbox and PR link - Tests: gh parsing/degradation unit tests; E2E for tick-deletes-origin, open-PR-blocks-and-links, and option-hidden-when-not-on-origin - Docs: Features.md, CHANGELOG.md, README.md Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Mc2djY95KPQyGwJpqHfy9m --- CHANGELOG.md | 12 ++ Docs/Features.md | 15 ++- README.md | 3 +- src/Models/PullRequestInfo.cs | 7 ++ src/Models/WorktreeDeletion.cs | 14 ++- src/Services/FidoServices.cs | 1 + src/Services/GitHubCli.cs | 78 +++++++++++++ src/Services/OpenerService.cs | 9 +- src/Services/UrlLauncher.cs | 25 +++++ src/Theme/FidoStyles.axaml | 23 ++++ src/ViewModels/MainWindowViewModel.cs | 54 +++++++++ src/Views/MainWindow.axaml | 19 ++++ src/Views/MainWindow.axaml.cs | 35 ++++-- tests/Fido.Tests/E2E/DeleteWorktreeTests.cs | 103 ++++++++++++++++++ tests/Fido.Tests/Infrastructure/FakeGitHub.cs | 27 +++++ .../Infrastructure/TestRepoWorld.cs | 4 +- tests/Fido.Tests/Services/GitHubCliTests.cs | 59 ++++++++++ 17 files changed, 467 insertions(+), 21 deletions(-) create mode 100644 src/Models/PullRequestInfo.cs create mode 100644 src/Services/GitHubCli.cs create mode 100644 src/Services/UrlLauncher.cs create mode 100644 tests/Fido.Tests/Infrastructure/FakeGitHub.cs create mode 100644 tests/Fido.Tests/Services/GitHubCliTests.cs 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..2d7f886 --- /dev/null +++ b/src/Services/GitHubCli.cs @@ -0,0 +1,78 @@ +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; + + 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 + { + r = await _run(dir, ["pr", "list", "--head", branch, "--state", "open", "--json", "number,url,title", "--limit", "1"], ct); + } + catch + { + 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; + if (!el.TryGetProperty("number", out var numEl) || numEl.ValueKind != JsonValueKind.Number) continue; + var number = numEl.GetInt32(); + var url = el.TryGetProperty("url", out var urlEl) ? urlEl.GetString() ?? "" : ""; + var title = el.TryGetProperty("title", out var titleEl) ? titleEl.GetString() ?? "" : ""; + return new PullRequestInfo(number, url, title); + } + return null; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/Services/OpenerService.cs b/src/Services/OpenerService.cs index a1a5f8b..0cfc45a 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> 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..fa5a43e 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,13 @@ 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}'."); + var remoteNote = outcome.RemoteBranchDeleted ? $" + origin/{plan.Branch}" + : outcome.RemoteDeleteFailed ? " (origin branch delete failed — see log)" + : ""; + _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 +482,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..3cf36ff --- /dev/null +++ b/tests/Fido.Tests/Services/GitHubCliTests.cs @@ -0,0 +1,59 @@ +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 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(); + } +} From 3827c18c010706aee8d44c6cbfc22b106809c52c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 23 Jul 2026 13:47:43 +0000 Subject: [PATCH 2/2] Harden remote-branch deletion after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the previous commit surfaced robustness and defense-in-depth gaps around the gh PR check and the remote-delete guard: - GitHubCli parsing could throw on well-formed-but-wrong-typed gh JSON (GetInt32/GetString on unexpected value kinds), which — since gh runs on every delete-arm of a pushed branch — would abort the whole delete-arm rather than degrade to "no PR". Parsing is now lenient (TryGetInt32 + value-kind-checked string reads) with a catch-all backstop, honouring the "never throws" contract. - The gh subprocess had no timeout; a network stall would freeze the delete-confirm flow. Bounded with a 10s linked-cancellation timeout that degrades to "no PR known". - The open-PR guard lived only in the view. DeleteBranchesAsync now also checks RemoteDeletionBlocked, so the invariant holds at the point git runs regardless of caller. - The success log now leads with a warning (not a green tick) when an opted-in remote delete failed. Tests: gh parsing of unexpected field types and non-integer numbers; a service-layer test proving an open PR withholds the remote delete even with WorktreeDeletionChoice.All. Full suite: 175 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mc2djY95KPQyGwJpqHfy9m --- src/Services/GitHubCli.cs | 25 ++++++++++++++----- src/Services/OpenerService.cs | 4 ++- src/Views/MainWindow.axaml.cs | 11 +++++--- tests/Fido.Tests/Services/GitHubCliTests.cs | 25 +++++++++++++++++++ .../Fido.Tests/Services/OpenerServiceTests.cs | 25 +++++++++++++++++++ 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/Services/GitHubCli.cs b/src/Services/GitHubCli.cs index 2d7f886..be9bd00 100644 --- a/src/Services/GitHubCli.cs +++ b/src/Services/GitHubCli.cs @@ -19,6 +19,10 @@ public sealed class GitHubCli 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) @@ -46,10 +50,13 @@ private static async Task<ProcessResult> DefaultRun(string dir, IReadOnlyList<st ProcessResult r; try { - r = await _run(dir, ["pr", "list", "--head", branch, "--state", "open", "--json", "number,url,title", "--limit", "1"], ct); + 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; } @@ -62,16 +69,22 @@ private static async Task<ProcessResult> DefaultRun(string dir, IReadOnlyList<st foreach (var el in doc.RootElement.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; - if (!el.TryGetProperty("number", out var numEl) || numEl.ValueKind != JsonValueKind.Number) continue; - var number = numEl.GetInt32(); - var url = el.TryGetProperty("url", out var urlEl) ? urlEl.GetString() ?? "" : ""; - var title = el.TryGetProperty("title", out var titleEl) ? titleEl.GetString() ?? "" : ""; + // 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 (JsonException) + 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 0cfc45a..d974e90 100644 --- a/src/Services/OpenerService.cs +++ b/src/Services/OpenerService.cs @@ -611,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/Views/MainWindow.axaml.cs b/src/Views/MainWindow.axaml.cs index fa5a43e..fae167e 100644 --- a/src/Views/MainWindow.axaml.cs +++ b/src/Views/MainWindow.axaml.cs @@ -445,10 +445,13 @@ internal async Task ConfirmDeleteAsync() outcome = await _opener.ForceDeleteWorktreeAsync(plan, choice); } - var remoteNote = outcome.RemoteBranchDeleted ? $" + origin/{plan.Branch}" - : outcome.RemoteDeleteFailed ? " (origin branch delete failed — see log)" - : ""; - _vm.AppendLog($"✓ Removed worktree & branch '{plan.Branch}'{remoteNote}."); + 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)) diff --git a/tests/Fido.Tests/Services/GitHubCliTests.cs b/tests/Fido.Tests/Services/GitHubCliTests.cs index 3cf36ff..cbb9ea2 100644 --- a/tests/Fido.Tests/Services/GitHubCliTests.cs +++ b/tests/Fido.Tests/Services/GitHubCliTests.cs @@ -40,6 +40,31 @@ public async Task Returns_null_on_malformed_output() 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() { 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(); + } }