Skip to content

Commit 0595be3

Browse files
authored
feat: add support for go command (#13)
1 parent 57743f6 commit 0595be3

6 files changed

Lines changed: 258 additions & 0 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,25 @@ Skip confirmation prompt:
9090
grove remove feature/new-feature --yes
9191
```
9292

93+
### Navigate to a worktree
94+
95+
Open a new shell session in a worktree directory:
96+
97+
```bash
98+
grove go feature-branch
99+
```
100+
101+
This spawns a new shell in the worktree directory. Exit the shell (Ctrl+D or `exit`) to return to your previous directory.
102+
103+
You can also navigate by partial branch name for nested branches:
104+
105+
```bash
106+
# If you have a worktree for feature/my-feature
107+
grove go my-feature
108+
```
109+
110+
The `GROVE_WORKTREE` environment variable is set to the branch name while in the worktree shell.
111+
93112
### List all worktrees
94113

95114
```bash
@@ -196,6 +215,7 @@ grove self-update --pr 42
196215

197216
- `grove init <git-url>` - Create a new worktree setup
198217
- `grove add <name> [options]` - Create a new worktree
218+
- `grove go <name>` - Navigate to a worktree
199219
- `grove remove <name> [options]` - Remove a worktree
200220
- `grove list [options]` - List all worktrees
201221
- `grove sync [options]` - Sync the bare clone with origin

site/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,15 @@ <h4>Add a new worktree</h4>
253253
<pre><code>grove add feature-branch --track origin/feature-branch</code></pre>
254254
</div>
255255

256+
<div class="command-group">
257+
<h4>Navigate to a worktree</h4>
258+
<p>Open a new shell session in a worktree directory:</p>
259+
<pre><code>grove go feature-branch</code></pre>
260+
<p>Navigate by partial branch name for nested branches:</p>
261+
<pre><code>grove go my-feature</code></pre>
262+
<p>Exit the shell (Ctrl+D or <code>exit</code>) to return to your previous directory.</p>
263+
</div>
264+
256265
<div class="command-group">
257266
<h4>List worktrees</h4>
258267
<p>Show all worktrees:</p>
@@ -320,6 +329,10 @@ <h3>Commands</h3>
320329
<td>grove add &lt;branch&gt;</td>
321330
<td>Create a new worktree for a branch</td>
322331
</tr>
332+
<tr>
333+
<td>grove go &lt;name&gt;</td>
334+
<td>Navigate to a worktree</td>
335+
</tr>
323336
<tr>
324337
<td>grove list [options]</td>
325338
<td>List all worktrees</td>

src/commands/go.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Command } from "commander";
2+
import { spawn } from "child_process";
3+
import chalk from "chalk";
4+
import { WorktreeManager } from "../git/WorktreeManager";
5+
6+
export function createGoCommand(): Command {
7+
const command = new Command("go");
8+
9+
command
10+
.description("Navigate to a worktree by branch name")
11+
.argument("<name>", "Branch name or worktree name to navigate to")
12+
.action(async (name: string) => {
13+
try {
14+
await runGo(name);
15+
} catch (error) {
16+
console.error(
17+
chalk.red("Error:"),
18+
error instanceof Error ? error.message : error,
19+
);
20+
process.exit(1);
21+
}
22+
});
23+
24+
return command;
25+
}
26+
27+
async function runGo(name: string): Promise<void> {
28+
const manager = new WorktreeManager();
29+
await manager.initialize();
30+
31+
const worktree = await manager.findWorktreeByName(name);
32+
33+
if (!worktree) {
34+
throw new Error(`Worktree '${name}' not found. Use 'grove list' to see available worktrees.`);
35+
}
36+
37+
// Get the user's default shell
38+
const shell = process.env.SHELL || "/bin/sh";
39+
40+
console.log(chalk.blue(`Entering worktree '${worktree.branch}' at ${worktree.path}`));
41+
console.log();
42+
43+
// Spawn an interactive shell in the worktree directory
44+
const child = spawn(shell, [], {
45+
cwd: worktree.path,
46+
stdio: "inherit",
47+
env: {
48+
...process.env,
49+
GROVE_WORKTREE: worktree.branch,
50+
},
51+
});
52+
53+
// Wait for the shell to exit
54+
await new Promise<void>((resolve, reject) => {
55+
child.on("close", (code) => {
56+
if (code === 0 || code === null) {
57+
resolve();
58+
} else {
59+
reject(new Error(`Shell exited with code ${code}`));
60+
}
61+
});
62+
child.on("error", reject);
63+
});
64+
}

src/git/WorktreeManager.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,4 +367,31 @@ export class WorktreeManager {
367367
throw new Error(`Failed to sync branch '${branch}': ${error}`);
368368
}
369369
}
370+
371+
async findWorktreeByName(name: string): Promise<Worktree | undefined> {
372+
const worktrees = await this.listWorktrees();
373+
374+
// First, try exact branch name match
375+
let worktree = worktrees.find((wt) => wt.branch === name);
376+
if (worktree) {
377+
return worktree;
378+
}
379+
380+
// Try matching by directory name (last part of the path)
381+
worktree = worktrees.find((wt) => {
382+
const dirName = path.basename(wt.path);
383+
return dirName === name;
384+
});
385+
if (worktree) {
386+
return worktree;
387+
}
388+
389+
// Try partial branch name match (suffix matching for nested branches like feature/foo)
390+
worktree = worktrees.find((wt) => wt.branch.endsWith(`/${name}`));
391+
if (worktree) {
392+
return worktree;
393+
}
394+
395+
return undefined;
396+
}
370397
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Command } from 'commander';
44
import chalk from 'chalk';
55
import { createAddCommand } from './commands/add';
6+
import { createGoCommand } from './commands/go';
67
import { createInitCommand } from './commands/init';
78
import { createListCommand } from './commands/list';
89
import { createPruneCommand } from './commands/prune';
@@ -22,6 +23,7 @@ program
2223

