diff --git a/CLAUDE.md b/CLAUDE.md index 555e68e..2cd4041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -443,7 +443,7 @@ warning is suppressed in the csproj deliberately — it recommends WinForms' 5. Termination paths: - **Close** (`vm.CloseCommand`) → `MainViewModel.OnSessionCloseRequested` → `vm.Dispose()` + remove from `Sessions` + `SessionManager.RemoveSession()`. Session is gone from `state.json`. - **Sleep** (`SleepSession(vm)`) → `vm.Dispose()` + remove from `Sessions` but **keep** the `ShellSession` in `SessionManager` with `IsDormant = true`. A muted dormant sidebar entry replaces the active one. - - **Wake** (`WakeSessionAsync(session)`) → re-runs `LaunchSessionAsync(session, restoring: true)` — same path as restore-on-startup. + - **Wake** (`WakeSessionAsync(session)`) → re-runs `LaunchSessionAsync(session, restoring: true, removeOnFailure: false)` — same path as restore-on-startup. **Both pass `removeOnFailure: false` and then `FallBackToDormantIfNotLive`.** With the default, a session whose launch was refused or whose PTY failed to start (a WSL distro uninstalled, `claude` briefly off PATH) was *deleted* from `SessionManager` and the next save made it permanent — the PTY-failure path returns rather than throws, so Wake's catch never ran. Verified before/after against a real `state.json` (via `CSM_STATE_PATH`): the old build lost the session from the sidebar and the file; the fixed one keeps it asleep. Every relaunch path — restore, wake, restart — now ends a failure with the session asleep and editable. - **Restart** (`RestartSessionAsync(vm)`) → sleep-style teardown *without* the dormant bookkeeping, then `LaunchSessionAsync(session, restoring: true, removeOnFailure: false)`. The `ShellSession` stays in `SessionManager` (so Id, group, run commands and sidebar slot survive) and never enters the recently-closed ring. Reachable from the **↻ toolbar button and the "Restart" menu entries** (see "Restarting sessions" below) and from "Edit session…". Both non-default arguments matter: `restoring: true` makes a Claude session resume rather than start a fresh conversation (matching Wake — a restart tears down the same way, so it must recover the same way), and `removeOnFailure: false` stops a bad edit from *deleting* the session, since `LaunchSessionAsync`'s PTY-failure path calls `SessionManager.RemoveSession` — correct for a session that never started, destructive for a relaunch. If the relaunch fails either way, the session falls back to dormant so the row stays visible and fixable instead of leaving a launching placeholder that never resolves. A Claude restart also **waits for the old process to actually exit** (`DisposeAndWaitForExitAsync` + a config quiesce) before relaunching. Without that it recreates the concurrent-config-writer race the launch stagger and the shutdown loop both exist to prevent, and `--resume` can read a session index the outgoing process hasn't finalised. Non-Claude sessions skip the wait — they don't touch that file. @@ -836,6 +836,19 @@ git itself refuses modified, untracked or locked worktrees and never removes the Deleting the branch is a separate, explicit question afterwards, using `branch -d` (not `-D`), so an unmerged branch is kept. +**Refused when another worktree lives inside it.** git's clean check does not look inside an +ignored folder, and a nested worktree usually sits in one (`.worktrees/` in `.gitignore`). +Reproduced with git 2.54: worktree B inside A's ignored `.worktrees/`, with uncommitted work — +A reports clean, `git worktree remove A` exits 0 and deletes B, work included. +`GitWorktrees.NestedInside` is checked against a list re-read at click time, not the menu's. + +**Sleeping sessions are deleted only after git has removed the folder.** They never go through +Recently closed, so deleting them before git ran meant a refusal lost them for good. + +**WSL paths follow Linux case rules.** `\\wsl.localhost\` is folded into `\\wsl$\` (GitService +always reports the latter), and below the distro name paths compare case-sensitively — +`/home/u/WT` is not `/home/u/wt`, and matching it would close the wrong worktree's sessions. + **Close the sessions and wait for their processes to exit first.** On Windows a process whose cwd is inside the folder locks it, and git then fails half-way through the delete with `failed to delete … Permission denied` — verified by running the remove from inside the @@ -857,6 +870,25 @@ fails 2. The full flow was also run end to end in the app with a terminal open i worktree: session closed, folder deleted, merged branch deleted. `GitServiceThreadingTests` now also find the repo root from a worktree checkout, where `.git` is a file. +## Opening a folder in Explorer + +"Open in Explorer" (toolbar 📁, sidebar menu, note search results) goes through +`Services/FolderOpener`, never `explorer.exe `. Passing the folder as Explorer's command +line silently opened **Documents** instead for two kinds of path, measured by asking the Shell +which folder each new Explorer window showed: + +- **forward slashes** (`C:/repos/wt-feat`) — the form git prints, which "New session in sibling + worktree" (and the Worktrees menu) stored as a session's `WorkingFolder`; +- **a comma** anywhere in the name — Explorer splits its arguments on commas, and quoting does + not stop it. + +`FolderOpener` opens the folder itself through the shell (ShellExecute `open` on the full Windows +path), which was right for every case tried — spaces, comma, `&`, `%`, forward slashes, trailing +separator, 8.3 — and honours a replacement file manager. **It refuses anything that is not an +existing directory first**: ShellExecute on a *file* runs it, and a folder path comes from +`state.json` and imports. `LaunchSessionInSiblingWorktreeAsync` now stores the Windows form of +the git path, so new worktree sessions don't carry forward slashes at all. + ## Per-Session Notes Each session gets a collapsible 📝 notepad panel between the terminal toolbar and the terminal. Toggled by the 📝 button on the terminal toolbar; the panel is a docked 160px-high `TextBox` (`Visibility.Collapsed` by default). diff --git a/src/CodeShellManager/MainWindow.Git.cs b/src/CodeShellManager/MainWindow.Git.cs index bff16ad..ca634a9 100644 --- a/src/CodeShellManager/MainWindow.Git.cs +++ b/src/CodeShellManager/MainWindow.Git.cs @@ -422,8 +422,7 @@ private MenuItem BuildWorktreesMenu(SessionViewModel vm, bool busy) foreach (var w in worktrees) { if (w.IsBare) continue; - bool isCurrent = current != null && GitWorktrees.Normalize(w.Path) - .Equals(GitWorktrees.Normalize(current.Path), StringComparison.OrdinalIgnoreCase); + bool isCurrent = current != null && GitWorktrees.SamePath(w.Path, current.Path); var mi = new MenuItem { Header = new TextBlock { Text = GitWorktrees.Label(w, statusOf.GetValueOrDefault(w.Path)) }, @@ -543,6 +542,22 @@ private async Task RemoveWorktreeAsync( SessionViewModel vm, GitService.WorktreeInfo wt, string mainPath, GitRepoStatus? status) { string name = GitWorktrees.Name(wt.Path); + + // Re-read the list now rather than trusting the menu's: a worktree created inside this + // one since the menu opened must still block the remove (see NestedInside). + var (fresh, _) = await LoadWorktreesAsync(wt.Path); + var nested = GitWorktrees.NestedInside(fresh, wt); + if (nested.Count > 0) + { + MessageBox.Show(this, + $"'{name}' contains another worktree:\n\n" + + string.Join("\n", nested.Select(n => $" • {GitWorktrees.Name(n.Path)} ({n.Path})")) + + "\n\nRemoving it would delete that worktree too, uncommitted work included — git does " + + "not check inside ignored folders. Remove the inner worktree first.", + "Remove worktree", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + if (status is { IsDirty: true }) { MessageBox.Show(this, @@ -596,16 +611,6 @@ private async Task RemoveWorktreeAsync( if (pty != null) exits.Add(WaitForPtyExitAsync(pty, timeoutMs: 10_000)); } await Task.WhenAll(exits); - foreach (var d in dormantHere) - { - _dormantSidebarItems.Remove(d.Id); - _sessionManager.RemoveSession(d.Id); - } - if (dormantHere.Count > 0) - { - RebuildSidebarOrder(); - _ = _vm.SaveStateAsync(); - } var r = await GitService.RemoveWorktreeAsync(mainPath, wt.Path); if (!r.Ok) @@ -618,6 +623,20 @@ private async Task RemoveWorktreeAsync( return; } + // Only now that git has removed the folder: deleted earlier, a refusal (a file + // created since the menu opened, an editor holding the folder) lost them for good — + // unlike live sessions they never go through Recently closed. + foreach (var d in dormantHere) + { + _dormantSidebarItems.Remove(d.Id); + _sessionManager.RemoveSession(d.Id); + } + if (dormantHere.Count > 0) + { + RebuildSidebarOrder(); + _ = _vm.SaveStateAsync(); + } + string removed = $"Removed worktree {name}."; if (!string.IsNullOrEmpty(wt.Branch) && MessageBox.Show(this, diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 6bbc0fd..b5a09fe 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -398,28 +398,24 @@ private async void OnLoaded(object sender, RoutedEventArgs e) long gateMs = restoreClock.ElapsedMilliseconds - gateStart; long launchStart = restoreClock.ElapsedMilliseconds; - try { await LaunchSessionAsync(s, restoring: true); } + // removeOnFailure: false — this is a relaunch of a saved session. With the + // default, a PTY that failed to start (claude briefly off PATH, a WSL distro + // uninstalled) made LaunchSessionAsync DELETE the session, and the next save + // made that permanent. Restart has always passed false for this reason. + try { await LaunchSessionAsync(s, restoring: true, removeOnFailure: false); } catch (Exception ex) { Log($"Restore FAILED for '{s.Name}': {ex}"); if (IsWebView2AccessDenied(ex) || Terminal.WebView2StartupError.IsIncompatibleCopy(ex)) - { webView2AccessDenied.Add(s.Name); - // The batched dialog below tells the user to reopen these from the - // sidebar, so make sure there is a row to reopen: put the session to - // sleep, as Wake and Restart already do when a launch fails. - if (!_sessionUi.ContainsKey(s.Id)) - { - _launchingSidebarItems.Remove(s.Id); - s.IsDormant = true; - AddDormantSidebarItem(s); - RebuildSidebarOrder(); - } - } else MessageBox.Show($"Failed to restore '{s.Name}': {ex.Message}", "Restore Error", MessageBoxButton.OK, MessageBoxImage.Warning); } + // Every failure shape — thrown, or handled inside LaunchSessionAsync — ends here + // with no pane. Put the session to sleep so it keeps a row that can be fixed + // and woken; otherwise it stays saved with no row anywhere. + FallBackToDormantIfNotLive(s); // Split the per-session cost into "waiting for the previous Claude" vs // "actually launching" (WebView2 create + navigate + PTY spawn). #82 asked @@ -1100,6 +1096,11 @@ private static void InheritSessionKindFrom(Models.ShellSession target, Models.Sh /// private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent, string worktreePath) { + // The path comes from `git worktree list`, which prints forward slashes. Stored as-is it + // became the session's WorkingFolder, and "Open in Explorer" then opened Documents — + // so store the Windows form. (WSL worktrees arrive as \\wsl$ UNC paths; this keeps + // them UNC, only fixing the separators.) + worktreePath = Services.FolderOpener.WindowsPath(worktreePath); if (!System.IO.Directory.Exists(worktreePath)) { MessageBox.Show(this, $"Worktree folder '{worktreePath}' does not exist.", @@ -5427,21 +5428,36 @@ private async Task WakeSessionAsync(ShellSession session) try { - await LaunchSessionAsync(session, restoring: true); + // removeOnFailure: false — see the restore loop. With the default, clicking a + // sleeping row whose program failed to start deleted the session for good: the + // PTY-failure path returns rather than throws, so the catch below never ran. + await LaunchSessionAsync(session, restoring: true, removeOnFailure: false); } catch (Exception ex) { Log($"Wake FAILED for '{session.Name}': {ex}"); - // Restore the dormant entry so the user doesn't lose access to the session - session.IsDormant = true; - AddDormantSidebarItem(session); - RebuildSidebarOrder(); MessageBox.Show($"Failed to wake '{session.Name}': {ex.Message}", "Wake Error", MessageBoxButton.OK, MessageBoxImage.Warning); } + // Thrown or handled inside, a failed wake leaves no pane: keep the sleeping row. + FallBackToDormantIfNotLive(session); _ = _vm.SaveStateAsync(); } + /// + /// After a relaunch (restore, wake): if no pane came up, put the session back to sleep so + /// it keeps a sidebar row the user can edit and wake. Restart does the same inline. + /// + private void FallBackToDormantIfNotLive(ShellSession session) + { + if (_sessionUi.ContainsKey(session.Id) || session.IsDormant) return; + if (!_sessionManager.Sessions.Any(s => s.Id == session.Id)) return; + _launchingSidebarItems.Remove(session.Id); + session.IsDormant = true; + AddDormantSidebarItem(session); + RebuildSidebarOrder(); + } + /// /// Builds and registers the muted sidebar row for a dormant session. The row is NOT /// added to the visual tree here: where it belongs — which group section, or whether it @@ -5751,8 +5767,7 @@ private async void SearchResult_Click(object sender, RoutedEventArgs e) // Note results: open folder in Explorer if (result.IsNote) { - if (!string.IsNullOrEmpty(result.FolderPath) && Directory.Exists(result.FolderPath)) - System.Diagnostics.Process.Start("explorer.exe", result.FolderPath); + _ = Services.FolderOpener.OpenAsync(result.FolderPath); return; } diff --git a/src/CodeShellManager/Services/FolderOpener.cs b/src/CodeShellManager/Services/FolderOpener.cs new file mode 100644 index 0000000..b0e2ee0 --- /dev/null +++ b/src/CodeShellManager/Services/FolderOpener.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace CodeShellManager.Services; + +/// +/// Opens a folder in the user's file manager, reliably. See "Opening a folder in Explorer" +/// in CLAUDE.md. +/// +/// +/// It used to be Process.Start("explorer.exe", folder) — the folder as raw command-line +/// text — and Explorer silently opened Documents instead of the folder for two kinds of +/// path (measured, by asking the Shell which folder each new Explorer window showed): +/// +/// forward slashes (C:/repos/wt-feat) — the form git prints, and so the form a +/// session opened through "New session in sibling worktree" stored; +/// a comma anywhere in the name — Explorer splits its arguments on commas, and quoting +/// the path does not stop it. +/// +/// Opening the folder itself through the shell (ShellExecute "open" on the full Windows path) +/// opened the right folder for every case tried: plain, spaces, comma, &, %, +/// forward slashes, trailing separator, 8.3 short name. It also honours a replacement file +/// manager the user has set as the folder handler. +/// +public static class FolderOpener +{ + /// + /// The path to hand to the shell, or null when it must not be opened: not rooted, or not + /// an existing DIRECTORY. The second is a security check, not tidiness — ShellExecute on a + /// file runs it, and a session's folder comes from state.json and imports, which are + /// untrusted. Touches the disk (and \\wsl$), so call it off the UI thread. + /// + public static string? Target(string? folder) + { + if (string.IsNullOrWhiteSpace(folder)) return null; + try + { + if (!Path.IsPathFullyQualified(folder)) return null; + string full = WindowsPath(folder); + return Directory.Exists(full) ? full : null; + } + catch { return null; } + } + + /// + /// Backslashes, absolute, no trailing separator (except a drive root). The form every + /// Windows tool accepts — use it for any folder that came from git. + /// + public static string WindowsPath(string folder) + { + string full = Path.GetFullPath(folder); + string trimmed = Path.TrimEndingDirectorySeparator(full); + return trimmed.EndsWith(':') ? trimmed + Path.DirectorySeparatorChar : trimmed; + } + + /// + /// Opens off the calling thread. Returns false when it was not + /// an existing folder or the shell refused; never throws. + /// + public static Task OpenAsync(string? folder) => Task.Run(() => + { + string? target = Target(folder); + if (target == null) return false; + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true, Verb = "open" })?.Dispose(); + return true; + } + catch { return false; } + }); +} diff --git a/src/CodeShellManager/Services/GitWorktrees.cs b/src/CodeShellManager/Services/GitWorktrees.cs index 9bdbb5c..c67950a 100644 --- a/src/CodeShellManager/Services/GitWorktrees.cs +++ b/src/CodeShellManager/Services/GitWorktrees.cs @@ -14,9 +14,46 @@ namespace CodeShellManager.Services; /// public static class GitWorktrees { - /// Forward slashes, no trailing separator — the form git prints and compares. - public static string Normalize(string path) => - string.IsNullOrEmpty(path) ? "" : path.Replace('\\', '/').TrimEnd('/'); + /// + /// Forward slashes, no trailing separator — the form git prints and compares. The two + /// spellings of a WSL share (\\wsl.localhost\ and \\wsl$\) are folded to + /// one: GitService always reports \\wsl$, and a session opened as + /// \\wsl.localhost otherwise matched no worktree at all. + /// + public static string Normalize(string path) + { + if (string.IsNullOrEmpty(path)) return ""; + string n = path.Replace('\\', '/').TrimEnd('/'); + const string localhost = "//wsl.localhost/"; + if (n.StartsWith(localhost, StringComparison.OrdinalIgnoreCase)) n = "//wsl$/" + n[localhost.Length..]; + return n; + } + + /// + /// The comparable form of a path and how to compare it. Windows paths are + /// case-insensitive; inside a WSL distro they are NOT — /home/u/wt and + /// /home/u/WT are different folders, and treating them as one would close (and + /// delete the sleeping) sessions of the wrong worktree. The distro name itself is + /// case-insensitive, so only that segment is folded. + /// + private static (string key, StringComparison cmp) Key(string path) + { + string n = Normalize(path); + const string wsl = "//wsl$/"; + if (!n.StartsWith(wsl, StringComparison.OrdinalIgnoreCase)) return (n, StringComparison.OrdinalIgnoreCase); + int slash = n.IndexOf('/', wsl.Length); + string distro = slash < 0 ? n[wsl.Length..] : n[wsl.Length..slash]; + string rest = slash < 0 ? "" : n[slash..]; + return (wsl + distro.ToLowerInvariant() + rest, StringComparison.Ordinal); + } + + /// Same folder, by — case rules per Windows / WSL. + public static bool SamePath(string a, string b) + { + var (ka, cmp) = Key(a); + var (kb, _) = Key(b); + return ka.Length > 0 && ka.Equals(kb, cmp); + } /// /// The folder's real path, in d form: 8.3 short names expanded, @@ -67,10 +104,10 @@ private static extern SafeFileHandle CreateFileW(string name, uint access, uint /// public static bool IsInside(string folder, string root) { - string f = Normalize(folder), r = Normalize(root); + var (f, cmp) = Key(folder); + var (r, _) = Key(root); if (f.Length == 0 || r.Length == 0) return false; - return f.Equals(r, StringComparison.OrdinalIgnoreCase) - || f.StartsWith(r + "/", StringComparison.OrdinalIgnoreCase); + return f.Equals(r, cmp) || f.StartsWith(r + "/", cmp); } /// @@ -87,7 +124,7 @@ public static bool IsInside(string folder, string root) /// entry is "main" too but has no working tree, so it is never offered for removal. /// public static bool IsMain(IReadOnlyList worktrees, WorktreeInfo wt) => - worktrees.Count > 0 && Normalize(worktrees[0].Path).Equals(Normalize(wt.Path), StringComparison.OrdinalIgnoreCase); + worktrees.Count > 0 && SamePath(worktrees[0].Path, wt.Path); /// /// Branches checked out in a worktree OTHER than , mapped to that @@ -101,13 +138,23 @@ public static IReadOnlyDictionary BranchesElsewhere( foreach (var w in worktrees) { if (w.IsBare || string.IsNullOrEmpty(w.Branch)) continue; - if (current != null && Normalize(w.Path).Equals(Normalize(current.Path), StringComparison.OrdinalIgnoreCase)) + if (current != null && SamePath(w.Path, current.Path)) continue; map.TryAdd(w.Branch, w); } return map; } + /// + /// Other worktrees that live INSIDE 's folder. Removing the target + /// would delete them too: git's clean check does not look inside an ignored folder, and a + /// nested worktree usually sits in one (.worktrees/ in .gitignore). Reproduced with + /// git 2.54 — `worktree remove` on the outer one exited 0 and deleted the inner one along + /// with its uncommitted file. So the menu refuses while any are listed. + /// + public static IReadOnlyList NestedInside(IReadOnlyList worktrees, WorktreeInfo target) => + worktrees.Where(w => !w.IsBare && !SamePath(w.Path, target.Path) && IsInside(w.Path, target.Path)).ToList(); + /// The worktree's folder name — what the menus show. public static string Name(string path) { diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index 910fb97..dbfc3de 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -329,12 +329,13 @@ private async Task PollGitInfoAsync(CancellationToken ct) [RelayCommand] private void Close() => CloseRequested?.Invoke(this); + /// + /// Opens the folder via — not explorer.exe <folder>, + /// which silently opened Documents for forward-slash paths and names with a comma. Off the + /// UI thread: the existence check is \\wsl$ I/O for a WSL session. + /// [RelayCommand] - private void OpenInExplorer() - { - if (System.IO.Directory.Exists(Session.WorkingFolder)) - System.Diagnostics.Process.Start("explorer.exe", Session.WorkingFolder); - } + private void OpenInExplorer() => _ = FolderOpener.OpenAsync(Session.WorkingFolder); /// /// Applies a CSM shell-integration payload (OSC 9001) emitted by the running program. diff --git a/tests/CodeShellManager.Tests/FolderOpenerTests.cs b/tests/CodeShellManager.Tests/FolderOpenerTests.cs new file mode 100644 index 0000000..91fbee4 --- /dev/null +++ b/tests/CodeShellManager.Tests/FolderOpenerTests.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// "Open in Explorer" opened Documents instead of the folder for forward-slash paths and names +/// with a comma. The fix opens the folder through the shell, which only must never happen for +/// something that isn't an existing directory. See "Opening a folder in Explorer" in CLAUDE.md. +/// +public sealed class FolderOpenerTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "csm-folderopener-" + Guid.NewGuid().ToString("N")[..8]); + + public FolderOpenerTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + [Fact] + public void A_forward_slash_path_from_git_becomes_the_windows_form() + { + string dir = Directory.CreateDirectory(Path.Combine(_root, "wt-feat")).FullName; + string asGitPrintsIt = dir.Replace('\\', '/'); + + Assert.Equal(Path.GetFullPath(dir), FolderOpener.Target(asGitPrintsIt)); + Assert.DoesNotContain('/', FolderOpener.Target(asGitPrintsIt)!); + } + + [Theory] + [InlineData("a,b")] // Explorer splits its arguments on commas, even quoted + [InlineData("x & y")] + [InlineData("100%")] + [InlineData("with space")] + public void Awkward_folder_names_are_accepted_unchanged(string name) + { + string dir = Directory.CreateDirectory(Path.Combine(_root, name)).FullName; + Assert.Equal(dir, FolderOpener.Target(dir)); + Assert.Equal(dir, FolderOpener.Target(dir + "\\")); // trailing separator dropped + } + + [Fact] + public void A_drive_root_keeps_its_separator() + { + string root = Path.GetPathRoot(_root)!; // "C:\" + Assert.Equal(root, FolderOpener.Target(root.TrimEnd('\\') + "/")); + } + + [Fact] + public void Nothing_but_an_existing_directory_is_ever_opened() + { + // ShellExecute on a FILE runs it, and a session's folder comes from state.json and + // imports — untrusted. So a file, a missing path, a relative path or a URL must all be + // refused before the shell sees them. + string file = Path.Combine(_root, "payload.cmd"); + File.WriteAllText(file, "@echo pwned"); + + Assert.Null(FolderOpener.Target(file)); + Assert.Null(FolderOpener.Target(Path.Combine(_root, "missing"))); + Assert.Null(FolderOpener.Target("relative\\folder")); + Assert.Null(FolderOpener.Target("https://example.com/")); + Assert.Null(FolderOpener.Target("calc.exe")); + Assert.Null(FolderOpener.Target("")); + Assert.Null(FolderOpener.Target(null)); + } + + [Fact] + public async Task OpenAsync_refuses_a_file_without_throwing() + { + string file = Path.Combine(_root, "payload.cmd"); + File.WriteAllText(file, "@echo pwned"); + Assert.False(await FolderOpener.OpenAsync(file)); + } +} diff --git a/tests/CodeShellManager.Tests/GitWorktreeTests.cs b/tests/CodeShellManager.Tests/GitWorktreeTests.cs index 480bf40..dadce69 100644 --- a/tests/CodeShellManager.Tests/GitWorktreeTests.cs +++ b/tests/CodeShellManager.Tests/GitWorktreeTests.cs @@ -27,6 +27,30 @@ private static WorktreeInfo Wt(string path, string? branch = null, bool bare = f public void IsInside_is_a_path_test_not_a_string_prefix(string folder, string root, bool expected) => Assert.Equal(expected, GitWorktrees.IsInside(folder, root)); + [Theory] + // Inside a distro, Linux paths are case-sensitive: /home/u/WT is a different folder, and + // matching it would close (and delete the sleeping) sessions of the wrong worktree. + [InlineData(@"\\wsl$\Ubuntu\home\u\WT", @"\\wsl$\Ubuntu\home\u\wt", false)] + [InlineData(@"\\wsl$\Ubuntu\home\u\wt\src", @"\\wsl$\Ubuntu\home\u\wt", true)] + // …but the distro name is not, and the two share spellings are the same place. + [InlineData(@"\\wsl$\ubuntu\home\u\wt", @"\\wsl$\Ubuntu\home\u\wt", true)] + [InlineData(@"\\wsl.localhost\Ubuntu\home\u\wt", @"\\wsl$\Ubuntu\home\u\wt", true)] + [InlineData(@"\\WSL.LOCALHOST\Ubuntu\home\u\wt\x", @"\\wsl$\Ubuntu\home\u\wt", true)] + public void Wsl_paths_follow_linux_case_rules_and_both_share_spellings(string folder, string root, bool expected) + { + Assert.Equal(expected, GitWorktrees.IsInside(folder, root)); + Assert.Equal(expected && !folder.EndsWith(@"\src") && !folder.EndsWith(@"\x"), GitWorktrees.SamePath(folder, root)); + } + + [Fact] + public void NestedInside_finds_worktrees_living_in_the_target_folder_only() + { + var outer = Wt("C:/w/a", "a"); + var wts = new[] { Wt("C:/w/main", "main"), outer, Wt("C:/w/a/.worktrees/b", "b"), Wt("C:/w/a-sibling", "s") }; + Assert.Equal(new[] { "b" }, GitWorktrees.NestedInside(wts, outer).Select(w => w.Branch)); + Assert.Empty(GitWorktrees.NestedInside(wts, wts[2])); + } + [Fact] public void Containing_picks_the_deepest_worktree() { @@ -220,6 +244,28 @@ public void Linked_worktree_is_told_apart_from_the_main_checkout_and_a_submodule Assert.Null(GitWorktrees.LinkedWorktreeOf(fakeSub, Main)); } + [Fact] + public async Task A_worktree_nested_in_an_ignored_folder_is_detected_because_git_would_delete_it() + { + // The layout the pre-release review flagged, reproduced: worktree B inside A's + // .gitignored .worktrees/ folder, with uncommitted work. git reports A clean, and + // `worktree remove A` deletes B too. The menu must see B and refuse. + File.WriteAllText(Path.Combine(Main, ".gitignore"), ".worktrees/\n"); + Git(Main, "add", ".gitignore"); + Git(Main, "commit", "-q", "-m", "ignore"); + AddWorktree("A", "a"); + Git(WtPath("A"), "worktree", "add", "-q", "-b", "b", "--", Path.Combine(WtPath("A"), ".worktrees", "b")); + File.WriteAllText(Path.Combine(WtPath("A"), ".worktrees", "b", "work.txt"), "uncommitted\n"); + + var status = await GitService.GetStatusAsync(WtPath("A")); + Assert.False(status!.IsDirty); // git really does call A clean + + var wts = GitWorktrees.Canonicalize(await GitService.ListWorktreesAsync(Main)); + var a = GitWorktrees.Containing(wts, GitWorktrees.Canonical(WtPath("A")))!; + Assert.Equal("a", a.Branch); + Assert.Equal(new[] { "b" }, GitWorktrees.NestedInside(wts, a).Select(w => w.Branch)); + } + [Fact] public async Task Removing_a_clean_worktree_deletes_it_and_keeps_its_branch() {