Skip to content

feat(git): worktrees in the git menu — where am I, switch-aware, go to, prune, remove - #180

Merged
mortenaslo merged 1 commit into
mainfrom
feat/git-worktree-tools
Nov 6, 2026
Merged

mortenaslo merged 1 commit into
mainfrom
feat/git-worktree-tools

Conversation

@mortenaslo

Copy link
Copy Markdown
Contributor

Summary

Makes the toolbar git menu worktree-aware, since a Claude session per task in its own worktree is how this tool is mostly used:

  1. Which worktree am I in — in a linked worktree the chip tooltip and menu header end with · worktree of <repo>.
  2. Switch branch lists a branch checked out in another worktree as feature — in wt-feature and opens that worktree, instead of failing with git's already used by worktree at ….
  3. Worktrees ▸ lists every worktree with branch, dirty dot and ↑/↓. Clicking one focuses its session, wakes a sleeping one, or opens a new one. Also: New worktree from this branch…
  4. Clean-up: Prune N missing worktrees, and Remove this worktree… with an optional, separate delete branch if merged.

Why

With many worktrees per repo: panes on one repo looked identical apart from their path; Switch branch offered branches git would always refuse; there was no way to jump between worktrees from the git menu; and finished worktrees piled up.

Implementation notes

  • Remove is the one write beyond pull/switch, guarded three ways:

    • refused up front when the worktree has changes;
    • confirmed with the exact folder, the sessions it will close and the sleeping ones it will delete;
    • run as git worktree remove without --force — git itself refuses modified, untracked or locked worktrees and the main checkout.

    Branch deletion is a separate question afterwards, using branch -d (not -D), so an unmerged branch is kept.

  • Sessions are closed and their processes awaited before git runs, and the remove runs from the main worktree. On Windows a process whose cwd is inside the folder locks it, and git fails half-way: failed to delete … Permission denied. Reproduced by running the remove from inside the worktree. Anything else still holding the folder (editor, dev server) gets a hint with the --force command to finish. That's safe, because git had already verified the tree was clean.

  • Paths are compared by their real form. git lists long paths, but a session can hold an 8.3 spelling or come through a junction. The first end-to-end run failed on exactly that: this machine's %TEMP% is MORTEN~1, and GitHub runners use RUNNER~1. The session was judged to be in no worktree, so Remove never appeared. GitWorktrees.Canonical resolves both sides via GetFinalPathNameByHandle, always off the UI thread.

  • "Worktree of" comes from the .git file (gitdir: …/worktrees/<name>), with no git spawn. The /worktrees/ segment tells it apart from a submodule.

  • Two related fixes:

    • GitService.RunOpAsync is now Task.Run-wrapped. Its Directory.Exists ran on the caller's thread, which for a WSL session meant \\wsl$ I/O on the UI thread.
    • GitServiceThreadingTests now find the repo root when .git is a file, so they pass in a worktree checkout.
  • WPF-free logic is in Services/GitWorktrees; wording and hints are in GitStatusText.

  • CLAUDE.md has a new "Worktrees in the git menu" section.

Test plan

  • GitWorktreeTests against real git:
    • listing and branch mapping; the switch refusal; linked vs main vs submodule
    • clean remove (branch kept); refusing modified, untracked and locked worktrees and the main checkout
    • branch -d keeping an unmerged branch; deleting a branch named -x
    • prune removing only the missing worktree; the 8.3 and junction spellings
  • Mutation: adding --force to the remove fails 3 tests; dropping -- from the branch delete fails 2.
  • End to end in the app, with a cmd session open inside the worktree: chip → Worktrees → Remove → Yes → delete branch Yes. Result: the session closed, the folder was fully deleted, the merged branch was deleted, git lists only the main worktree, and crash.log is clean.
  • Full suite: 747/747, including the 3 threading tests, now passing in a worktree checkout too.
  • Manual:
    • the "— in wt-…" switch entries
    • going to a worktree with a live, sleeping or no session
    • Prune after deleting a worktree folder by hand
    • Remove refused on a dirty worktree

🤖 Generated with Claude Code

https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX

…o, prune, remove

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 <repo>" 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX
@mortenaslo
mortenaslo merged commit 448aca7 into main Nov 6, 2026
1 check passed
mortenaslo added a commit that referenced this pull request Nov 6, 2026
… relaunch, worktree remove safety (#182)

* fix(explorer): open the right folder for forward-slash and comma paths

"Open in Explorer" ran explorer.exe with the folder as raw command-line text,
and Explorer silently opened Documents instead for two kinds of path —
measured by asking the Shell which folder each new Explorer window showed:

- forward slashes (C:/repos/wt-feat): the form git prints, and so the form a
  session opened through "New session in sibling worktree" — and now the
  Worktrees menu — stored as its WorkingFolder;
- a comma in the name: Explorer splits its arguments on commas, and quoting
  the path does not help (also measured).

Services/FolderOpener opens the folder itself through the shell (ShellExecute
"open" on the full Windows path), which opened the right folder for every case
tried: plain, spaces, comma, &, %, forward slashes, trailing separator, 8.3. It
refuses anything that is not an existing directory first — ShellExecute on a
FILE runs it, and a session's folder comes from state.json and imports — and
runs off the UI thread (the check is \wsl$ I/O for WSL). Used by the toolbar
📁, the sidebar menu and note search results.

LaunchSessionInSiblingWorktreeAsync now stores the Windows form of the git
path, so new worktree sessions don't carry forward slashes at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX

* fix: pre-release review — never delete a session on a failed relaunch; worktree remove safety

From two read-only reviews of everything since v0.9.0. Each finding was
reproduced before fixing.

Relaunch (restore, wake) — data loss since before v0.9.0:
- Both called LaunchSessionAsync with the default removeOnFailure: true, so a
  session whose launch was refused or whose PTY failed to start (WSL distro
  uninstalled, claude briefly off PATH) was DELETED, and the next save made it
  permanent. Wake's catch never ran: the PTY-failure path returns, not throws.
  Both now pass removeOnFailure: false and end in FallBackToDormantIfNotLive,
  which also covers every WebView2 failure on restore (#175 only covered the
  two batched ones). Verified before/after with a real state.json via
  CSM_STATE_PATH: old build — session gone from sidebar and file; fixed —
  kept, asleep.

Worktree remove (#180):
- Refused when another worktree lives inside the target. git's clean check
  skips ignored folders: with B in A's .gitignored .worktrees/ holding
  uncommitted work, `git worktree remove A` exited 0 and deleted B (reproduced
  with git 2.54). Checked against a list re-read at click time.
- Sleeping sessions are deleted only after git removed the folder — they never
  go through Recently closed, so a refusal used to lose them for good.
- WSL: \wsl.localhost folded into \wsl$ (otherwise no match at all), and
  below the distro paths compare case-sensitively (/home/u/WT is not .../wt).

Not fixed here (follow-up issue): window bounds restore under per-monitor DPI
on mixed-DPI setups, the busy-slot key before RepoRoot resolves, and
ParseStatusV2 showing every unmerged entry as "UU".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant