From 1b4ffcd069dcea86e8f1c903ba70ff8095561a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 11:08:36 +0100 Subject: [PATCH] =?UTF-8?q?feat(git):=20worktrees=20in=20the=20git=20menu?= =?UTF-8?q?=20=E2=80=94=20where=20am=20I,=20switch-aware,=20go=20to,=20pru?= =?UTF-8?q?ne,=20remove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worktrees are how this tool is mostly used (a Claude session per task, each in its own worktree), so the git menu now knows about them: - Chip tooltip and menu header end with "· worktree of " in a linked worktree (from the .git FILE, no git spawn; /worktrees/ tells it apart from a submodule). - Switch branch lists a branch checked out in another worktree as "feature — in wt-feature" and opens that worktree instead of failing with "already used by worktree at …". - Worktrees ▸ lists every worktree with branch, dirty dot and ↑/↓ (fetched only when it opens); click focuses its session, wakes a sleeping one, or opens a new one. Then New worktree from this branch…, Prune N missing worktrees, and Remove this worktree…. Remove is guarded three ways: refused up front when the worktree has changes; confirmed with the exact folder and the sessions it closes / sleeping ones it deletes; run as `git worktree remove` WITHOUT --force, so git itself refuses modified, untracked and locked worktrees and the main checkout. Branch deletion is a separate question afterwards, with `branch -d`, so an unmerged branch is kept. Sessions in the folder are closed and their PTY exits awaited first, and the remove runs from the main worktree: on Windows a process whose cwd is in the folder locks it and git fails half-way ("failed to delete … Permission denied" — reproduced by running the remove from inside the worktree). Found by the end-to-end run: git lists long paths, but a session can hold an 8.3 spelling (this machine's %TEMP%; GitHub runners' RUNNER~1) or a junction, so a string compare put the session in no worktree and Remove never appeared. GitWorktrees.Canonical resolves both sides via GetFinalPathNameByHandle, off the UI thread. Also: GitService.RunOpAsync is Task.Run-wrapped (its Directory.Exists ran on the caller's thread — \wsl$ I/O on the UI thread for WSL), and GitServiceThreadingTests find the repo root when .git is a file, so they pass in a worktree checkout. Tests: GitWorktreeTests against real git — listing/mapping, the switch refusal, linked vs main vs submodule, clean remove (branch kept), refusing modified/untracked/locked/main, branch -d keeping unmerged, deleting "-x", prune touching only the missing one, 8.3 and junction spellings. --force on the remove fails 3; dropping -- from branch delete fails 2. Run end to end in the app with a terminal open inside the worktree: session closed, folder deleted, merged branch deleted. 747/747. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- CLAUDE.md | 54 +++ src/CodeShellManager/MainWindow.Git.cs | 324 ++++++++++++++++- src/CodeShellManager/Services/GitService.cs | 47 ++- .../Services/GitStatusText.cs | 27 +- src/CodeShellManager/Services/GitWorktrees.cs | 169 +++++++++ .../GitServiceThreadingTests.cs | 8 +- .../GitWorktreeTests.cs | 336 ++++++++++++++++++ 7 files changed, 954 insertions(+), 11 deletions(-) create mode 100644 src/CodeShellManager/Services/GitWorktrees.cs create mode 100644 tests/CodeShellManager.Tests/GitWorktreeTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 7b6bdc8..555e68e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -803,6 +803,60 @@ branch, and the remote-only branch `--no-guess` must not invent. Removing `--` o fails 6 tests each; removing `--no-rebase` fails none, because git 2.54 already lets `--ff-only` win — it is kept as a guard for other git versions. +### Worktrees in the git menu + +Worktrees are the normal way this tool is used — a Claude session per task, each in its own +worktree — so the git menu knows about them. Logic is in the WPF-free `Services/GitWorktrees`; +the UI in `MainWindow.Git.cs`. + +- **Which worktree am I in.** In a linked worktree the chip tooltip and menu header end with + `· worktree of
`. Worked out from the `.git` FILE (`gitdir: …/worktrees/`), + with no git spawn, off the UI thread. A submodule's `.git` is a file too, so the + `/worktrees/` segment is what tells them apart. +- **Switch branch knows where branches are checked out.** git allows a branch in one worktree at + a time; switching to one held elsewhere fails with `already used by worktree at …`. Such a + branch is listed as `feature — in wt-feature` and opens that worktree instead. +- **Worktrees ▸** lists every worktree (branch, dirty dot, ↑/↓ — one status per worktree, only + when the submenu opens). Clicking one focuses its live session, else wakes a sleeping one, + else opens a new session there. Then: New worktree from this branch…, **Prune N missing + worktrees** (only git's bookkeeping for folders already deleted), **Remove this worktree…**. + +**Compare paths by their real form, never as strings.** git lists a worktree's long path; a +session can hold an 8.3 spelling of the same folder, or reach it through a junction. This +machine's `%TEMP%` is `C:\Users\MORTEN~1\…` (GitHub's runners use `RUNNER~1`), and the first +end-to-end run of "Remove this worktree" failed on exactly that: the session was judged to be in +no worktree, so the item never appeared. `GitWorktrees.Canonical` resolves both sides through +`GetFinalPathNameByHandle` (expands 8.3, follows junctions and symlinks) before any comparison. +It touches the disk, so it always runs off the UI thread. + +**Remove is the one write here beyond pull/switch, so it is guarded three ways:** refused up front +when the worktree has changes; confirmed with the exact folder and the sessions it will close +(and sleeping ones it will delete); and run as `git worktree remove` **without** `--force`, so +git itself refuses modified, untracked or locked worktrees and never removes the main checkout. +Deleting the branch is a separate, explicit question afterwards, using `branch -d` (not `-D`), so +an unmerged branch is kept. + +**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 +worktree. The remove therefore runs from the main worktree, after every session in the folder +has been closed and its PTY's exit awaited (bounded, `HasExited` — see "Waiting for a PTY to +exit"). Something else holding the folder (an editor, a dev server) still produces that error; +`FailureHint` explains it and gives the `--force` command to finish with — safe, because git +had already checked the tree was clean. + +`GitService.RunOpAsync` is now `Task.Run`-wrapped: its `Directory.Exists` used to run on the +caller's thread, which for a WSL session is `\\wsl$` I/O on the UI thread. + +Covered by `GitWorktreeTests`, against real git: listing and branch mapping; the switch refusal; +linked vs main vs submodule; removing a clean worktree (branch kept); refusing modified, +untracked and locked ones and the main checkout; `branch -d` keeping an unmerged branch; +deleting a branch named `-x`; prune touching only the missing one; and the 8.3 and junction +spellings. Adding `--force` to the remove fails 3 tests; dropping `--` from the branch delete +fails 2. The full flow was also run end to end in the app with a terminal open inside the +worktree: session closed, folder deleted, merged branch deleted. `GitServiceThreadingTests` +now also find the repo root from a worktree checkout, where `.git` is a file. + ## 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 31d8da2..bff16ad 100644 --- a/src/CodeShellManager/MainWindow.Git.cs +++ b/src/CodeShellManager/MainWindow.Git.cs @@ -17,6 +17,7 @@ using Clipboard = System.Windows.Clipboard; using ContextMenu = System.Windows.Controls.ContextMenu; using MenuItem = System.Windows.Controls.MenuItem; +using MessageBox = System.Windows.MessageBox; namespace CodeShellManager; @@ -38,6 +39,8 @@ private sealed class GitPaneUi public required Border Panel; public required TextBlock PanelHeader; public required WpfTextBox PanelText; + /// Main repo's name when this pane is in a LINKED worktree, else null. + public string? WorktreeOf; } private readonly Dictionary _gitPanes = []; @@ -155,11 +158,34 @@ private static string GitRepoKey(SessionViewModel vm) => { if (e.PropertyName is nameof(SessionViewModel.GitInfoVersion) or nameof(SessionViewModel.RepoRoot)) Dispatcher.Invoke(() => UpdateGitChip(vm)); + if (e.PropertyName is nameof(SessionViewModel.RepoRoot)) + ProbeLinkedWorktree(vm); }; UpdateGitChip(vm); + ProbeLinkedWorktree(vm); return (chip, panel); } + /// + /// Works out, off the UI thread, whether this pane is in a linked worktree — a few file + /// reads walking up from the folder, which for a WSL session means \\wsl$ I/O. + /// + private void ProbeLinkedWorktree(SessionViewModel vm) + { + if (vm.Session.Kind == SessionKind.Ssh || string.IsNullOrEmpty(vm.RepoRoot)) return; + string folder = vm.WorkingFolder, root = vm.RepoRoot; + _ = Task.Run(() => GitWorktrees.LinkedWorktreeOf(folder, root)).ContinueWith(t => + { + if (!t.IsCompletedSuccessfully) return; + Dispatcher.BeginInvoke(() => + { + if (!_gitPanes.TryGetValue(vm.Id, out var ui) || ui.WorktreeOf == t.Result) return; + ui.WorktreeOf = t.Result; + UpdateGitChip(vm); + }); + }, TaskScheduler.Default); + } + /// Repaints one pane's chip from the VM. Cheap; called on every git notification. private void UpdateGitChip(SessionViewModel vm) { @@ -186,7 +212,7 @@ private void UpdateGitChip(SessionViewModel vm) if (sync.Length > 0) t.Inlines.Add(new Run(" " + sync) { Foreground = GitSync }); ui.Chip.ToolTip = GitStatusText.MenuHeader( - vm.GitBranch, vm.GitUpstream, vm.GitAhead, vm.GitBehind, vm.GitChangeCount); + vm.GitBranch, vm.GitUpstream, vm.GitAhead, vm.GitBehind, vm.GitChangeCount, ui.WorktreeOf); } private void UpdateGitChipsForRepo(string repoKey) @@ -214,7 +240,8 @@ private void ShowGitMenu(SessionViewModel vm, WpfButton anchor) { Header = new TextBlock { - Text = GitStatusText.MenuHeader(vm.GitBranch, vm.GitUpstream, vm.GitAhead, vm.GitBehind, vm.GitChangeCount), + Text = GitStatusText.MenuHeader(vm.GitBranch, vm.GitUpstream, vm.GitAhead, vm.GitBehind, vm.GitChangeCount, + _gitPanes.TryGetValue(vm.Id, out var paneUi) ? paneUi.WorktreeOf : null), }, IsEnabled = false, }); @@ -246,7 +273,14 @@ private void ShowGitMenu(SessionViewModel vm, WpfButton anchor) { if (loaded) return; loaded = true; - var branches = await GitService.ListBranchesAsync(vm.WorkingFolder); + string folder = vm.WorkingFolder; + var branchesTask = Task.Run(() => GitService.ListBranchesAsync(folder)); + var worktreesTask = LoadWorktreesAsync(folder); + var branches = await branchesTask; + var (worktrees, here) = await worktreesTask; + // A branch checked out in another worktree can't be switched to here — git allows + // it in one worktree at a time. Offer to go to that worktree instead. + var elsewhere = GitWorktrees.BranchesElsewhere(worktrees, GitWorktrees.Containing(worktrees, here)); switchMenu.Items.Clear(); if (branches.Count == 0) { @@ -256,6 +290,20 @@ private void ShowGitMenu(SessionViewModel vm, WpfButton anchor) foreach (string b in branches) { bool current = b == vm.GitBranch; + string target = b; + if (!current && elsewhere.TryGetValue(b, out var wt)) + { + var go = new MenuItem + { + Header = new TextBlock { Text = $"{b} — in {GitWorktrees.Name(wt.Path)}" }, + ToolTip = $"Checked out in {wt.Path}. Git allows a branch in only one worktree " + + "at a time, so this opens that worktree instead of switching.", + }; + string path = wt.Path; + go.Click += async (_, _) => await GoToWorktreeAsync(vm, path); + switchMenu.Items.Add(go); + continue; + } var mi = new MenuItem { Header = new TextBlock { Text = b }, @@ -263,13 +311,14 @@ private void ShowGitMenu(SessionViewModel vm, WpfButton anchor) IsChecked = current, IsEnabled = !current, }; - string target = b; mi.Click += async (_, _) => await SwitchBranchAsync(vm, target); switchMenu.Items.Add(mi); } }; menu.Items.Add(switchMenu); + menu.Items.Add(BuildWorktreesMenu(vm, busy)); + menu.Items.Add(new Separator()); var copy = new MenuItem { Header = "Copy branch name", IsEnabled = !string.IsNullOrEmpty(vm.GitBranch) }; @@ -347,6 +396,273 @@ private async Task SwitchBranchAsync(SessionViewModel vm, string branch) return result; } + // ── worktrees ─────────────────────────────────────────────────────────────────── + + /// + /// "Worktrees ▸": every worktree of the repo with its branch and state, go-to on click, + /// then New / Prune / Remove. Filled when it opens — one `worktree list` plus one status + /// per worktree, in parallel — so an unopened menu costs nothing. + /// + private MenuItem BuildWorktreesMenu(SessionViewModel vm, bool busy) + { + var wtMenu = new MenuItem { Header = "Worktrees" }; + wtMenu.Items.Add(new MenuItem { Header = "Loading…", IsEnabled = false }); + bool loaded = false; + wtMenu.SubmenuOpened += async (_, _) => + { + if (loaded) return; + loaded = true; + var (worktrees, here) = await LoadWorktreesAsync(vm.WorkingFolder); + var current = GitWorktrees.Containing(worktrees, here); + var live = worktrees.Where(w => !w.IsBare && !w.IsPrunable).ToList(); + var statuses = await Task.WhenAll(live.Select(w => Task.Run(() => GitService.GetStatusAsync(w.Path)))); + var statusOf = live.Zip(statuses).ToDictionary(p => p.First.Path, p => p.Second); + + wtMenu.Items.Clear(); + foreach (var w in worktrees) + { + if (w.IsBare) continue; + bool isCurrent = current != null && GitWorktrees.Normalize(w.Path) + .Equals(GitWorktrees.Normalize(current.Path), StringComparison.OrdinalIgnoreCase); + var mi = new MenuItem + { + Header = new TextBlock { Text = GitWorktrees.Label(w, statusOf.GetValueOrDefault(w.Path)) }, + IsChecked = isCurrent, + IsEnabled = !isCurrent && !w.IsPrunable, + ToolTip = w.IsPrunable ? $"{w.Path} no longer exists — prune to forget it." : w.Path, + }; + ToolTipService.SetShowOnDisabled(mi, true); + string path = w.Path; + mi.Click += async (_, _) => await GoToWorktreeAsync(vm, path); + wtMenu.Items.Add(mi); + } + + wtMenu.Items.Add(new Separator()); + var add = new MenuItem { Header = "New worktree from this branch…" }; + add.Click += async (_, _) => await OpenNewWorktreeDialogAsync(vm); + wtMenu.Items.Add(add); + + int prunable = worktrees.Count(w => w.IsPrunable); + if (prunable > 0) + { + var prune = new MenuItem + { + Header = prunable == 1 ? "Prune 1 missing worktree" : $"Prune {prunable} missing worktrees", + IsEnabled = !busy, + ToolTip = "Forgets worktrees whose folder was deleted. Only git's own bookkeeping is removed.", + }; + prune.Click += async (_, _) => await PruneWorktreesAsync(vm, prunable); + wtMenu.Items.Add(prune); + } + + if (current != null && !GitWorktrees.IsMain(worktrees, current)) + { + var remove = new MenuItem + { + Header = "Remove this worktree…", + IsEnabled = !busy && !current.IsLocked, + ToolTip = current.IsLocked + ? "This worktree is locked (git worktree unlock)." + : "Closes the sessions in it and deletes the folder. Git refuses if anything is uncommitted.", + }; + ToolTipService.SetShowOnDisabled(remove, true); + string mainPath = worktrees[0].Path; + var target = current; + remove.Click += async (_, _) => + await RemoveWorktreeAsync(vm, target, mainPath, statusOf.GetValueOrDefault(target.Path)); + wtMenu.Items.Add(remove); + } + }; + return wtMenu; + } + + /// + /// `git worktree list` with every path canonicalized, plus 's own + /// canonical path — both off the UI thread, since canonicalizing touches the disk. + /// + private static Task<(IReadOnlyList worktrees, string here)> LoadWorktreesAsync(string folder) => + Task.Run(async () => + { + var raw = await GitService.ListWorktreesAsync(folder).ConfigureAwait(false); + return (GitWorktrees.Canonicalize(raw), GitWorktrees.Canonical(folder)); + }); + + /// + /// Live and sleeping sessions whose folder is inside + /// (already canonical). Folders are canonicalized off the UI thread — see + /// for why a string compare is not enough. + /// + private async Task<(List live, List dormant)> SessionsInWorktreeAsync(string worktreePath) + { + var live = _vm.Sessions.Select(s => (s, s.WorkingFolder)).ToList(); + var dormant = _sessionManager.Sessions.Where(s => s.IsDormant).Select(s => (s, s.WorkingFolder)).ToList(); + return await Task.Run(() => ( + live.Where(x => GitWorktrees.IsInside(GitWorktrees.Canonical(x.WorkingFolder), worktreePath)).Select(x => x.s).ToList(), + dormant.Where(x => GitWorktrees.IsInside(GitWorktrees.Canonical(x.WorkingFolder), worktreePath)).Select(x => x.s).ToList())); + } + + /// + /// Takes the user to a worktree: its live session if there is one, else wakes a sleeping + /// one, else opens a new session there (same command, group and profile as this one). + /// + private async Task GoToWorktreeAsync(SessionViewModel from, string worktreePath) + { + var (liveHere, dormantHere) = await SessionsInWorktreeAsync(worktreePath); + var live = liveHere.FirstOrDefault(); + if (live != null) + { + _vm.FocusSessionCommand.Execute(live); + return; + } + var dormant = dormantHere.FirstOrDefault(); + if (dormant != null) + { + await WakeSessionAsync(dormant); + return; + } + await LaunchSessionInSiblingWorktreeAsync(from, worktreePath); + } + + private async Task PruneWorktreesAsync(SessionViewModel vm, int count) + { + var r = await RunGitOpAsync(vm, "worktree prune", GitService.PruneWorktreesAsync); + if (r is not { Ok: true }) return; + ToastHelper.Show("git worktree prune", + count == 1 ? "Forgot 1 worktree whose folder was gone." : $"Forgot {count} worktrees whose folders were gone."); + } + + /// + /// The one write in this menu beyond pull/switch, so it is guarded three ways: refused up + /// front when the worktree has changes, confirmed with the exact folder and the sessions it + /// will close, and run as `git worktree remove` WITHOUT --force so git refuses anything + /// uncommitted or untracked itself. Sessions in the folder are closed and their processes + /// waited for before git runs: on Windows a process whose cwd is inside the folder locks it, + /// and git then fails half-way through deleting it. + /// + private async Task RemoveWorktreeAsync( + SessionViewModel vm, GitService.WorktreeInfo wt, string mainPath, GitRepoStatus? status) + { + string name = GitWorktrees.Name(wt.Path); + if (status is { IsDirty: true }) + { + MessageBox.Show(this, + $"'{name}' has {status.Changes.Count} uncommitted or untracked change(s).\n\n" + + "Commit or stash them first — removing the worktree would delete them.", + "Remove worktree", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var (liveHere, dormantHere) = await SessionsInWorktreeAsync(wt.Path); + + var msg = new System.Text.StringBuilder(); + msg.AppendLine($"Remove the worktree '{name}'" + (string.IsNullOrEmpty(wt.Branch) ? "?" : $" (branch {wt.Branch})?")); + msg.AppendLine(); + msg.AppendLine($"Folder: {wt.Path}"); + msg.AppendLine(); + msg.AppendLine("The folder is deleted. Git refuses if anything in it is uncommitted or untracked; " + + "ignored files such as build output are deleted with it. The branch itself is kept " + + "unless you choose to delete it next."); + if (liveHere.Count > 0) + { + msg.AppendLine(); + msg.AppendLine("These sessions will be closed:"); + foreach (var s in liveHere) msg.AppendLine($" • {s.DisplayName}"); + } + if (dormantHere.Count > 0) + { + msg.AppendLine(); + msg.AppendLine("These sleeping sessions will be deleted:"); + foreach (var s in dormantHere) msg.AppendLine($" • {(string.IsNullOrWhiteSpace(s.Name) ? s.DefaultDisplayName : s.Name)}"); + } + if (MessageBox.Show(this, msg.ToString().TrimEnd(), "Remove worktree", + MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) != MessageBoxResult.Yes) + return; + + string key = GitRepoKey(vm); + if (!_gitBusyRepos.Add(key)) + { + ToastHelper.Show("Git busy", "A git operation is already running for this repository."); + return; + } + UpdateGitChipsForRepo(key); + try + { + // Close first and wait for the processes to exit — see the summary for why. + var exits = new List(); + foreach (var s in liveHere) + { + var pty = s.Pty; + s.CloseCommand.Execute(null); + 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) + { + string? hint = GitStatusText.FailureHint(r.Output); + MessageBox.Show(this, + $"git worktree remove failed{(hint is null ? "." : $": {hint}")}\n\n{r.Output}" + + (liveHere.Count > 0 ? "\n\nThe sessions that were in it are closed; Ctrl+Shift+T reopens them." : ""), + "Remove worktree", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + string removed = $"Removed worktree {name}."; + if (!string.IsNullOrEmpty(wt.Branch) && + MessageBox.Show(this, + $"Worktree '{name}' removed.\n\nAlso delete the branch '{wt.Branch}'?\n\n" + + "Git only deletes it if it is fully merged; otherwise it is kept and nothing changes.", + "Delete branch", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) + == MessageBoxResult.Yes) + { + var b = await GitService.DeleteMergedBranchAsync(mainPath, wt.Branch); + if (b.Ok) removed += $" Deleted branch {wt.Branch}."; + else + MessageBox.Show(this, + $"The branch '{wt.Branch}' was kept: {GitStatusText.FailureHint(b.Output) ?? b.Output}", + "Delete branch", MessageBoxButton.OK, MessageBoxImage.Information); + } + ToastHelper.Show("git worktree remove", removed); + } + finally + { + _gitBusyRepos.Remove(key); + var peers = _vm.Sessions + .Where(s => string.Equals(GitRepoKey(s), key, StringComparison.OrdinalIgnoreCase)).ToList(); + await Task.WhenAll(peers.Select(s => s.RefreshGitInfoAsync())); + UpdateGitChipsForRepo(key); + } + } + + /// + /// Waits for a closed session's process to exit, bounded. HasExited, not IsRunning — see + /// "Waiting for a PTY to exit" in CLAUDE.md. + /// + private static async Task WaitForPtyExitAsync(Terminal.PseudoTerminal pty, int timeoutMs) + { + if (pty.HasExited) return; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnExit() => tcs.TrySetResult(); + pty.Exited += OnExit; + try + { + if (pty.HasExited) return; + await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); + } + finally { pty.Exited -= OnExit; } + } + private async Task ShowGitStatusAsync(SessionViewModel vm) { ShowGitPanel(vm, "git status", "…"); diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index 5812af7..e7f593a 100644 --- a/src/CodeShellManager/Services/GitService.cs +++ b/src/CodeShellManager/Services/GitService.cs @@ -141,8 +141,51 @@ public static Task SwitchBranchAsync(string folderPath, string bran public static Task StatusTextAsync(string folderPath) => RunOpAsync(folderPath, new[] { "-c", "color.status=false", "status" }, timeoutMs: 10_000, readOnly: true); - private static async Task RunOpAsync( - string folderPath, string[] args, int timeoutMs, bool readOnly = false) + /// + /// git worktree remove -- <path>, deliberately WITHOUT --force: git then + /// refuses a worktree with modified or untracked files, and a locked one, which is the + /// guard that makes this safe to offer in a menu. Ignored files (build output) are deleted. + /// + /// + /// A folder of the same repo OUTSIDE the worktree being removed — the main worktree. On + /// Windows a process whose cwd is inside the folder locks it, and git then fails half-way + /// with "failed to delete … Permission denied" (verified against git 2.54 by running the + /// remove from inside the worktree itself). The same is true of every terminal open there, + /// which is why the caller closes those sessions first. + /// + public static Task RemoveWorktreeAsync(string runFrom, string worktreePath) => + RunOpAsync(runFrom, RemoveWorktreeArgs(worktreePath), timeoutMs: 60_000); + + internal static string[] RemoveWorktreeArgs(string worktreePath) => + new[] { "worktree", "remove", "--", worktreePath }; + + /// + /// git branch -d -- <branch> — lower-case -d, so git refuses a branch + /// that is not fully merged. -- because the name comes from the repo. + /// + public static Task DeleteMergedBranchAsync(string runFrom, string branch) => + RunOpAsync(runFrom, DeleteMergedBranchArgs(branch), timeoutMs: 30_000); + + internal static string[] DeleteMergedBranchArgs(string branch) => + new[] { "branch", "-d", "--", branch }; + + /// + /// git worktree prune: forgets worktrees whose folder no longer exists. Only ever + /// removes git's own bookkeeping under .git/worktrees, never a working tree. + /// + public static Task PruneWorktreesAsync(string folderPath) => + RunOpAsync(folderPath, new[] { "worktree", "prune" }, timeoutMs: 30_000); + + private static Task RunOpAsync( + string folderPath, string[] args, int timeoutMs, bool readOnly = false) => + // Task.Run so the Directory.Exists below can't run on the caller's thread: these are + // called from the UI thread, and for a WSL session the folder is a \\wsl$ path, where + // even an existence check can block on the distro. See "Never spawn a process on the + // UI thread" in CLAUDE.md. + Task.Run(() => RunOpCoreAsync(folderPath, args, timeoutMs, readOnly)); + + private static async Task RunOpCoreAsync( + string folderPath, string[] args, int timeoutMs, bool readOnly) { if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath)) return new GitOpResult(false, "Folder does not exist.", TimedOut: false); diff --git a/src/CodeShellManager/Services/GitStatusText.cs b/src/CodeShellManager/Services/GitStatusText.cs index 78d587f..da41f32 100644 --- a/src/CodeShellManager/Services/GitStatusText.cs +++ b/src/CodeShellManager/Services/GitStatusText.cs @@ -18,9 +18,12 @@ public static string Sync(int? ahead, int? behind) /// /// The disabled first line of the menu, e.g. "main → origin/main · ↑2 ↓5 · 3 changed" - /// or "main · no upstream · clean". + /// or "main · no upstream · clean". In a linked worktree, + /// (the main repo's name) adds " · worktree of X" — otherwise two panes on one repo + /// differ only by their folder path. /// - public static string MenuHeader(string? branch, string? upstream, int? ahead, int? behind, int changeCount) + public static string MenuHeader(string? branch, string? upstream, int? ahead, int? behind, int changeCount, + string? worktreeOf = null) { string head = string.IsNullOrEmpty(branch) ? "detached HEAD" : branch; string track = string.IsNullOrEmpty(upstream) ? " · no upstream" : $" → {upstream}"; @@ -35,7 +38,8 @@ public static string MenuHeader(string? branch, string? upstream, int? ahead, in 1 => "1 changed", _ => $"{changeCount} changed", }; - return $"{head}{track}" + (sync.Length > 0 ? $" · {sync}" : "") + $" · {changes}"; + return $"{head}{track}" + (sync.Length > 0 ? $" · {sync}" : "") + $" · {changes}" + + (string.IsNullOrEmpty(worktreeOf) ? "" : $" · worktree of {worktreeOf}"); } /// Toast text after a successful fetch, from the freshly re-read status. @@ -60,6 +64,23 @@ public static string FetchSummary(string? branch, string? upstream, int? ahead, string o = output ?? ""; bool Has(string s) => o.Contains(s, StringComparison.OrdinalIgnoreCase); + // Worktree refusals first — their wording overlaps the generic cases below. Each was + // captured from git 2.54; see GitWorktreeRepoTests. + if (Has("is already used by worktree at") || Has("is already checked out at")) + return "That branch is checked out in another worktree. Open that worktree instead (Worktrees ▸)."; + if (Has("contains modified or untracked files")) + return "The worktree has uncommitted or untracked files. Commit or stash them first."; + if (Has("cannot remove a locked working tree")) + return "The worktree is locked. Unlock it with 'git worktree unlock' first."; + if (Has("is a main working tree")) + return "That is the repository's main checkout, which can't be removed as a worktree."; + if (Has("is not fully merged")) + return "The branch has commits that aren't merged anywhere, so it was kept."; + if (Has("failed to delete") && Has("Permission denied")) + return "Something still has files open in that folder — an editor, a terminal or a dev " + + "server. Close it, then finish in a terminal with 'git worktree remove --force '. " + + "Everything in it was committed."; + if (Has("Not possible to fast-forward") || Has("diverging branches") || Has("have diverged")) return "The branch has diverged from its upstream, so it can't fast-forward. Merge or rebase in a terminal."; if (Has("would be overwritten")) diff --git a/src/CodeShellManager/Services/GitWorktrees.cs b/src/CodeShellManager/Services/GitWorktrees.cs new file mode 100644 index 0000000..9bdbb5c --- /dev/null +++ b/src/CodeShellManager/Services/GitWorktrees.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +using WorktreeInfo = CodeShellManager.Services.GitService.WorktreeInfo; + +namespace CodeShellManager.Services; + +/// +/// WPF-free worktree rules behind the git menu's worktree items. See "Worktrees in the git +/// menu" in CLAUDE.md. +/// +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('/'); + + /// + /// The folder's real path, in d form: 8.3 short names expanded, + /// junctions and symlinks followed. Comparing raw strings is not enough — git reports + /// C:/Users/MortenAsloOestergaar/… for a worktree whose session was opened as + /// C:\Users\MORTEN~1\… (an 8.3 %TEMP%; the end-to-end test of "Remove this + /// worktree" failed on exactly that), and a folder reached through a junction has two + /// spellings too. Touches the disk — call it off the UI thread. A path that doesn't + /// exist, or a \\wsl$ / network path, is only normalized. + /// + public static string Canonical(string path) + { + if (string.IsNullOrEmpty(path)) return ""; + try + { + string full = Path.GetFullPath(path); + if (full.StartsWith(@"\\", StringComparison.Ordinal) || !Directory.Exists(full)) + return Normalize(full); + using var h = CreateFileW(full, 0, FileShareAll, IntPtr.Zero, OpenExisting, BackupSemantics, IntPtr.Zero); + if (h.IsInvalid) return Normalize(full); + var buf = new char[1024]; + uint n = GetFinalPathNameByHandleW(h, buf, (uint)buf.Length, 0); + if (n == 0 || n >= buf.Length) return Normalize(full); + string final = new string(buf, 0, (int)n); + if (final.StartsWith(@"\\?\UNC\", StringComparison.Ordinal)) final = @"\\" + final[8..]; + else if (final.StartsWith(@"\\?\", StringComparison.Ordinal)) final = final[4..]; + return Normalize(final); + } + catch { return Normalize(path); } + } + + /// applied to every listed worktree's path. + public static IReadOnlyList Canonicalize(IReadOnlyList worktrees) => + worktrees.Select(w => w with { Path = w.IsPrunable ? Normalize(w.Path) : Canonical(w.Path) }).ToList(); + + private const uint FileShareAll = 0x7, OpenExisting = 3, BackupSemantics = 0x02000000; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, + uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle h, [Out] char[] buf, uint len, uint flags); + + /// + /// True when is or below it. A plain + /// prefix test is wrong: C:/repo-wt starts with C:/repo but is not inside it. + /// + public static bool IsInside(string folder, string root) + { + string f = Normalize(folder), r = Normalize(root); + if (f.Length == 0 || r.Length == 0) return false; + return f.Equals(r, StringComparison.OrdinalIgnoreCase) + || f.StartsWith(r + "/", StringComparison.OrdinalIgnoreCase); + } + + /// + /// The worktree containing — the deepest match, since a worktree + /// can legally live inside another one's folder. + /// + public static WorktreeInfo? Containing(IReadOnlyList worktrees, string folder) => + worktrees.Where(w => !w.IsBare && IsInside(folder, w.Path)) + .OrderByDescending(w => Normalize(w.Path).Length) + .FirstOrDefault(); + + /// + /// git lists the main worktree first; every other entry is a linked one. A bare repo's + /// 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); + + /// + /// Branches checked out in a worktree OTHER than , mapped to that + /// worktree. git allows a branch in only one worktree at a time, so switching to one of + /// these always fails ("already used by worktree at …"); the menu offers to go there instead. + /// + public static IReadOnlyDictionary BranchesElsewhere( + IReadOnlyList worktrees, WorktreeInfo? current) + { + var map = new Dictionary(StringComparer.Ordinal); + 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)) + continue; + map.TryAdd(w.Branch, w); + } + return map; + } + + /// The worktree's folder name — what the menus show. + public static string Name(string path) + { + string n = Normalize(path); + int i = n.LastIndexOf('/'); + return i >= 0 ? n[(i + 1)..] : n; + } + + /// + /// "wt-feature ⎇ feature ● ↑1 ↓2", "wt-old — folder missing", "… 🔒". The status is + /// optional: it is fetched lazily and may not be there (or may have failed). + /// + public static string Label(WorktreeInfo w, GitRepoStatus? status) + { + string name = Name(w.Path); + if (w.IsPrunable) return $"{name} — folder missing"; + string branch = w.IsDetached || string.IsNullOrEmpty(w.Branch) ? "detached" : w.Branch; + string label = $"{name} ⎇ {branch}"; + if (status is { IsDirty: true }) label += " ●"; + string sync = GitStatusText.Sync(status?.Ahead, status?.Behind); + if (sync.Length > 0) label += " " + sync; + if (w.IsLocked) label += " 🔒"; + return label; + } + + /// + /// For the chip and menu header: the main repo's name when is + /// in a LINKED worktree, else null. Pure file reads, no git spawn — but it does touch the + /// disk (and a \\wsl$ path), so callers run it off the UI thread. + /// + /// + /// A linked worktree's .git is a FILE: gitdir: <common>/worktrees/<name>. + /// A submodule's is a file too (gitdir: …/.git/modules/<name>), so the file alone + /// is not enough — the /worktrees/ segment is what distinguishes them. + /// + public static string? LinkedWorktreeOf(string folder, string? repoRoot) + { + try + { + for (var dir = new DirectoryInfo(folder); dir != null; dir = dir.Parent) + { + string dotGit = Path.Combine(dir.FullName, ".git"); + if (Directory.Exists(dotGit)) return null; // main working tree + if (!File.Exists(dotGit)) continue; + string text = File.ReadAllText(dotGit).Trim(); + if (!text.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) return null; + string gitdir = Normalize(text["gitdir:".Length..].Trim()); + if (!gitdir.Contains("/worktrees/", StringComparison.OrdinalIgnoreCase)) return null; + // repoRoot is the main working tree (GetRepoRootAsync strips the ".git"). + if (!string.IsNullOrEmpty(repoRoot)) return Name(repoRoot); + // Fall back to the folder holding /.git. + int i = gitdir.IndexOf("/.git/worktrees/", StringComparison.OrdinalIgnoreCase); + return i > 0 ? Name(gitdir[..i]) : "another checkout"; + } + } + catch { } + return null; + } +} diff --git a/tests/CodeShellManager.Tests/GitServiceThreadingTests.cs b/tests/CodeShellManager.Tests/GitServiceThreadingTests.cs index 4ec27da..c04ee86 100644 --- a/tests/CodeShellManager.Tests/GitServiceThreadingTests.cs +++ b/tests/CodeShellManager.Tests/GitServiceThreadingTests.cs @@ -135,9 +135,13 @@ public async Task Process_creation_never_happens_on_the_thread_designated_as_the private static string TestRepoPath() { - // Walk up from the test binary to the repo root (the folder containing .git). + // Walk up from the test binary to the repo root (the folder containing .git). In a + // linked worktree .git is a FILE ("gitdir: …"), so accept either — otherwise these + // tests fail in every worktree checkout, which is how this repo is mostly worked on. var dir = new System.IO.DirectoryInfo(AppContext.BaseDirectory); - while (dir != null && !System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git"))) + while (dir != null + && !System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git")) + && !System.IO.File.Exists(System.IO.Path.Combine(dir.FullName, ".git"))) dir = dir.Parent; Assert.NotNull(dir); diff --git a/tests/CodeShellManager.Tests/GitWorktreeTests.cs b/tests/CodeShellManager.Tests/GitWorktreeTests.cs new file mode 100644 index 0000000..480bf40 --- /dev/null +++ b/tests/CodeShellManager.Tests/GitWorktreeTests.cs @@ -0,0 +1,336 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CodeShellManager.Services; +using Xunit; +using WorktreeInfo = CodeShellManager.Services.GitService.WorktreeInfo; + +namespace CodeShellManager.Tests; + +/// Pure rules behind the git menu's worktree items. See "Worktrees in the git menu". +public class GitWorktreeRuleTests +{ + private static WorktreeInfo Wt(string path, string? branch = null, bool bare = false, + bool detached = false, bool locked = false, bool prunable = false) => + new(path, branch, bare, detached, locked, prunable); + + [Theory] + [InlineData("C:/repo", "C:/repo", true)] + [InlineData("C:/repo/src/app", "C:/repo", true)] + [InlineData(@"C:\repo\src", "C:/repo", true)] // mixed separators + [InlineData("c:/REPO/src", "C:/repo/", true)] // case, trailing slash + [InlineData("C:/repo-wt", "C:/repo", false)] // a prefix is not "inside" + [InlineData("C:/repo2/x", "C:/repo", false)] + [InlineData("", "C:/repo", false)] + public void IsInside_is_a_path_test_not_a_string_prefix(string folder, string root, bool expected) => + Assert.Equal(expected, GitWorktrees.IsInside(folder, root)); + + [Fact] + public void Containing_picks_the_deepest_worktree() + { + // A worktree may legally live inside another one's folder. + var wts = new[] { Wt("C:/repo", "main"), Wt("C:/repo/.wt/feat", "feat"), Wt("C:/repo-other", "x") }; + Assert.Equal("feat", GitWorktrees.Containing(wts, "C:/repo/.wt/feat/src")!.Branch); + Assert.Equal("main", GitWorktrees.Containing(wts, "C:/repo/src")!.Branch); + Assert.Null(GitWorktrees.Containing(wts, "D:/elsewhere")); + } + + [Fact] + public void Containing_ignores_the_bare_entry() + { + var wts = new[] { Wt("C:/repo.git", bare: true), Wt("C:/repo.git/wt", "feat") }; + Assert.Equal("feat", GitWorktrees.Containing(wts, "C:/repo.git/wt")!.Branch); + } + + [Fact] + public void BranchesElsewhere_maps_every_other_worktrees_branch_but_not_this_ones() + { + var main = Wt("C:/repo", "main"); + var wts = new[] { main, Wt("C:/wt-a", "feat-a"), Wt("C:/wt-d", detached: true), Wt("C:/b.git", "x", bare: true) }; + var map = GitWorktrees.BranchesElsewhere(wts, main); + Assert.Equal(new[] { "feat-a" }, map.Keys); + Assert.Equal("C:/wt-a", map["feat-a"].Path); + } + + [Fact] + public void IsMain_is_the_first_entry_git_lists() + { + var wts = new[] { Wt("C:/repo", "main"), Wt("C:/wt", "feat") }; + Assert.True(GitWorktrees.IsMain(wts, wts[0])); + Assert.False(GitWorktrees.IsMain(wts, wts[1])); + } + + [Fact] + public void Label_shows_branch_state_and_problems() + { + var dirtyAhead = new GitRepoStatus("feat", false, "origin/feat", 1, 2, + new[] { new GitFileChange(".M", "a.txt") }); + Assert.Equal("wt-feat ⎇ feat ● ↑1 ↓2", GitWorktrees.Label(Wt("C:/x/wt-feat", "feat"), dirtyAhead)); + Assert.Equal("wt ⎇ detached", GitWorktrees.Label(Wt("C:/wt", detached: true), null)); + Assert.Equal("wt ⎇ feat 🔒", GitWorktrees.Label(Wt("C:/wt", "feat", locked: true), null)); + Assert.Equal("gone — folder missing", GitWorktrees.Label(Wt("C:/gone", "feat", prunable: true), null)); + } + + [Fact] + public void Header_names_the_main_repo_only_in_a_linked_worktree() + { + Assert.EndsWith(" · worktree of CodeShellManager", + GitStatusText.MenuHeader("feat", null, null, null, 0, "CodeShellManager")); + Assert.DoesNotContain("worktree", GitStatusText.MenuHeader("main", null, null, null, 0)); + } + + [Theory] + // Each message captured verbatim from git 2.54. + [InlineData("fatal: 'feat' is already used by worktree at 'C:/tmp/wt-feat'", "another worktree")] + [InlineData("fatal: '../wt-feat' contains modified or untracked files, use --force to delete it", "uncommitted or untracked")] + [InlineData("fatal: cannot remove a locked working tree;\nuse 'remove -f -f' to override or unlock first", "locked")] + [InlineData("fatal: '.' is a main working tree", "main checkout")] + [InlineData("error: the branch 'feat' is not fully merged\nhint: If you are sure you want to delete it, run 'git branch -D feat'", "aren't merged")] + [InlineData("error: failed to delete 'C:/tmp/wt-self': Permission denied", "files open")] + public void Worktree_refusals_get_a_plain_hint(string gitOutput, string expectedFragment) => + Assert.Contains(expectedFragment, GitStatusText.FailureHint(gitOutput)); + + [Fact] + public void Remove_and_branch_delete_argv_keep_values_as_one_element_after_dashdash() + { + foreach (string hostile in new[] { "-x", "--force", "$(id)", "a b", "a\"b" }) + { + Assert.Equal(new[] { "worktree", "remove", "--", hostile }, GitService.RemoveWorktreeArgs(hostile)); + Assert.Equal(new[] { "branch", "-d", "--", hostile }, GitService.DeleteMergedBranchArgs(hostile)); + } + } +} + +/// +/// The worktree operations end to end against real git: a throwaway repo with linked +/// worktrees, exercising every refusal the menu relies on. +/// +public sealed class GitWorktreeRepoTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "csm-gitwt-" + Guid.NewGuid().ToString("N")[..8]); + private string Main => Path.Combine(_root, "main"); + private string WtPath(string name) => Path.Combine(_root, name); + + public GitWorktreeRepoTests() + { + Directory.CreateDirectory(Main); + Git(Main, "init", "-q", "-b", "main"); + Git(Main, "config", "user.email", "test@example.invalid"); + Git(Main, "config", "user.name", "test"); + Git(Main, "config", "commit.gpgsign", "false"); + File.WriteAllText(Path.Combine(Main, "a.txt"), "one\n"); + Git(Main, "add", "a.txt"); + Git(Main, "commit", "-q", "-m", "one"); + } + + private void AddWorktree(string name, string branch) => + Git(Main, "worktree", "add", "-q", "-b", branch, "--", WtPath(name)); + + private static string Git(string cwd, params string[] args) + { + var psi = new ProcessStartInfo("git") + { + WorkingDirectory = cwd, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-c"); + psi.ArgumentList.Add("core.autocrlf=false"); + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = Process.Start(psi)!; + string o = p.StandardOutput.ReadToEnd(); + string e = p.StandardError.ReadToEnd(); + p.WaitForExit(); + if (p.ExitCode != 0) throw new InvalidOperationException($"git {string.Join(' ', args)}: {e}"); + return o; + } + + [Fact] + public async Task Listing_maps_branches_to_worktrees_and_finds_the_current_one() + { + AddWorktree("wt-feat", "feat"); + var wts = await GitService.ListWorktreesAsync(Main); + + Assert.Equal(2, wts.Count); + Assert.True(GitWorktrees.IsMain(wts, wts[0])); + var here = GitWorktrees.Containing(wts, Path.Combine(WtPath("wt-feat"))); + Assert.Equal("feat", here!.Branch); + Assert.Equal("wt-feat", GitWorktrees.Name(here.Path)); + // From main, "feat" is elsewhere; from the worktree, "main" is. + Assert.Contains("feat", GitWorktrees.BranchesElsewhere(wts, wts[0]).Keys); + Assert.Contains("main", GitWorktrees.BranchesElsewhere(wts, here).Keys); + } + + [Fact] + public async Task Switching_to_a_branch_held_by_another_worktree_fails_with_a_recognised_message() + { + // The case the Switch submenu now routes to "go to that worktree" instead. + AddWorktree("wt-feat", "feat"); + var r = await GitService.SwitchBranchAsync(Main, "feat"); + Assert.False(r.Ok); + Assert.Contains("another worktree", GitStatusText.FailureHint(r.Output)); + } + + [Fact] + public async Task A_session_folder_spelled_differently_from_gits_path_still_finds_its_worktree() + { + // Found by the end-to-end test: %TEMP% here is an 8.3 path (C:\Users\MORTEN~1\…, and + // GitHub's runners use RUNNER~1) while git lists the long form, so a raw compare decided + // the session was in no worktree and "Remove this worktree" never appeared. + AddWorktree("wt-feat", "feat"); + var wts = GitWorktrees.Canonicalize(await GitService.ListWorktreesAsync(Main)); + string asOpened = Path.Combine(Path.GetTempPath(), Path.GetFileName(_root), "wt-feat"); + + Assert.Equal("feat", GitWorktrees.Containing(wts, GitWorktrees.Canonical(asOpened))?.Branch); + } + + [Fact] + public async Task A_session_opened_through_a_junction_finds_its_worktree() + { + AddWorktree("wt-feat", "feat"); + string link = Path.Combine(_root, "link-to-wt"); + var psi = new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true, + }; + foreach (var a in new[] { "/c", "mklink", "/J", link, WtPath("wt-feat") }) psi.ArgumentList.Add(a); + using (var mk = Process.Start(psi)!) { mk.WaitForExit(); Assert.Equal(0, mk.ExitCode); } + + var wts = GitWorktrees.Canonicalize(await GitService.ListWorktreesAsync(Main)); + Assert.False(GitWorktrees.IsInside(link, wts[1].Path)); // the raw spellings really differ + Assert.Equal("feat", GitWorktrees.Containing(wts, GitWorktrees.Canonical(link))?.Branch); + } + + [Fact] + public void Linked_worktree_is_told_apart_from_the_main_checkout_and_a_submodule() + { + AddWorktree("wt-feat", "feat"); + Assert.Equal("main", GitWorktrees.LinkedWorktreeOf(Path.Combine(WtPath("wt-feat")), Main)); + Assert.Null(GitWorktrees.LinkedWorktreeOf(Main, Main)); + + // A submodule's .git is also a file — but it points into .git/modules, not worktrees. + string fakeSub = Path.Combine(_root, "sub"); + Directory.CreateDirectory(fakeSub); + File.WriteAllText(Path.Combine(fakeSub, ".git"), "gitdir: ../main/.git/modules/sub\n"); + Assert.Null(GitWorktrees.LinkedWorktreeOf(fakeSub, Main)); + } + + [Fact] + public async Task Removing_a_clean_worktree_deletes_it_and_keeps_its_branch() + { + AddWorktree("wt-feat", "feat"); + var r = await GitService.RemoveWorktreeAsync(Main, WtPath("wt-feat")); + + Assert.True(r.Ok, r.Output); + Assert.False(Directory.Exists(WtPath("wt-feat"))); + Assert.Contains("feat", await GitService.ListBranchesAsync(Main)); + } + + [Theory] + [InlineData("untracked.txt", false)] // an untracked file + [InlineData("a.txt", true)] // a modified tracked file + public async Task Removing_a_worktree_with_changes_is_refused_and_nothing_is_deleted(string file, bool tracked) + { + AddWorktree("wt-feat", "feat"); + string path = Path.Combine(WtPath("wt-feat"), file); + File.WriteAllText(path, tracked ? "changed\n" : "new\n"); + + var r = await GitService.RemoveWorktreeAsync(Main, WtPath("wt-feat")); + + Assert.False(r.Ok); + Assert.Contains("uncommitted or untracked", GitStatusText.FailureHint(r.Output)); + Assert.True(File.Exists(path)); + Assert.True(File.Exists(Path.Combine(WtPath("wt-feat"), "a.txt"))); + } + + [Fact] + public async Task Removing_a_locked_worktree_is_refused() + { + AddWorktree("wt-feat", "feat"); + Git(Main, "worktree", "lock", "--", WtPath("wt-feat")); + + var r = await GitService.RemoveWorktreeAsync(Main, WtPath("wt-feat")); + + Assert.False(r.Ok); + Assert.Contains("locked", GitStatusText.FailureHint(r.Output)); + Assert.True(Directory.Exists(WtPath("wt-feat"))); + Git(Main, "worktree", "unlock", "--", WtPath("wt-feat")); + } + + [Fact] + public async Task The_main_checkout_can_never_be_removed() + { + var r = await GitService.RemoveWorktreeAsync(Main, Main); + Assert.False(r.Ok); + Assert.True(File.Exists(Path.Combine(Main, "a.txt"))); + } + + [Fact] + public async Task Branch_delete_keeps_an_unmerged_branch_and_deletes_a_merged_one() + { + AddWorktree("wt-done", "done"); // no new commits → merged into main + AddWorktree("wt-wip", "wip"); + File.WriteAllText(Path.Combine(WtPath("wt-wip"), "b.txt"), "b\n"); + Git(WtPath("wt-wip"), "add", "b.txt"); + Git(WtPath("wt-wip"), "commit", "-q", "-m", "wip"); + Assert.True((await GitService.RemoveWorktreeAsync(Main, WtPath("wt-done"))).Ok); + Assert.True((await GitService.RemoveWorktreeAsync(Main, WtPath("wt-wip"))).Ok); + + var merged = await GitService.DeleteMergedBranchAsync(Main, "done"); + var unmerged = await GitService.DeleteMergedBranchAsync(Main, "wip"); + + Assert.True(merged.Ok, merged.Output); + Assert.False(unmerged.Ok); + Assert.Contains("aren't merged", GitStatusText.FailureHint(unmerged.Output)); + var left = await GitService.ListBranchesAsync(Main); + Assert.DoesNotContain("done", left); + Assert.Contains("wip", left); + } + + [Fact] + public async Task An_option_shaped_branch_name_is_deleted_as_a_name() + { + // git won't create "-x" via `git branch`, but a ref can still be called that (a clone + // or update-ref can produce one). Without `--`, `branch -d -x` would be an option. + string head = Git(Main, "rev-parse", "HEAD").Trim(); + Git(Main, "update-ref", "refs/heads/-x", head); + + var r = await GitService.DeleteMergedBranchAsync(Main, "-x"); + + Assert.True(r.Ok, r.Output); + Assert.DoesNotContain("-x", await GitService.ListBranchesAsync(Main)); + } + + [Fact] + public async Task Prune_forgets_a_worktree_whose_folder_was_deleted_and_nothing_else() + { + AddWorktree("wt-gone", "gone"); + AddWorktree("wt-kept", "kept"); + Directory.Delete(WtPath("wt-gone"), recursive: true); + Assert.Contains(await GitService.ListWorktreesAsync(Main), w => w.IsPrunable); + + var r = await GitService.PruneWorktreesAsync(Main); + + Assert.True(r.Ok, r.Output); + var after = await GitService.ListWorktreesAsync(Main); + Assert.DoesNotContain(after, w => w.IsPrunable); + Assert.Contains(after, w => GitWorktrees.Name(w.Path) == "wt-kept"); + Assert.True(Directory.Exists(WtPath("wt-kept"))); + } + + public void Dispose() + { + try + { + foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + Directory.Delete(_root, recursive: true); + } + catch { } + } +}