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() {