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