From aa82241499ea35831c7da302a9cee0d3a05e806d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 12:41:02 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20v0.10.0=20release=20prep=20=E2=80=94?= =?UTF-8?q?=20second=20review=20round,=20docs,=20CI=20manifest=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-round review (fresh eyes on the #182 fixes) and a release-facing review: - Worktree remove's nested check failed OPEN: ListWorktreesAsync returns empty on any git error, which read as "nothing nested". It now refuses unless the freshly read list contains the target. - It only saw worktrees registered with the same repo. An independent clone or another repo's worktree in an ignored folder was still deleted silently. GitWorktrees.NestedRepositories scans the folder for any .git of its own (no junction following, never into .git, capped — over the cap it refuses). - Stale comment: restart is no longer the only removeOnFailure: false caller. Docs: README feature list gains the git chip/menu, Worktrees menu, Open in VS Code / Explorer / PowerShell, drag-and-drop ordering and the full bulk-action list; worktrees re-rated "one click, not automatic" in README and comparison. CI: after Publish, fail if the SHIPPED exe lacks the PerMonitorV2 manifest — the unit test only pins it into the build output (verified by hand that the single-file publish keeps it). Tests: independent clone + submodule found, plain worktree and a junction to a repo not, cap returns null. 765/765. End-to-end remove re-run in the app. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- .github/workflows/build.yml | 8 ++++ CLAUDE.md | 11 +++-- README.md | 9 ++-- docs/comparison.md | 4 +- src/CodeShellManager/MainWindow.Git.cs | 35 ++++++++++++--- src/CodeShellManager/MainWindow.xaml.cs | 7 +-- src/CodeShellManager/Services/GitWorktrees.cs | 45 +++++++++++++++++++ .../GitWorktreeTests.cs | 41 +++++++++++++++++ 8 files changed, 143 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 35902c7..9dfa864 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,6 +94,14 @@ jobs: # Ensure Assets are always present as loose files alongside the exe # (ExcludeFromSingleFile handles this, but belt-and-suspenders for SDK quirks) Copy-Item -Path src/CodeShellManager/Assets -Destination publish/Assets -Recurse -Force + # The unit test pins PerMonitorV2 into the BUILD output; this pins it into the exe + # that actually ships. Losing the manifest would silently revert to system-DPI + # scaling (blurry, wrongly sized after a monitor change) — see CLAUDE.md, "DPI + # awareness and the WebView2 data folder". + $exe = [System.IO.File]::ReadAllBytes("publish/CodeShellManager.exe") + if (-not [System.Text.Encoding]::ASCII.GetString($exe).Contains("PerMonitorV2")) { + throw "Published CodeShellManager.exe does not embed the PerMonitorV2 manifest." + } # ── Install WiX v4 ─────────────────────────────────────────────────────── - name: Install WiX toolset v4 (last open-source release) diff --git a/CLAUDE.md b/CLAUDE.md index 2cd4041..fa7b0be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -836,11 +836,16 @@ 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 +**Refused when another repository 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. +A reports clean, `git worktree remove A` exits 0 and deletes B, work included. Two checks, both +at click time: `GitWorktrees.NestedInside` against a freshly re-read worktree list, and +`GitWorktrees.NestedRepositories`, an on-disk scan for any folder below it with a `.git` of its +own — an independent clone or another repo's worktree is deleted just the same, and only the scan +sees those (it also catches submodules, which git refuses to remove anyway). Junctions are not +followed. **Both fail closed:** the listing returns empty on any git error, so a list that does +not contain the target refuses; a scan that hits its folder cap returns null and refuses. **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. diff --git a/README.md b/README.md index e6e6d5e..d6d81d4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ A condensed view, as of October 2026. Tools without a Windows build (Conductor, | Working / idle state per agent | ❌ [#154](https://github.com/umage-ai/CodeShellManager/issues/154) | ? | ? | ? | ✅ | ? | | SSH + WSL sessions | ✅ | ✅ | ◐ | ◐ SSH | ◐ SSH | ❌ | | Per-session run commands | ✅ | ◐ preview servers | ❌ | ❌ | ❌ | ❌ | -| Git worktrees | ◐ manual | ✅ | ✅ | ✅ | ◐ plugins | ✅ | +| Git worktrees | ◐ one click, not automatic | ✅ | ✅ | ✅ | ◐ plugins | ✅ | | Diff / review view | ❌ [#148](https://github.com/umage-ai/CodeShellManager/issues/148) | ✅ | ✅ | ✅ | ◐ plugin | ✅ | | Agents keep running with the window closed | ❌ [#149](https://github.com/umage-ai/CodeShellManager/issues/149) | ✅ cloud | ? | ? | ✅ | ✅ | | Licence | MIT | Proprietary | MIT | MIT | Apache-2.0 | AGPL-3.0 | @@ -55,10 +55,11 @@ Spotted something out of date? Competitors move fast. [Open an issue](https://gi ## Features - **Multi-terminal grid** — run up to 18 sessions simultaneously in configurable layouts (1, 2, 3, 4, 6 columns; 2×2, 6×2, 6×3 grids); the active pane is highlighted with a 2px accent ring so it's easy to spot -- **Sidebar groups** — organise sessions into named groups with their own color and filter strip; bulk actions (sleep / close / re-group) operate on the active group +- **Sidebar groups** — organise sessions into named groups with their own color and filter strip; bulk actions (sleep / close / restart / remote control / re-group) operate on the active group +- **Drag-and-drop ordering** — drag sessions in the sidebar to reorder them, within or across groups; a blue insertion line shows exactly where the session will land, and the pane grid follows the sidebar order - **Sleep & wake** — 💤 button parks a session: PTY torn down, but the session (and its notes) stays in the sidebar so you can wake it later from where you left off. Great when you have many long-running projects but only need a few live at once. - **Restart sessions** — ↻ restarts a session in place (Claude conversations resume), or restart a whole group or every session to pick up a new CLI build; bulk restarts show progress and can be stopped -- **Git worktrees** — start a new session in a fresh worktree from any session's branch; sibling worktrees can be grouped together in the sidebar +- **Git worktrees** — start a new session in a fresh worktree from any session's branch; sibling worktrees can be grouped together in the sidebar. The git menu's **Worktrees ▸** lists every worktree of the repo with its state, jumps to (or opens) its session, prunes worktrees whose folder is gone, and removes a finished one — closing its sessions first, refusing if anything is uncommitted, and offering to delete the branch only if it is merged - **Edit session** — change a session's folder, command, SSH/WSL target or appearance after it was created - **Recently closed** — Ctrl+Shift+T reopens the last-closed session (browser convention); the New Session dialog also lists the last 10 closed sessions for one-click revival - **Per-session run commands** — define a list of labelled commands per session (Test, Build, Watch…); ▶ runs the default, F5 / Shift+F5 run/stop it, output streams into a side drawer without touching the parent terminal. Optional post-run URL opens in your browser on exit code 0. @@ -66,6 +67,8 @@ Spotted something out of date? Competitors move fast. [Open an issue](https://gi - **Per-project notepad** — collapsible 📝 notes panel on every terminal, auto-saved and searchable - **Alert detection** — detects when Claude is waiting for input or tool approval; green/orange dot indicators - **Git status** — shows branch and dirty state in the sidebar per session +- **Git tools on the terminal toolbar** — a chip like `⎇ main ● ↑2 ↓5` (branch, uncommitted changes, ahead/behind) on every repo session; its menu does Fetch, fast-forward-only Pull, Status, and Switch branch (worktree-aware). Deliberately read-mostly: no commit, push or merge, and nothing is ever typed into the terminal +- **Open in VS Code / Explorer / PowerShell** — `{ }`, 📁 and `>_` on the terminal toolbar (and in the sidebar right-click menu) open the session's folder; VS Code appears when it is installed with `code` on PATH - **Session rename** — double-click any session name or click ✏ to rename inline - **Shell integration** — programs running in a session can push their accent color, git branch / dirty state, and tab title to CSM via OSC 9001 (handy for SSH overlays). See [`docs/shell-integration.md`](docs/shell-integration.md). - **Auto-resume** — automatically resumes the last Claude Code session when restoring on startup (`--resume `); toggleable in Settings diff --git a/docs/comparison.md b/docs/comparison.md index 61c5e22..2cbe1d9 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -42,7 +42,7 @@ Popular tools without a Windows build (Conductor, Superset, cmux) are left out. | Agents keep running with the window closed | ❌ ([#149](https://github.com/umage-ai/CodeShellManager/issues/149)) | ✅ cloud | ? | ? | ✅ | ✅ tmux | ❌ | | Mobile or remote control | ❌ | ✅ Dispatch | ✅ iOS | ✅ | ✅ SSH attach | ❌ | ❌ | | **Review and workflow** | | | | | | | | -| Git worktree creation | ◐ manual | ✅ per session | ✅ | ✅ core feature | ◐ via plugins | ✅ | ? | +| Git worktree creation | ◐ one click, not automatic | ✅ per session | ✅ | ✅ core feature | ◐ via plugins | ✅ | ? | | Diff / review view | ❌ ([#148](https://github.com/umage-ai/CodeShellManager/issues/148)) | ✅ | ✅ | ✅ | ◐ via plugin | ✅ | ◐ git sidebar | | Browser / app preview | ❌ post-run URL only | ✅ | ✅ | ? | ❌ | ❌ | ✅ | | Per-session run commands (F5, background output) | ✅ | ◐ preview servers | ❌ | ❌ | ❌ | ❌ | ❌ | @@ -68,7 +68,7 @@ Popular tools without a Windows build (Conductor, Superset, cmux) are left out. - **No "agent is working" state.** CSM knows when a session is waiting for you, but not when it's busy. Herdr tracks working, blocked, done and idle for about 25 agents: [#154](https://github.com/umage-ai/CodeShellManager/issues/154). - **No diff or review view** yet: [#148](https://github.com/umage-ai/CodeShellManager/issues/148). - **Closing the window ends the sessions.** Close-to-tray with live status is planned: [#149](https://github.com/umage-ai/CodeShellManager/issues/149). -- **Worktrees are manual.** Orca, Claude Code Desktop and Nimbalyst make one worktree per task the default. +- **Worktrees are one click, not automatic.** CSM creates, lists, jumps between, prunes and removes worktrees from the git menu, but Orca, Claude Code Desktop and Nimbalyst make one worktree per task the default. - **No mobile or remote companion.** - **Windows-only, by design.** diff --git a/src/CodeShellManager/MainWindow.Git.cs b/src/CodeShellManager/MainWindow.Git.cs index ca634a9..b84121d 100644 --- a/src/CodeShellManager/MainWindow.Git.cs +++ b/src/CodeShellManager/MainWindow.Git.cs @@ -544,16 +544,39 @@ private async Task RemoveWorktreeAsync( 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). + // one since the menu opened must still block the remove. Fail CLOSED — the listing + // returns empty on any git error, and an empty list must not read as "nothing nested". var (fresh, _) = await LoadWorktreesAsync(wt.Path); - var nested = GitWorktrees.NestedInside(fresh, wt); + if (!fresh.Any(w => GitWorktrees.SamePath(w.Path, wt.Path))) + { + MessageBox.Show(this, + $"Couldn't read the repository's worktree list, so '{name}' can't be checked safely " + + "and was left alone. Try again, or remove it from a terminal.", + "Remove worktree", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + // Registered worktrees of this repo, plus anything on disk with a .git of its own — an + // independent clone or another repo's worktree in an ignored folder is deleted too. + var nested = GitWorktrees.NestedInside(fresh, wt).Select(w => w.Path).ToList(); + string wtPath = wt.Path; + var onDisk = await Task.Run(() => GitWorktrees.NestedRepositories(FolderOpener.WindowsPath(wtPath))); + if (onDisk == null) + { + MessageBox.Show(this, + $"'{name}' is too large to check for nested repositories, so it was left alone. " + + "Remove it from a terminal if you are sure.", + "Remove worktree", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + foreach (string p in onDisk) + if (!nested.Any(n => GitWorktrees.SamePath(n, p))) nested.Add(p); 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.", + $"'{name}' contains another git repository or worktree:\n\n" + + string.Join("\n", nested.Select(p => $" • {GitWorktrees.Name(p)} ({p})")) + + "\n\nRemoving it would delete that too, uncommitted work included — git does not " + + "check inside ignored folders. Move or remove it first.", "Remove worktree", MessageBoxButton.OK, MessageBoxImage.Warning); return; } diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index b5a09fe..af9e80b 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1454,9 +1454,10 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal MessageBox.Show(this, $"Cannot start '{label}'.\n\n{validationError}", "Launch Error", MessageBoxButton.OK, MessageBoxImage.Warning); // Mirrors the PTY-failure catch below: dormant fallback is the caller's job. - // RestartSessionAsync (the only removeOnFailure: false caller) already checks - // _sessionUi after the await and adds the dormant row itself — doing it here - // too would leave a second, untracked Border in the sidebar. + // Every relaunch caller (restore, wake, restart) passes removeOnFailure: false and + // adds the dormant row itself after the await (FallBackToDormantIfNotLive, or + // inline in RestartSessionCoreAsync) — doing it here too would leave a second, + // untracked Border in the sidebar. if (removeOnFailure) _sessionManager.RemoveSession(session.Id); if (_launchingSidebarItems.Remove(session.Id)) RebuildSidebarOrder(); return; diff --git a/src/CodeShellManager/Services/GitWorktrees.cs b/src/CodeShellManager/Services/GitWorktrees.cs index c67950a..7a1822b 100644 --- a/src/CodeShellManager/Services/GitWorktrees.cs +++ b/src/CodeShellManager/Services/GitWorktrees.cs @@ -155,6 +155,51 @@ public static IReadOnlyDictionary BranchesElsewhere( public static IReadOnlyList NestedInside(IReadOnlyList worktrees, WorktreeInfo target) => worktrees.Where(w => !w.IsBare && !SamePath(w.Path, target.Path) && IsInside(w.Path, target.Path)).ToList(); + /// + /// Folders BELOW that are a git repository, worktree or submodule + /// of their own (they hold a .git folder or file). only + /// sees worktrees registered with the same repo; an independent clone, or another repo's + /// worktree, inside an ignored folder would be deleted by `git worktree remove` just the + /// same (second-round pre-release review). Junctions and symlinks are not followed, and + /// .git folders are not descended into. Returns null when more than + /// folders had to be visited — the caller cannot vouch for the + /// tree then and must refuse. Disk I/O: call off the UI thread. + /// + public static IReadOnlyList? NestedRepositories(string root, int maxDirs = 500_000) + { + var found = new List(); + var options = new EnumerationOptions + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + var pending = new Stack(); + pending.Push(root); + int visited = 0; + while (pending.Count > 0) + { + string dir = pending.Pop(); + bool isRoot = ReferenceEquals(dir, root); + if (!isRoot && File.Exists(Path.Combine(dir, ".git"))) + { + found.Add(dir); // linked worktree or submodule + continue; + } + foreach (string sub in Directory.EnumerateDirectories(dir, "*", options)) + { + if (++visited > maxDirs) return null; + if (Path.GetFileName(sub).Equals(".git", StringComparison.OrdinalIgnoreCase)) + { + if (!isRoot) found.Add(dir); // a repository of its own + continue; // never walk into .git + } + pending.Push(sub); + } + } + return found; + } + /// The worktree's folder name — what the menus show. public static string Name(string path) { diff --git a/tests/CodeShellManager.Tests/GitWorktreeTests.cs b/tests/CodeShellManager.Tests/GitWorktreeTests.cs index dadce69..492cb13 100644 --- a/tests/CodeShellManager.Tests/GitWorktreeTests.cs +++ b/tests/CodeShellManager.Tests/GitWorktreeTests.cs @@ -266,6 +266,47 @@ public async Task A_worktree_nested_in_an_ignored_folder_is_detected_because_git Assert.Equal(new[] { "b" }, GitWorktrees.NestedInside(wts, a).Select(w => w.Branch)); } + [Fact] + public void NestedRepositories_finds_an_independent_clone_and_a_submodule_but_not_the_worktree_itself() + { + // NestedInside only sees worktrees registered with THIS repo. An independent clone, or + // a submodule (.git FILE), inside an ignored folder is deleted by `worktree remove` too. + AddWorktree("A", "a"); + string a = WtPath("A"); + Git(a, "init", "-q", Path.Combine(a, ".cache", "other-clone")); // .git folder + Directory.CreateDirectory(Path.Combine(a, "vendor", "sub")); + File.WriteAllText(Path.Combine(a, "vendor", "sub", ".git"), "gitdir: ../../.git/modules/sub\n"); + Directory.CreateDirectory(Path.Combine(a, "src", "deep", "er")); // plain folders + + var found = GitWorktrees.NestedRepositories(a)!.Select(p => Path.GetFileName(p)).OrderBy(n => n); + + Assert.Equal(new[] { "other-clone", "sub" }, found); // not A itself + } + + [Fact] + public void NestedRepositories_is_empty_for_a_plain_worktree_and_does_not_follow_junctions() + { + AddWorktree("A", "a"); + // A junction from inside A to the MAIN checkout (which has a .git folder) must not be + // followed — git doesn't follow it when deleting either. + string link = Path.Combine(WtPath("A"), "link-to-main"); + var psi = new ProcessStartInfo("cmd.exe") { UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var x in new[] { "/c", "mklink", "/J", link, Main }) psi.ArgumentList.Add(x); + using (var mk = Process.Start(psi)!) { mk.WaitForExit(); Assert.Equal(0, mk.ExitCode); } + + Assert.Empty(GitWorktrees.NestedRepositories(WtPath("A"))!); + Directory.Delete(link); // the link only + } + + [Fact] + public void NestedRepositories_gives_up_rather_than_vouching_for_a_tree_it_did_not_finish() + { + AddWorktree("A", "a"); + for (int i = 0; i < 5; i++) Directory.CreateDirectory(Path.Combine(WtPath("A"), $"d{i}")); + Assert.Null(GitWorktrees.NestedRepositories(WtPath("A"), maxDirs: 3)); + } + [Fact] public async Task Removing_a_clean_worktree_deletes_it_and_keeps_its_branch() {