2324
// Add all commands
2425
program.addCommand(createAddCommand());
26+
program.addCommand(createGoCommand());
2527
program.addCommand(createInitCommand());
2628
program.addCommand(createListCommand());
2729
program.addCommand(createPruneCommand());

test/unit/WorktreeManager.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,136 @@ describe("WorktreeManager", () => {
219219
);
220220
});
221221
});
222+
223+
describe("findWorktreeByName", () => {
224+
test("should find worktree by exact branch name", async () => {
225+
mockGit.raw.mockImplementation((args) => {
226+
if (args[0] === "config") {
227+
return Promise.resolve("false");
228+
}
229+
if (args[0] === "worktree" && args[1] === "list") {
230+
return Promise.resolve(
231+
"worktree /home/user/repo/main\n" +
232+
"HEAD abc123\n" +
233+
"branch refs/heads/main\n" +
234+
"\n" +
235+
"worktree /home/user/repo/feature-branch\n" +
236+
"HEAD def456\n" +
237+
"branch refs/heads/feature-branch\n"
238+
);
239+
}
240+
return Promise.resolve("");
241+
});
242+
mockGit.status.mockImplementation(() =>
243+
Promise.resolve({ isClean: () => true })
244+
);
245+
246+
const result = await manager.findWorktreeByName("feature-branch");
247+
248+
expect(result).toBeDefined();
249+
expect(result?.branch).toBe("feature-branch");
250+
expect(result?.path).toBe("/home/user/repo/feature-branch");
251+
});
252+
253+
test("should find worktree by directory name", async () => {
254+
mockGit.raw.mockImplementation((args) => {
255+
if (args[0] === "config") {
256+
return Promise.resolve("false");
257+
}
258+
if (args[0] === "worktree" && args[1] === "list") {
259+
return Promise.resolve(
260+
"worktree /home/user/repo/my-worktree\n" +
261+
"HEAD abc123\n" +
262+
"branch refs/heads/feature/some-feature\n"
263+
);
264+
}
265+
return Promise.resolve("");
266+
});
267+
mockGit.status.mockImplementation(() =>
268+
Promise.resolve({ isClean: () => true })
269+
);
270+
271+
const result = await manager.findWorktreeByName("my-worktree");
272+
273+
expect(result).toBeDefined();
274+
expect(result?.branch).toBe("feature/some-feature");
275+
expect(result?.path).toBe("/home/user/repo/my-worktree");
276+
});
277+
278+
test("should find worktree by partial branch name suffix", async () => {
279+
mockGit.raw.mockImplementation((args) => {
280+
if (args[0] === "config") {
281+
return Promise.resolve("false");
282+
}
283+
if (args[0] === "worktree" && args[1] === "list") {
284+
return Promise.resolve(
285+
"worktree /home/user/repo/feature/my-feature\n" +
286+
"HEAD abc123\n" +
287+
"branch refs/heads/feature/my-feature\n"
288+
);
289+
}
290+
return Promise.resolve("");
291+
});
292+
mockGit.status.mockImplementation(() =>
293+
Promise.resolve({ isClean: () => true })
294+
);
295+
296+
const result = await manager.findWorktreeByName("my-feature");
297+
298+
expect(result).toBeDefined();
299+
expect(result?.branch).toBe("feature/my-feature");
300+
});
301+
302+
test("should return undefined when worktree not found", async () => {
303+
mockGit.raw.mockImplementation((args) => {
304+
if (args[0] === "config") {
305+
return Promise.resolve("false");
306+
}
307+
if (args[0] === "worktree" && args[1] === "list") {
308+
return Promise.resolve(
309+
"worktree /home/user/repo/main\n" +
310+
"HEAD abc123\n" +
311+
"branch refs/heads/main\n"
312+
);
313+
}
314+
return Promise.resolve("");
315+
});
316+
mockGit.status.mockImplementation(() =>
317+
Promise.resolve({ isClean: () => true })
318+
);
319+
320+
const result = await manager.findWorktreeByName("non-existent");
321+
322+
expect(result).toBeUndefined();
323+
});
324+
325+
test("should prefer exact branch name match over directory name", async () => {
326+
mockGit.raw.mockImplementation((args) => {
327+
if (args[0] === "config") {
328+
return Promise.resolve("false");
329+
}
330+
if (args[0] === "worktree" && args[1] === "list") {
331+
return Promise.resolve(
332+
"worktree /home/user/repo/feature-branch\n" +
333+
"HEAD abc123\n" +
334+
"branch refs/heads/other-branch\n" +
335+
"\n" +
336+
"worktree /home/user/repo/other-dir\n" +
337+
"HEAD def456\n" +
338+
"branch refs/heads/feature-branch\n"
339+
);
340+
}
341+
return Promise.resolve("");
342+
});
343+
mockGit.status.mockImplementation(() =>
344+
Promise.resolve({ isClean: () => true })
345+
);
346+
347+
const result = await manager.findWorktreeByName("feature-branch");
348+
349+
expect(result).toBeDefined();
350+
expect(result?.branch).toBe("feature-branch");
351+
expect(result?.path).toBe("/home/user/repo/other-dir");
352+
});
353+
});
222354
});

0 commit comments

Comments
 (0)