Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -55,17 +55,20 @@ 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.
- **Full-text search** — all terminal output indexed to SQLite FTS5; instant search across every session, ever
- **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 <id>`); toggleable in Settings
Expand Down
4 changes: 2 additions & 2 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ❌ | ❌ | ❌ | ❌ | ❌ |
Expand All @@ -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.**

Expand Down
35 changes: 29 additions & 6 deletions src/CodeShellManager/MainWindow.Git.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 4 additions & 3 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/CodeShellManager/Services/GitWorktrees.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,51 @@ public static IReadOnlyDictionary<string, WorktreeInfo> BranchesElsewhere(
public static IReadOnlyList<WorktreeInfo> NestedInside(IReadOnlyList<WorktreeInfo> worktrees, WorktreeInfo target) =>
worktrees.Where(w => !w.IsBare && !SamePath(w.Path, target.Path) && IsInside(w.Path, target.Path)).ToList();

/// <summary>
/// Folders BELOW <paramref name="root"/> that are a git repository, worktree or submodule
/// of their own (they hold a <c>.git</c> folder or file). <see cref="NestedInside"/> 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
/// <c>.git</c> folders are not descended into. Returns null when more than
/// <paramref name="maxDirs"/> folders had to be visited — the caller cannot vouch for the
/// tree then and must refuse. Disk I/O: call off the UI thread.
/// </summary>
public static IReadOnlyList<string>? NestedRepositories(string root, int maxDirs = 500_000)
{
var found = new List<string>();
var options = new EnumerationOptions
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};
var pending = new Stack<string>();
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;
}

/// <summary>The worktree's folder name — what the menus show.</summary>
public static string Name(string path)
{
Expand Down
41 changes: 41 additions & 0 deletions tests/CodeShellManager.Tests/GitWorktreeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down