/worktree-files: open the filetree pane on this conversation's git worktree

A Claude Code mod that adds /worktree-files: open the filetree pane on the git worktree the current conversation is working in.
Claude often works in a worktree under .claude/worktrees/ while the session itself stays in the main checkout, so plain /filetree shows the wrong folder. This finds the right one.
/worktree-files this conversation's worktree
/worktree-files home-screen a worktree by its folder name
/worktree-files ~/code/other any folder
With no argument it picks, in order:
git worktree add, EnterWorktree);When several worktrees exist and nothing points at one, it lists them as commands to type.
Claude Code 2.1.287 or newer, and the filetree mod. In a terminal:
claude plugin marketplace add cmorss/claude-mods
claude plugin install worktree-files@claude-mods
claude plugin marketplace add data-goblin/claude-code-filetree
claude plugin install filetree@claude-code-filetree
Then start a new session or type /reload-plugins. It is installed for you in every session and project. See the repository README for more.
hooks/register.ts is the command; hooks/worktree.ts decides which worktree is the conversation's (the same file is in the worktree-terminal mod: keep the two alike). Run the tests with claude plugin test <this folder> and check it with claude plugin validate <this folder>.
hooks/register.ts 94 lines1import type { EngineInterface, Register, SessionMessage } from 'claude-code';
2import { conversationWorktree, parseWorktreeList, type Worktree } from './worktree';
3
4const COMMAND = 'worktree-files';
5
6const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
7
8export interface Choice {
9 path?: string;
10 /** What the command says in the transcript. */
11 text: string;
12}
13
14/**
15 * Which folder to show: the worktree named in `arg` (or the path typed), else this
16 * conversation's worktree; when nothing points at one, the choices to type.
17 */
18export function chooseWorktree(input: {
19 arg: string;
20 cwdTop: string | undefined;
21 main: string | undefined;
22 linked: readonly Worktree[];
23 messages: readonly SessionMessage[];
24}): Choice {
25 const { arg, linked } = input;
26 if (arg) {
27 const named = linked.find((t) => t.name === arg || t.path === arg);
28 if (named) return { path: named.path, text: `Files of worktree ${named.name}.` };
29 return { path: arg, text: `Files of ${arg}.` };
30 }
31 const found = conversationWorktree(input);
32 if (found) {
33 return { path: found.tree.path, text: `Files of worktree ${found.tree.name} (${found.why}).` };
34 }
35 if (linked.length === 0) return { text: 'This repository has no linked worktrees.' };
36 return {
37 text: `This conversation names no worktree. Pick one: ${linked
38 .map((t) => `/${COMMAND} ${t.name}`)
39 .join(', ')}`,
40 };
41}
42
43export const register: Register = (on) => {
44 on('session.start', async ($, e, next) => {
45 try {
46 await $.command.register({
47 name: COMMAND,
48 description: "Open the file tree on this conversation's worktree",
49 argumentHint: '[worktree name or path]',
50 });
51 } catch (error) {
52 $.ui.toast(`worktree-files: /${COMMAND} could not be registered: ${message(error)}`);
53 }
54 return next(e);
55 });
56
57 on('command.run', { command: COMMAND }, async ($, e) => {
58 try {
59 return await openFiles($, e.args);
60 } catch (error) {
61 return { text: `Couldn't open the worktree's files: ${message(error)}` };
62 }
63 });
64};
65
66/** Find this conversation's worktree and open the filetree mod's pane on it. */
67async function openFiles($: EngineInterface, args: string): Promise<{ text: string }> {
68 const cwd = await $.session.cwd();
69 const git = (gitArgs: string[]) =>
70 $.process.run(['git', '--no-optional-locks', '-C', cwd, ...gitArgs], { timeoutMs: 10_000 });
71 const list = await git(['worktree', 'list', '--porcelain']);
72 if (list.exitCode !== 0) return { text: `${cwd} is not inside a git repository.` };
73 const { main, linked } = parseWorktreeList(list.stdout);
74 const top = await git(['rev-parse', '--show-toplevel']);
75 const choice = chooseWorktree({
76 arg: args.trim(),
77 cwdTop: top.exitCode === 0 ? top.stdout.trim() : undefined,
78 main,
79 linked,
80 messages: await $.session.messages(),
81 });
82 const path = choice.path;
83 if (path) {
84 // A command run from inside another command's hook waits for the session to be idle, so it
85 // goes once this one has answered.
86 $.clock.after(0, () => {
87 $.command.run({ command: 'filetree', args: path }).catch((error: unknown) => {
88 $.ui.toast(`Couldn't open filetree (is the filetree mod installed?): ${message(error)}`);
89 });
90 });
91 }
92 return { text: choice.text };
93}
94hooks/worktree.ts 121 lines1import type { SessionMessage } from 'claude-code';
2
3/**
4 * Which git worktree belongs to this conversation. Pure: register.ts reads git and the transcript,
5 * then asks here. The same file is in the worktree-files and worktree-terminal mods; keep the
6 * two copies alike.
7 */
8
9/** One linked worktree of the repository. */
10export interface Worktree {
11 /** Absolute path. */
12 path: string;
13 /** Its folder's name (`home-screen`), what the person types after a command. */
14 name: string;
15}
16
17/** The main working tree and the linked worktrees, from `git worktree list --porcelain`. */
18export function parseWorktreeList(porcelain: string): {
19 main: string | undefined;
20 linked: Worktree[];
21} {
22 const paths = porcelain
23 .split('\n')
24 .filter((line) => line.startsWith('worktree '))
25 .map((line) => line.slice('worktree '.length).trim());
26 const [main, ...rest] = paths;
27 return { main, linked: rest.map((path) => ({ path, name: baseName(path) })) };
28}
29
30export const baseName = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path;
31
32const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
34/** Where in `text` a worktree is last named, by its absolute or repo-relative path; -1 when not. */
35function lastMention(text: string, tree: Worktree, main: string | undefined): number {
36 const forms = [tree.path];
37 if (main && tree.path.startsWith(`${main}/`)) forms.push(tree.path.slice(main.length + 1));
38 let at = -1;
39 for (const form of forms) {
40 // Not followed by more of a name: `worktrees/home` must not match inside `worktrees/home-screen`.
41 const re = new RegExp(`${escape(form)}(?![\\w.-])`, 'g');
42 for (let m = re.exec(text); m; m = re.exec(text)) at = Math.max(at, m.index);
43 }
44 return at;
45}
46
47/** The worktree named last in the newest text that names one (texts newest first). */
48export function newestNamed(
49 texts: readonly string[],
50 linked: readonly Worktree[],
51 main: string | undefined,
52): Worktree | undefined {
53 for (const text of texts) {
54 let best: { at: number; tree: Worktree } | undefined;
55 for (const tree of linked) {
56 const at = lastMention(text, tree, main);
57 if (at >= 0 && (!best || at > best.at)) best = { at, tree };
58 }
59 if (best) return best.tree;
60 }
61 return undefined;
62}
63
64const commandOf = (input: Record<string, unknown>): string =>
65 typeof input.command === 'string' ? input.command : '';
66
67/**
68 * What the conversation says about worktrees, newest first, in two tiers: `entered` holds what
69 * created or entered one (EnterWorktree, `git worktree add`), `mentioned` everything else that
70 * names paths. Output of `git worktree list` is left out: it names every worktree at once.
71 */
72export function worktreeTexts(messages: readonly SessionMessage[]): {
73 entered: string[];
74 mentioned: string[];
75} {
76 const entered: string[] = [];
77 const mentioned: string[] = [];
78 for (const message of messages) {
79 if (message.text) mentioned.push(message.text);
80 for (const use of message.toolUses) {
81 const command = commandOf(use.input);
82 if (use.tool === 'EnterWorktree') {
83 entered.push(`${JSON.stringify(use.input)} ${use.text ?? ''}`);
84 } else if (use.tool === 'Bash' && /\bworktree\s+add\b/.test(command)) {
85 entered.push(command);
86 }
87 if (use.tool === 'Bash' && /\bworktree\s+(list|prune)\b/.test(command)) continue;
88 mentioned.push(`${JSON.stringify(use.input)} ${use.text ?? ''}`);
89 }
90 }
91 return { entered: entered.reverse(), mentioned: mentioned.reverse() };
92}
93
94export interface Found {
95 tree: Worktree;
96 /** Why this one, for the command's line in the transcript. */
97 why: string;
98}
99
100/**
101 * This conversation's worktree: the session's own, else the newest one the conversation created
102 * or entered, else the newest one it worked in, else the repository's only one.
103 */
104export function conversationWorktree(input: {
105 cwdTop: string | undefined;
106 main: string | undefined;
107 linked: readonly Worktree[];
108 messages: readonly SessionMessage[];
109}): Found | undefined {
110 const { cwdTop, main, linked, messages } = input;
111 const own = linked.find((t) => t.path === cwdTop);
112 if (own) return { tree: own, why: 'the session runs in it' };
113 const { entered, mentioned } = worktreeTexts(messages);
114 const made = newestNamed(entered, linked, main);
115 if (made) return { tree: made, why: 'created or entered in this conversation' };
116 const used = newestNamed(mentioned, linked, main);
117 if (used) return { tree: used, why: 'the newest one this conversation worked in' };
118 if (linked.length === 1) return { tree: linked[0]!, why: "the repository's only worktree" };
119 return undefined;
120}
121