From 2b83ad618bb8559b6006550a06f8eb39a178b3ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 08:23:10 +0000 Subject: [PATCH] Reopen a placed branch instead of re-adding its worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a new-worktree placement card created the worktree and launched into it, but left the card labelled "new worktree". A second tool click (e.g. open a Console, then open Rider) therefore ran `git worktree add` for the same branch again, which git rejects — the branch is now checked out in the worktree the first click created — surfacing as "git worktree add failed". Placing a branch now converts the card in place to the real checkout it became: a created worktree becomes a deletable Worktree target, a switched clone becomes a MainClone target, with solutions re-globbed from the tree that now exists. Subsequent opens launch that folder directly. The conversion happens as soon as the placement succeeds (before the launch, which may fail to locate the editor), so the card can never fall back into trying to place the branch a second time. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QSKHusKNU5w1zMeWfj2wjc --- CHANGELOG.md | 11 +++++ src/ViewModels/MainWindowViewModel.cs | 26 ++++++++++ src/Views/MainWindow.axaml.cs | 32 +++++++++++++ tests/Fido.Tests/E2E/NewBranchRepoTests.cs | 55 ++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index caaa129..d6af74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -150,6 +150,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (default **10 seconds**; **0** closes immediately), and selecting **Never** turns auto-close off entirely. +### Fixed + +- **Opening a second tool on a freshly placed branch no longer fails.** Opening a **new worktree** + placement card created the worktree and launched into it — but the card still read *new worktree*, + so a second click (open a **Console**, then open **Rider**) tried to `git worktree add` the same + branch again and failed with *git worktree add failed*, since the branch was now checked out in the + worktree the first click made. Placing a branch now **converts the card in place** to the real + checkout it became — a created worktree becomes a deletable **worktree**, a switched clone becomes a + **main clone** — with its solution chips re-scanned from the tree that now exists. Any further tool + clicks open that folder directly, no rescan required. + ### Changed - **MRU suggestions no longer drop down on focus.** The Branch and Solution boxes used to open diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/ViewModels/MainWindowViewModel.cs index a619b0f..3be3ba7 100644 --- a/src/ViewModels/MainWindowViewModel.cs +++ b/src/ViewModels/MainWindowViewModel.cs @@ -394,6 +394,32 @@ public void RemoveTarget(TargetCard card) Phase = DiscoveryPhase.NotFound; } + /// + /// Swaps a just-placed card for the real checkout it became: a + /// whose worktree was created turns into a , a + /// whose main tree was switched turns into a + /// . Opening the card again then launches the folder that now + /// exists instead of trying to place the branch a second time — which git rejects, because the + /// branch is already checked out. Selection follows the swapped card so the just-opened target stays + /// current (and, for a new worktree, becomes deletable without waiting for a rescan). + /// + public void ReplaceTarget(TargetCard existing, DiscoveredTarget materialised) + { + var index = Targets.IndexOf(existing); + if (index < 0) return; + + var wasSelected = ReferenceEquals(_selectedTarget, existing); + var card = new TargetCard(materialised); + Targets[index] = card; + + // Replacing the selected item can bounce the list's SelectedItem through null; set it back + // explicitly so selection — and everything gated on it — lands on the materialised card. + if (wasSelected) SelectedTarget = card; + + OnPropertyChanged(nameof(HasMultipleTargets)); + OnPropertyChanged(nameof(MultiTargetHelperText)); + } + // --- Auto-close countdown --------------------------------------------------------- private bool _isClosingCountdown; diff --git a/src/Views/MainWindow.axaml.cs b/src/Views/MainWindow.axaml.cs index 4abe624..4ef24be 100644 --- a/src/Views/MainWindow.axaml.cs +++ b/src/Views/MainWindow.axaml.cs @@ -287,6 +287,10 @@ internal async Task OpenWithAsync(Editor editor, bool fromCommandLine = false) _vm.AppendLog($"▸ Creating a worktree for '{branch}' in {card.Target.RepoName}…"); var ctx = await _opener.BuildMainContextAsync(repo, branch, _config); folder = await _opener.CreateWorktreeAsync(repo, branch, ctx); + // The worktree now exists on disk: convert the card to a real one so a second tool + // click opens it instead of trying to add the same worktree again (git would refuse — + // the branch is now checked out here). + await MaterialisePlacementAsync(card, folder, TargetKind.Worktree); } else if (card.Target.Kind == TargetKind.SwitchMainClone) { @@ -296,6 +300,9 @@ internal async Task OpenWithAsync(Editor editor, bool fromCommandLine = false) _vm.AppendLog($"▸ Switching {card.Target.RepoName}'s main tree to '{branch}'…"); var ctx = await _opener.BuildMainContextAsync(repo, branch, _config); folder = await _opener.CheckoutInMainAsync(repo, branch, ctx); + // The main tree is now on the branch: convert the card to a plain main-clone target so + // a second click just reopens it rather than re-running the switch. + await MaterialisePlacementAsync(card, folder, TargetKind.MainClone); } else { @@ -338,6 +345,31 @@ internal async Task OpenWithAsync(Editor editor, bool fromCommandLine = false) } } + /// + /// Replaces a placement card whose branch was just put on disk (worktree created, or clone + /// switched) with the real checkout at . Done as + /// soon as the placement succeeds — before the launch itself, which may fail to locate the editor — + /// so a follow-up click on the same card opens the existing folder instead of asking git to place + /// the branch again. The solutions are re-globbed from the tree that now exists (the placement card + /// only previewed the clone's), keeping the chip row honest for the next open. + /// + private async Task MaterialisePlacementAsync(TargetCard card, string folder, TargetKind kind) + { + DateTime? updated = null; + try { updated = Directory.GetLastWriteTimeUtc(folder); } + catch { /* advisory meta only — an unreadable timestamp shouldn't block the swap */ } + + var solutions = await Task.Run(() => _opener.FindSolutionsInFolder(folder, _config)); + var materialised = card.Target with + { + Path = folder, + Kind = kind, + Solutions = solutions, + UpdatedUtc = updated, + }; + _vm.ReplaceTarget(card, materialised); + } + // --- Delete (inline two-step confirm) ------------------------------------------------- /// First click: build a fresh deletion plan (dirty files / orphaned commits included) diff --git a/tests/Fido.Tests/E2E/NewBranchRepoTests.cs b/tests/Fido.Tests/E2E/NewBranchRepoTests.cs index beb50d7..bd9afec 100644 --- a/tests/Fido.Tests/E2E/NewBranchRepoTests.cs +++ b/tests/Fido.Tests/E2E/NewBranchRepoTests.cs @@ -193,6 +193,61 @@ await Harness.WithWindow(services, async window => }); } + /// + /// Regression: opening a placement card creates the worktree once and then converts the + /// card to the real checkout, so a second tool click (the user's "open a console, then open Rider") + /// launches the existing worktree instead of asking git to add it again — which git rejects because + /// the branch is already checked out there. + /// + [Test] + public async Task Opening_a_second_tool_reuses_the_created_worktree_instead_of_re_adding_it() + { + using var world = new TestRepoWorld(); + var origin = world.CreateOrigin("Foo", "Foo"); + var root = world.SearchRoot("root"); + var clone = world.Clone(origin, root, "Foo"); + world.PublishBranchToOrigin(origin, "feature/x"); // never fetched by the clone + + var launcher = new FakeEditorLauncher(); + var services = world.BuildServices([root], launcher, new FakeDialogService()); + + await Harness.WithWindow(services, async window => + { + var vm = window.Vm(); + await window.Discover("feature/x"); + await AssertSingleCandidate(window, "feature/x", remoteOnly: true); + + // First click opens a console on the (freshly created) worktree. + await window.OpenWithAsync(new Editor { Name = "Console", Kind = EditorKind.Console }); + var worktree = vm.Targets[0].Path; + await Assert.That(Directory.Exists(worktree)).IsTrue(); + + // The card has become a real, deletable worktree — no rescan needed — and stays selected. + await Assert.That(vm.Targets[0].IsNewWorktree).IsFalse(); + await Assert.That(vm.Targets[0].IsWorktree).IsTrue(); + await Assert.That(vm.SelectedTarget!.IsWorktree).IsTrue(); + await Assert.That(vm.CanDelete).IsTrue(); + + // Second click — Rider this time — must open the existing worktree, not fail on a re-add. + await window.OpenWithAsync(new Editor { Name = "Rider", Kind = EditorKind.Rider }); + + await Assert.That(window.LogText().Contains("git worktree add failed")).IsFalse(); + await Assert.That(launcher.Launches.Count).IsEqualTo(2); + + // Both launches landed inside the one worktree; no second, "-2" tree was created. + await Assert.That(Paths.StartsWith(launcher.Launches[0].Target, worktree)).IsTrue(); + var riderTarget = launcher.Launches[1].Target; + await Assert.That(Paths.StartsWith(riderTarget, worktree)).IsTrue(); + await Assert.That(riderTarget).EndsWith("Foo.sln"); + await Assert.That(Directory.Exists(worktree + "-2")).IsFalse(); + + // Discovery still reports exactly one worktree for the branch. + await window.RunDiscoveryAsync(); + await Assert.That(vm.Targets.Count).IsEqualTo(1); + await Assert.That(vm.Targets[0].IsWorktree).IsTrue(); + }); + } + [Test] public async Task Opening_the_switch_card_moves_the_main_tree_onto_the_branch() {