Skip to content

Commit c13529f

Browse files
VdustRclaude
andauthored
Add prunable worktree debugging aids (#15)
## Summary Closes #11 - **Enhanced tooltip** for prunable worktrees: shows "⚠ Prunable Worktree" header, expected path, fish-style abbreviated config path (e.g. `~/r/v/m/.git/worktrees/wt-hotfix`), and "Directory missing" warning - **New context menu commands** for prunable items: `Copy Path (Missing)` and `Copy Worktree Config Path` - **Hidden inapplicable actions** (Open in New/Current Window, Reveal in OS, Open in Terminal, Add Favorite) for prunable worktrees via `!(viewItem =~ /prunable/)` when-clause - **Prunable state propagated to favorites**: warning icon, prunable tooltip, and restricted context menu - **Bug fix**: `resolveWorktreeNames()` no longer silently skips prunable worktrees — falls back to `path.normalize()` when `realpathSync` fails for deleted directories - **New utility**: `abbreviatePath()` in `src/utils/fishPath.ts` for fish-shell-style path abbreviation ## Test plan - [x] `pnpm test` — 74 tests pass (7 test files) - [x] `pnpm lint:es` — no lint errors - [x] `pnpm build` — builds successfully - [x] Manual test: create worktree, `rm -rf` its directory, verify tooltip/menu behavior - [ ] Verify favorite of prunable worktree shows warning icon and restricted menu 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7364dde commit c13529f

File tree

16 files changed

+510
-32
lines changed

16 files changed

+510
-32
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"git-work-grove": minor
3+
---
4+
5+
Add debugging aids for prunable worktrees
6+
7+
- Enhanced tooltip: shows "Prunable Worktree" header, expected path, fish-style abbreviated config path, and "Directory missing" message
8+
- New context menu commands for prunable items: Copy Path (Missing) and Copy Worktree Config Path
9+
- Hidden inapplicable actions (Open, Reveal, Terminal, Add Favorite) for prunable worktrees
10+
- Prunable state propagated to favorite items with warning icon and restricted menu
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Prunable Worktree Debugging Aids
2+
3+
Issue: [#11](https://github.com/vp-tw/vscode-extension-git-work-grove/issues/11)
4+
5+
## Summary
6+
7+
Enhance prunable worktree UX with two changes:
8+
9+
1. **Tooltip enhancement** — show branch, expected path, fish-style abbreviated config path, and "Directory missing" message on hover
10+
2. **Context menu overhaul** — hide inapplicable actions, add `Copy Worktree Config Path`, rename `Copy Path` to `Copy Path (Missing)`
11+
12+
## Tooltip Design
13+
14+
Prunable worktree tooltip example:
15+
16+
```
17+
⚠ Prunable Worktree
18+
Branch: hotfix/login
19+
Expected path: /tmp/grove-test/wt-hotfix
20+
Config: ~/r/v/m/.git/worktrees/wt-hotfix
21+
Directory missing — run Prune to clean up
22+
```
23+
24+
### Fish-Style Path Abbreviation Rules
25+
26+
1. Replace `$HOME` prefix with `~`
27+
2. Abbreviate each path component to its first character, **except**:
28+
- The final component (preserve full name)
29+
- Everything after `.git/` (preserve full path: `worktrees/<name>`)
30+
3. Non-home paths skip step 1 but follow the same abbreviation
31+
32+
Examples:
33+
34+
| Full path | Abbreviated |
35+
|-----------|-------------|
36+
| `/Users/v/repo/vp-tw/main-repo/.git/worktrees/wt-hotfix` | `~/r/v/m/.git/worktrees/wt-hotfix` |
37+
| `/tmp/grove-test/main-repo/.git/worktrees/wt-hotfix` | `/t/g/m/.git/worktrees/wt-hotfix` |
38+
39+
### Data Sources
40+
41+
- **Branch**: already in `WorktreeInfo.branch` (parsed from `git worktree list --porcelain`)
42+
- **Expected path**: `WorktreeInfo.path` (the worktree path git recorded, which no longer exists)
43+
- **Config path**: `path.join(commonDir, "worktrees", worktreeInfo.name)` — requires `getCommonDir()` result
44+
45+
## Context Menu Design
46+
47+
### Prunable Worktree Menu (new)
48+
49+
Only these items appear for prunable worktrees:
50+
51+
| Command | Group | Purpose |
52+
|---------|-------|---------|
53+
| Copy Name | `5_cutcopypaste@1` | Branch name for recreating |
54+
| Copy Path (Missing) | `5_cutcopypaste@2` | Original expected worktree path |
55+
| Copy Worktree Config Path | `5_cutcopypaste@3` | Git internal directory (absolute) |
56+
57+
### Hidden for Prunable
58+
59+
These commands add `!(viewItem =~ /prunable/)` to their `when` clause:
60+
61+
- Open in New Window
62+
- Open in Current Window
63+
- Reveal in OS
64+
- Open in Terminal
65+
- Add Favorite
66+
67+
### Copy Path (Missing)
68+
69+
- Separate command from `copyPath` (VS Code requires distinct commands for distinct titles)
70+
- Command ID: `gitWorkGrove.copyMissingPath`
71+
- `when`: `viewItem =~ /prunable/`
72+
- Behavior: copies `WorktreeInfo.path` to clipboard (same value as regular Copy Path)
73+
74+
### Copy Worktree Config Path
75+
76+
- Command ID: `gitWorkGrove.copyWorktreeConfigPath`
77+
- `when`: `viewItem =~ /prunable/`
78+
- Behavior: copies absolute path to `<commonDir>/worktrees/<name>/`
79+
- Requires resolving `commonDir` at copy time (or caching it)
80+
81+
## Implementation Scope
82+
83+
### New Constants
84+
85+
```
86+
CMD_COPY_MISSING_PATH = "gitWorkGrove.copyMissingPath"
87+
CMD_COPY_WORKTREE_CONFIG_PATH = "gitWorkGrove.copyWorktreeConfigPath"
88+
```
89+
90+
### Files to Modify
91+
92+
| File | Change |
93+
|------|--------|
94+
| `src/constants.ts` | Add 2 command IDs |
95+
| `src/utils/tooltip.ts` | Add fish-style abbreviation, add config path + expected path lines for prunable |
96+
| `src/utils/fishPath.ts` | **New file** — fish-style path abbreviation utility |
97+
| `src/extension.ts` | Register 2 new commands |
98+
| `src/commands/copyInfo.ts` | Add `copyWorktreeConfigPath` handler; `copyMissingPath` reuses `copyPath` |
99+
| `package.json` | Add 2 commands, update `when` clauses for prunable exclusion, add new menu entries |
100+
| `docs/spec/commands.md` | Document new commands and prunable menu behavior |
101+
102+
### Not in Scope
103+
104+
- Option A (open folder) from the issue — deferred, can add later if needed

docs/spec/commands.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ All commands use the `gitWorkGrove.*` prefix.
1313
| `removeFavorite` | Remove Favorite | `$(star-full)` | `gitWorkGrove.hasRepository` |
1414
| `moveFavoriteUp` | Move Favorite Up | `$(chevron-up)` | `gitWorkGrove.hasRepository` |
1515
| `moveFavoriteDown` | Move Favorite Down | `$(chevron-down)` | `gitWorkGrove.hasRepository` |
16+
| `copyMissingPath` | Copy Path (Missing) || `gitWorkGrove.hasRepository` |
1617
| `copyName` | Copy Name || `gitWorkGrove.hasRepository` |
1718
| `copyPath` | Copy Path || `gitWorkGrove.hasRepository` |
19+
| `copyWorktreeConfigPath` | Copy Worktree Config Path || `gitWorkGrove.hasRepository` |
1820
| `openInTerminal` | Open in Terminal | `$(terminal)` | `gitWorkGrove.hasRepository` |
1921
| `revealInOS` | Reveal in Finder || `gitWorkGrove.hasRepository` |
2022
| `refresh` | Refresh | `$(refresh)` | `gitWorkGrove.hasRepository` |
@@ -35,14 +37,16 @@ All commands use the `gitWorkGrove.*` prefix.
3537

3638
| Command | Group | When clause |
3739
|---|---|---|
38-
| `openInNewWindow` | `navigation@1` | `viewItem =~ /^worktree\|^workspaceFile\|^repository\|^favorite\./` |
40+
| `openInNewWindow` | `navigation@1` | `viewItem =~ /^worktree\|^workspaceFile\|^repository\|^favorite\./` AND NOT `viewItem =~ /prunable/` |
3941
| `openInCurrentWindow` | `navigation@2` | *(same)* |
4042
| `revealInOS` | `navigation@3` | *(same)* |
4143
| `openInTerminal` | `navigation@4` | *(same)* |
42-
| `copyName` | `5_cutcopypaste@1` | *(same)* |
43-
| `copyPath` | `5_cutcopypaste@2` | *(same)* |
44-
| `openInTerminal` | `inline` | `viewItem =~ /^worktree\|^workspaceFile\|^repository/` AND NOT `viewItem =~ /favorite/` |
45-
| `addFavorite` | `inline` | `viewItem =~ /^worktree\|^workspaceFile\|^repository/` AND NOT `viewItem =~ /favorite/` |
44+
| `copyName` | `5_cutcopypaste@1` | `viewItem =~ /^worktree\|^workspaceFile\|^repository\|^favorite\./` |
45+
| `copyPath` | `5_cutcopypaste@2` | *(same as copyName)* AND NOT `viewItem =~ /prunable/` |
46+
| `copyMissingPath` | `5_cutcopypaste@2` | `viewItem =~ /prunable/` |
47+
| `copyWorktreeConfigPath` | `5_cutcopypaste@3` | `viewItem =~ /prunable/` |
48+
| `openInTerminal` | `inline` | `viewItem =~ /^worktree\|^workspaceFile\|^repository/` AND NOT `viewItem =~ /favorite/` AND NOT `viewItem =~ /prunable/` |
49+
| `addFavorite` | `inline` | `viewItem =~ /^worktree\|^workspaceFile\|^repository/` AND NOT `viewItem =~ /favorite/` AND NOT `viewItem =~ /prunable/` |
4650
| `removeFavorite` | `inline` | `viewItem =~ /favorite/` |
4751
| `moveFavoriteUp` | `inline` | `viewItem =~ /^favorite\./` |
4852
| `moveFavoriteDown` | `inline` | `viewItem =~ /^favorite\./` |
@@ -97,6 +101,23 @@ Both commands apply to all actionable item types (`WorktreeItem`, `WorkspaceFile
97101
- `WorkspaceFileItem``workspaceFileInfo.path`
98102
- `FavoriteItem``favoritePath`
99103

104+
### Copy Path (Missing) / Copy Worktree Config Path
105+
106+
Prunable-only commands — shown when `viewItem =~ /prunable/`.
107+
108+
- `copyMissingPath` — same behavior as `copyPath` (reuses handler), but displayed with title "Copy Path (Missing)" to clarify the path no longer exists on disk
109+
- `copyWorktreeConfigPath` — copies `worktreeInfo.configPath` (absolute path to git-internal worktree config directory, e.g., `/repo/.git/worktrees/wt-hotfix`). Shows a warning if `configPath` is not available (e.g., main worktree).
110+
111+
### Prunable Menu Behavior
112+
113+
Prunable worktrees (directory deleted without `git worktree remove`) have a restricted context menu:
114+
115+
**Hidden for prunable:** `openInNewWindow`, `openInCurrentWindow`, `revealInOS`, `openInTerminal`, `addFavorite`, `copyPath`
116+
117+
**Visible for prunable:** `copyName`, `copyMissingPath`, `copyWorktreeConfigPath`
118+
119+
This applies to both `WorktreeItem` and `FavoriteItem` via the `prunable` segment in `contextValue`.
120+
100121
### Prune
101122

102123
Runs `git worktree prune`, refreshes the tree. Shows error with "Show Logs" option on failure.

package.json

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@
9090
"icon": "$(star-full)",
9191
"enablement": "gitWorkGrove.hasRepository"
9292
},
93+
{
94+
"command": "gitWorkGrove.copyMissingPath",
95+
"title": "Git WorkGrove: Copy Path (Missing)",
96+
"enablement": "gitWorkGrove.hasRepository"
97+
},
9398
{
9499
"command": "gitWorkGrove.copyName",
95100
"title": "Git WorkGrove: Copy Name",
@@ -100,6 +105,11 @@
100105
"title": "Git WorkGrove: Copy Path",
101106
"enablement": "gitWorkGrove.hasRepository"
102107
},
108+
{
109+
"command": "gitWorkGrove.copyWorktreeConfigPath",
110+
"title": "Git WorkGrove: Copy Worktree Config Path",
111+
"enablement": "gitWorkGrove.hasRepository"
112+
},
103113
{
104114
"command": "gitWorkGrove.openInTerminal",
105115
"title": "Git WorkGrove: Open in Terminal",
@@ -160,22 +170,22 @@
160170
"view/item/context": [
161171
{
162172
"command": "gitWorkGrove.openInNewWindow",
163-
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./",
173+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./ && !(viewItem =~ /prunable/)",
164174
"group": "navigation@1"
165175
},
166176
{
167177
"command": "gitWorkGrove.openInCurrentWindow",
168-
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./",
178+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./ && !(viewItem =~ /prunable/)",
169179
"group": "navigation@2"
170180
},
171181
{
172182
"command": "gitWorkGrove.revealInOS",
173-
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./",
183+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./ && !(viewItem =~ /prunable/)",
174184
"group": "navigation@3"
175185
},
176186
{
177187
"command": "gitWorkGrove.openInTerminal",
178-
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./",
188+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./ && !(viewItem =~ /prunable/)",
179189
"group": "navigation@4"
180190
},
181191
{
@@ -185,17 +195,27 @@
185195
},
186196
{
187197
"command": "gitWorkGrove.copyPath",
188-
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./",
198+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository|^favorite\\./ && !(viewItem =~ /prunable/)",
199+
"group": "5_cutcopypaste@2"
200+
},
201+
{
202+
"command": "gitWorkGrove.copyMissingPath",
203+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /prunable/",
189204
"group": "5_cutcopypaste@2"
190205
},
206+
{
207+
"command": "gitWorkGrove.copyWorktreeConfigPath",
208+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /prunable/",
209+
"group": "5_cutcopypaste@3"
210+
},
191211
{
192212
"command": "gitWorkGrove.openInTerminal",
193-
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository/ && !(viewItem =~ /favorite/)",
213+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository/ && !(viewItem =~ /favorite/) && !(viewItem =~ /prunable/)",
194214
"group": "inline"
195215
},
196216
{
197217
"command": "gitWorkGrove.addFavorite",
198-
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository/ && !(viewItem =~ /favorite/)",
218+
"when": "view == gitWorkGrove.worktrees && viewItem =~ /^worktree|^workspaceFile|^repository/ && !(viewItem =~ /favorite/) && !(viewItem =~ /prunable/)",
199219
"group": "inline"
200220
},
201221
{
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { WorktreeInfo } from "../../types.js";
2+
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const mockWriteText = vi.fn();
6+
const mockShowWarningMessage = vi.fn();
7+
8+
vi.mock("vscode", () => ({
9+
env: { clipboard: { writeText: (...args: Array<unknown>) => mockWriteText(...args) } },
10+
window: { showWarningMessage: (...args: Array<unknown>) => mockShowWarningMessage(...args) },
11+
}));
12+
13+
const { copyName, copyPath, copyWorktreeConfigPath } = await import("../copyInfo.js");
14+
15+
function makeWorktreeInfo(overrides?: Partial<WorktreeInfo>): WorktreeInfo {
16+
return {
17+
name: "feat-auth",
18+
path: "/repo/feat-auth",
19+
head: "abc12345def67890",
20+
branch: "feat/auth",
21+
isDetached: false,
22+
isCurrent: false,
23+
isMain: false,
24+
isPrunable: false,
25+
...overrides,
26+
};
27+
}
28+
29+
describe("copyName", () => {
30+
beforeEach(() => vi.clearAllMocks());
31+
32+
it("copies worktree name", async () => {
33+
const item = { worktreeInfo: makeWorktreeInfo() } as any;
34+
await copyName(item);
35+
expect(mockWriteText).toHaveBeenCalledWith("feat-auth");
36+
});
37+
38+
it("no-ops for undefined item", async () => {
39+
await copyName(undefined);
40+
expect(mockWriteText).not.toHaveBeenCalled();
41+
});
42+
});
43+
44+
describe("copyPath", () => {
45+
beforeEach(() => vi.clearAllMocks());
46+
47+
it("copies worktree path", async () => {
48+
const item = { worktreeInfo: makeWorktreeInfo() } as any;
49+
await copyPath(item);
50+
expect(mockWriteText).toHaveBeenCalledWith("/repo/feat-auth");
51+
});
52+
});
53+
54+
describe("copyWorktreeConfigPath", () => {
55+
beforeEach(() => vi.clearAllMocks());
56+
57+
it("copies configPath to clipboard", async () => {
58+
const item = {
59+
worktreeInfo: makeWorktreeInfo({
60+
configPath: "/repo/.git/worktrees/feat-auth",
61+
}),
62+
} as any;
63+
await copyWorktreeConfigPath(item);
64+
expect(mockWriteText).toHaveBeenCalledWith("/repo/.git/worktrees/feat-auth");
65+
});
66+
67+
it("shows warning for undefined item", async () => {
68+
await copyWorktreeConfigPath(undefined);
69+
expect(mockShowWarningMessage).toHaveBeenCalledWith("Worktree config path not available.");
70+
expect(mockWriteText).not.toHaveBeenCalled();
71+
});
72+
73+
it("shows warning for non-worktree item", async () => {
74+
const item = { favoritePath: "/some/path" } as any;
75+
await copyWorktreeConfigPath(item);
76+
expect(mockShowWarningMessage).toHaveBeenCalledWith("Worktree config path not available.");
77+
});
78+
79+
it("shows warning when configPath is undefined", async () => {
80+
const item = { worktreeInfo: makeWorktreeInfo() } as any;
81+
await copyWorktreeConfigPath(item);
82+
expect(mockShowWarningMessage).toHaveBeenCalledWith("Worktree config path not available.");
83+
});
84+
});

src/commands/copyInfo.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,11 @@ export async function copyPath(item: ActionableItem | undefined): Promise<void>
2626
await vscode.env.clipboard.writeText(path);
2727
}
2828
}
29+
30+
export async function copyWorktreeConfigPath(item: ActionableItem | undefined): Promise<void> {
31+
if (!item || !("worktreeInfo" in item) || !item.worktreeInfo?.configPath) {
32+
void vscode.window.showWarningMessage("Worktree config path not available.");
33+
return;
34+
}
35+
await vscode.env.clipboard.writeText(item.worktreeInfo.configPath);
36+
}

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ export const CMD_REMOVE_FAVORITE = "gitWorkGrove.removeFavorite";
1010
export const CMD_MOVE_FAVORITE_UP = "gitWorkGrove.moveFavoriteUp";
1111
export const CMD_MOVE_FAVORITE_DOWN = "gitWorkGrove.moveFavoriteDown";
1212
export const CMD_OPEN_IN_TERMINAL = "gitWorkGrove.openInTerminal";
13+
export const CMD_COPY_MISSING_PATH = "gitWorkGrove.copyMissingPath";
1314
export const CMD_COPY_NAME = "gitWorkGrove.copyName";
1415
export const CMD_COPY_PATH = "gitWorkGrove.copyPath";
16+
export const CMD_COPY_WORKTREE_CONFIG_PATH = "gitWorkGrove.copyWorktreeConfigPath";
1517
export const CMD_REVEAL_IN_OS = "gitWorkGrove.revealInOS";
1618
export const CMD_REFRESH = "gitWorkGrove.refresh";
1719
export const CMD_SHOW_OUTPUT = "gitWorkGrove.showOutput";

src/extension.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from "node:path";
33

44
import * as vscode from "vscode";
55

6-
import { copyName, copyPath } from "./commands/copyInfo.js";
6+
import { copyName, copyPath, copyWorktreeConfigPath } from "./commands/copyInfo.js";
77
import { moveFavoriteDown, moveFavoriteUp } from "./commands/moveFavorite.js";
88
import { openInTerminal } from "./commands/openTerminal.js";
99
import { openDefault, openInCurrentWindow, openInNewWindow } from "./commands/openWorkspace.js";
@@ -12,8 +12,10 @@ import { toggleFavorite } from "./commands/toggleFavorite.js";
1212
import {
1313
CMD_ADD_FAVORITE,
1414
CMD_CLEAN_STALE_FAVORITES,
15+
CMD_COPY_MISSING_PATH,
1516
CMD_COPY_NAME,
1617
CMD_COPY_PATH,
18+
CMD_COPY_WORKTREE_CONFIG_PATH,
1719
CMD_MOVE_FAVORITE_DOWN,
1820
CMD_MOVE_FAVORITE_UP,
1921
CMD_OPEN_IN_CURRENT_WINDOW,
@@ -96,10 +98,14 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
9698
moveFavoriteUp(item, favorites, treeProvider)),
9799
vscode.commands.registerCommand(CMD_MOVE_FAVORITE_DOWN, (item) =>
98100
moveFavoriteDown(item, favorites, treeProvider)),
101+
vscode.commands.registerCommand(CMD_COPY_MISSING_PATH, (item) =>
102+
copyPath(item)),
99103
vscode.commands.registerCommand(CMD_COPY_NAME, (item) =>
100104
copyName(item)),
101105
vscode.commands.registerCommand(CMD_COPY_PATH, (item) =>
102106
copyPath(item)),
107+
vscode.commands.registerCommand(CMD_COPY_WORKTREE_CONFIG_PATH, (item) =>
108+
copyWorktreeConfigPath(item)),
103109
vscode.commands.registerCommand(CMD_REVEAL_IN_OS, (item) => {
104110
const fsPath = item ? resolveItemPath(item) : undefined;
105111
if (fsPath) {

0 commit comments

Comments
 (0)