worktree-launcher 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +607 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# worktree-launcher (wt)
|
|
2
|
+
|
|
3
|
+
CLI tool to streamline git worktrees with AI coding assistants (Claude Code / Codex).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g worktree-launcher
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or for local development:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone <repo>
|
|
15
|
+
cd worktree-launcher
|
|
16
|
+
npm install
|
|
17
|
+
npm run build
|
|
18
|
+
npm link
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
### `wt new <branch-name>`
|
|
24
|
+
|
|
25
|
+
Create a new worktree and launch your AI assistant.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
wt new feature-auth
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This will:
|
|
32
|
+
1. Create a new branch (if it doesn't exist)
|
|
33
|
+
2. Create a worktree at `../<repo-name>-<branch-name>/`
|
|
34
|
+
3. Copy all `.env*` files from the main repo
|
|
35
|
+
4. Show an interactive selector to choose Claude Code or Codex
|
|
36
|
+
5. Launch the selected AI assistant in the new worktree
|
|
37
|
+
|
|
38
|
+
**Options:**
|
|
39
|
+
- `-i, --install` - Run package manager install after creating worktree
|
|
40
|
+
- `-s, --skip-launch` - Create worktree without launching AI assistant
|
|
41
|
+
|
|
42
|
+
**Examples:**
|
|
43
|
+
```bash
|
|
44
|
+
wt new feature-auth # Create and launch AI
|
|
45
|
+
wt new feature-auth --install # Also run npm/yarn/pnpm install
|
|
46
|
+
wt new feature-auth --skip-launch # Just create the worktree
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `wt list` (or `wt ls`)
|
|
50
|
+
|
|
51
|
+
List all worktrees for the current repository.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
wt list
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Shows:
|
|
58
|
+
- Worktree paths
|
|
59
|
+
- Branch names
|
|
60
|
+
- Status (active, merged, local only, detached)
|
|
61
|
+
|
|
62
|
+
### `wt clean`
|
|
63
|
+
|
|
64
|
+
Interactively remove worktrees for merged or deleted branches.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
wt clean
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This will:
|
|
71
|
+
1. Find worktrees where the branch was merged or deleted
|
|
72
|
+
2. Show an interactive checkbox to select which to remove
|
|
73
|
+
3. Remove selected worktrees
|
|
74
|
+
|
|
75
|
+
### `wt remove <name>` (or `wt rm`)
|
|
76
|
+
|
|
77
|
+
Remove a specific worktree.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
wt remove feature-auth
|
|
81
|
+
wt rm feature-auth --force
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Options:**
|
|
85
|
+
- `-f, --force` - Force removal even with uncommitted changes
|
|
86
|
+
|
|
87
|
+
## Worktree Location
|
|
88
|
+
|
|
89
|
+
Worktrees are created as siblings to your main repository:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
/Users/you/code/
|
|
93
|
+
├── myproject/ # Main repo
|
|
94
|
+
├── myproject-feature-auth/ # Worktree for feature-auth branch
|
|
95
|
+
└── myproject-fix-bug/ # Worktree for fix-bug branch
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Env Files
|
|
99
|
+
|
|
100
|
+
The following env files are automatically copied to new worktrees:
|
|
101
|
+
- `.env`
|
|
102
|
+
- `.env.local`
|
|
103
|
+
- `.env.development`
|
|
104
|
+
- `.env.development.local`
|
|
105
|
+
- `.env.test`
|
|
106
|
+
- `.env.test.local`
|
|
107
|
+
- `.env.production`
|
|
108
|
+
- `.env.production.local`
|
|
109
|
+
|
|
110
|
+
Template files (`.env.example`, `.env.sample`) are not copied.
|
|
111
|
+
|
|
112
|
+
## Requirements
|
|
113
|
+
|
|
114
|
+
- Node.js 18+
|
|
115
|
+
- Git
|
|
116
|
+
- Claude Code or Codex CLI installed and in PATH
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/new.ts
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import path4 from "path";
|
|
10
|
+
|
|
11
|
+
// src/utils/git.ts
|
|
12
|
+
import { execFile } from "child_process";
|
|
13
|
+
import { promisify } from "util";
|
|
14
|
+
import path from "path";
|
|
15
|
+
var execFileAsync = promisify(execFile);
|
|
16
|
+
async function isGitRepo() {
|
|
17
|
+
try {
|
|
18
|
+
await execFileAsync("git", ["rev-parse", "--git-dir"]);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function getGitRoot() {
|
|
25
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"]);
|
|
26
|
+
return stdout.trim();
|
|
27
|
+
}
|
|
28
|
+
async function branchExists(branchName) {
|
|
29
|
+
try {
|
|
30
|
+
await execFileAsync("git", ["rev-parse", "--verify", branchName]);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function remoteBranchExists(branchName) {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execFileAsync("git", ["branch", "-r"]);
|
|
39
|
+
const remoteBranches = stdout.split("\n").map((b) => b.trim());
|
|
40
|
+
return remoteBranches.some(
|
|
41
|
+
(b) => b === `origin/${branchName}` || b.endsWith(`/${branchName}`)
|
|
42
|
+
);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function getDefaultBranch() {
|
|
48
|
+
try {
|
|
49
|
+
const { stdout } = await execFileAsync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
50
|
+
return stdout.trim().replace("refs/remotes/origin/", "");
|
|
51
|
+
} catch {
|
|
52
|
+
if (await branchExists("main")) return "main";
|
|
53
|
+
if (await branchExists("master")) return "master";
|
|
54
|
+
return "main";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function createWorktree(worktreePath, branchName) {
|
|
58
|
+
validateBranchName(branchName);
|
|
59
|
+
const exists = await branchExists(branchName);
|
|
60
|
+
if (exists) {
|
|
61
|
+
await execFileAsync("git", ["worktree", "add", "--", worktreePath, branchName]);
|
|
62
|
+
} else {
|
|
63
|
+
await execFileAsync("git", ["worktree", "add", "-b", branchName, "--", worktreePath]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function listWorktrees() {
|
|
67
|
+
const { stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain"]);
|
|
68
|
+
const worktrees = [];
|
|
69
|
+
let current = {};
|
|
70
|
+
for (const line of stdout.split("\n")) {
|
|
71
|
+
if (line.startsWith("worktree ")) {
|
|
72
|
+
if (current.path) {
|
|
73
|
+
worktrees.push(current);
|
|
74
|
+
}
|
|
75
|
+
current = {
|
|
76
|
+
path: line.substring(9),
|
|
77
|
+
bare: false,
|
|
78
|
+
detached: false
|
|
79
|
+
};
|
|
80
|
+
} else if (line.startsWith("HEAD ")) {
|
|
81
|
+
current.head = line.substring(5);
|
|
82
|
+
} else if (line.startsWith("branch ")) {
|
|
83
|
+
current.branch = line.substring(7).replace("refs/heads/", "");
|
|
84
|
+
} else if (line === "bare") {
|
|
85
|
+
current.bare = true;
|
|
86
|
+
} else if (line === "detached") {
|
|
87
|
+
current.detached = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (current.path) {
|
|
91
|
+
worktrees.push(current);
|
|
92
|
+
}
|
|
93
|
+
return worktrees;
|
|
94
|
+
}
|
|
95
|
+
async function removeWorktree(worktreePath, force = false) {
|
|
96
|
+
const args = ["worktree", "remove"];
|
|
97
|
+
if (force) args.push("--force");
|
|
98
|
+
args.push(worktreePath);
|
|
99
|
+
await execFileAsync("git", args);
|
|
100
|
+
}
|
|
101
|
+
async function pruneWorktrees() {
|
|
102
|
+
await execFileAsync("git", ["worktree", "prune"]);
|
|
103
|
+
}
|
|
104
|
+
async function isBranchMerged(branchName) {
|
|
105
|
+
try {
|
|
106
|
+
const defaultBranch = await getDefaultBranch();
|
|
107
|
+
const { stdout } = await execFileAsync("git", ["branch", "--merged", defaultBranch]);
|
|
108
|
+
const mergedBranches = stdout.split("\n").map((b) => b.trim().replace("* ", ""));
|
|
109
|
+
return mergedBranches.includes(branchName);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function validateBranchName(branchName) {
|
|
115
|
+
if (!branchName || branchName.trim() === "") {
|
|
116
|
+
throw new Error("Branch name cannot be empty");
|
|
117
|
+
}
|
|
118
|
+
if (branchName.startsWith("-")) {
|
|
119
|
+
throw new Error("Branch name cannot start with -");
|
|
120
|
+
}
|
|
121
|
+
if (branchName.includes("..")) {
|
|
122
|
+
throw new Error("Branch name cannot contain ..");
|
|
123
|
+
}
|
|
124
|
+
if (branchName.length > 250) {
|
|
125
|
+
throw new Error("Branch name too long (max 250 characters)");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function getWorktreePath(mainRepoPath, branchName) {
|
|
129
|
+
validateBranchName(branchName);
|
|
130
|
+
const repoName = path.basename(mainRepoPath);
|
|
131
|
+
const safeBranchName = branchName.replace(/\//g, "-");
|
|
132
|
+
return path.join(path.dirname(mainRepoPath), `${repoName}-${safeBranchName}`);
|
|
133
|
+
}
|
|
134
|
+
async function findWorktree(identifier) {
|
|
135
|
+
const worktrees = await listWorktrees();
|
|
136
|
+
return worktrees.find(
|
|
137
|
+
(wt) => wt.branch === identifier || wt.path === identifier || path.basename(wt.path) === identifier || wt.path.endsWith(identifier)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/utils/env.ts
|
|
142
|
+
import { glob } from "glob";
|
|
143
|
+
import { copyFile } from "fs/promises";
|
|
144
|
+
import path2 from "path";
|
|
145
|
+
async function findEnvFiles(sourceDir) {
|
|
146
|
+
const files = await glob(".env*", {
|
|
147
|
+
cwd: sourceDir,
|
|
148
|
+
dot: true,
|
|
149
|
+
nodir: true
|
|
150
|
+
});
|
|
151
|
+
return files.filter((file) => {
|
|
152
|
+
if (file !== ".env" && !file.startsWith(".env.")) return false;
|
|
153
|
+
if (file.endsWith(".example") || file.endsWith(".sample") || file.endsWith(".template")) return false;
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async function copyEnvFiles(sourceDir, destDir) {
|
|
158
|
+
const envFiles = await findEnvFiles(sourceDir);
|
|
159
|
+
const copied = [];
|
|
160
|
+
for (const file of envFiles) {
|
|
161
|
+
try {
|
|
162
|
+
await copyFile(path2.join(sourceDir, file), path2.join(destDir, file));
|
|
163
|
+
copied.push(file);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.warn(`Warning: Could not copy ${file}: ${error}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return copied;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/utils/launcher.ts
|
|
172
|
+
import { spawn } from "child_process";
|
|
173
|
+
import { access } from "fs/promises";
|
|
174
|
+
import path3 from "path";
|
|
175
|
+
import { constants } from "fs";
|
|
176
|
+
function launchAITool(options) {
|
|
177
|
+
const { cwd, tool } = options;
|
|
178
|
+
const child = spawn(tool, [], {
|
|
179
|
+
cwd,
|
|
180
|
+
stdio: "inherit",
|
|
181
|
+
// Detach so the CLI can exit
|
|
182
|
+
detached: true
|
|
183
|
+
});
|
|
184
|
+
child.unref();
|
|
185
|
+
}
|
|
186
|
+
async function isToolAvailable(tool) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const child = spawn("which", [tool]);
|
|
189
|
+
child.on("close", (code) => {
|
|
190
|
+
resolve(code === 0);
|
|
191
|
+
});
|
|
192
|
+
child.on("error", () => {
|
|
193
|
+
resolve(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async function detectPackageManager(dir) {
|
|
198
|
+
const lockfiles = [
|
|
199
|
+
{ file: "bun.lockb", manager: "bun" },
|
|
200
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
201
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
202
|
+
{ file: "package-lock.json", manager: "npm" }
|
|
203
|
+
];
|
|
204
|
+
for (const { file, manager } of lockfiles) {
|
|
205
|
+
try {
|
|
206
|
+
await access(path3.join(dir, file), constants.R_OK);
|
|
207
|
+
return manager;
|
|
208
|
+
} catch {
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
await access(path3.join(dir, "package.json"), constants.R_OK);
|
|
213
|
+
return "npm";
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function runInstall(dir, packageManager) {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const child = spawn(packageManager, ["install"], {
|
|
221
|
+
cwd: dir,
|
|
222
|
+
stdio: "inherit"
|
|
223
|
+
});
|
|
224
|
+
child.on("close", (code) => {
|
|
225
|
+
if (code === 0) {
|
|
226
|
+
resolve();
|
|
227
|
+
} else {
|
|
228
|
+
reject(new Error(`${packageManager} install failed with code ${code}`));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
child.on("error", reject);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/ui/selector.ts
|
|
236
|
+
import inquirer from "inquirer";
|
|
237
|
+
var AI_TOOLS = [
|
|
238
|
+
{
|
|
239
|
+
name: "Claude Code",
|
|
240
|
+
value: "claude",
|
|
241
|
+
description: "Anthropic's Claude coding assistant"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "Codex",
|
|
245
|
+
value: "codex",
|
|
246
|
+
description: "OpenAI's Codex coding assistant"
|
|
247
|
+
}
|
|
248
|
+
];
|
|
249
|
+
async function selectAITool() {
|
|
250
|
+
const { tool } = await inquirer.prompt([
|
|
251
|
+
{
|
|
252
|
+
type: "list",
|
|
253
|
+
name: "tool",
|
|
254
|
+
message: "Select AI coding assistant:",
|
|
255
|
+
choices: AI_TOOLS.map((t) => ({
|
|
256
|
+
name: `${t.name} - ${t.description}`,
|
|
257
|
+
value: t.value,
|
|
258
|
+
short: t.name
|
|
259
|
+
}))
|
|
260
|
+
}
|
|
261
|
+
]);
|
|
262
|
+
return tool;
|
|
263
|
+
}
|
|
264
|
+
async function confirm(message, defaultValue = true) {
|
|
265
|
+
const { confirmed } = await inquirer.prompt([
|
|
266
|
+
{
|
|
267
|
+
type: "confirm",
|
|
268
|
+
name: "confirmed",
|
|
269
|
+
message,
|
|
270
|
+
default: defaultValue
|
|
271
|
+
}
|
|
272
|
+
]);
|
|
273
|
+
return confirmed;
|
|
274
|
+
}
|
|
275
|
+
async function selectMultiple(message, choices) {
|
|
276
|
+
const { selected } = await inquirer.prompt([
|
|
277
|
+
{
|
|
278
|
+
type: "checkbox",
|
|
279
|
+
name: "selected",
|
|
280
|
+
message,
|
|
281
|
+
choices
|
|
282
|
+
}
|
|
283
|
+
]);
|
|
284
|
+
return selected;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/commands/new.ts
|
|
288
|
+
async function newCommand(branchName, options) {
|
|
289
|
+
if (!await isGitRepo()) {
|
|
290
|
+
console.error(chalk.red("Error: Not a git repository"));
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
const mainRepoPath = await getGitRoot();
|
|
294
|
+
const repoName = path4.basename(mainRepoPath);
|
|
295
|
+
const worktreePath = getWorktreePath(mainRepoPath, branchName);
|
|
296
|
+
console.log(chalk.cyan(`
|
|
297
|
+
Creating worktree for branch: ${chalk.bold(branchName)}`));
|
|
298
|
+
console.log(chalk.dim(`Repository: ${repoName}`));
|
|
299
|
+
console.log(chalk.dim(`Worktree path: ${worktreePath}
|
|
300
|
+
`));
|
|
301
|
+
const spinner = ora("Creating worktree...").start();
|
|
302
|
+
try {
|
|
303
|
+
await createWorktree(worktreePath, branchName);
|
|
304
|
+
spinner.succeed(chalk.green("Worktree created successfully"));
|
|
305
|
+
} catch (error) {
|
|
306
|
+
spinner.fail(chalk.red("Failed to create worktree"));
|
|
307
|
+
console.error(chalk.red(error.message || error));
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
const envSpinner = ora("Copying .env files...").start();
|
|
311
|
+
try {
|
|
312
|
+
const copiedFiles = await copyEnvFiles(mainRepoPath, worktreePath);
|
|
313
|
+
if (copiedFiles.length > 0) {
|
|
314
|
+
envSpinner.succeed(chalk.green(`Copied ${copiedFiles.length} env file(s): ${copiedFiles.join(", ")}`));
|
|
315
|
+
} else {
|
|
316
|
+
envSpinner.info(chalk.yellow("No .env files found to copy"));
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
envSpinner.warn(chalk.yellow(`Warning: Could not copy env files: ${error.message}`));
|
|
320
|
+
}
|
|
321
|
+
if (options.install) {
|
|
322
|
+
const packageManager = await detectPackageManager(worktreePath);
|
|
323
|
+
if (packageManager) {
|
|
324
|
+
const installSpinner = ora(`Running ${packageManager} install...`).start();
|
|
325
|
+
try {
|
|
326
|
+
await runInstall(worktreePath, packageManager);
|
|
327
|
+
installSpinner.succeed(chalk.green(`${packageManager} install completed`));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
installSpinner.fail(chalk.red(`${packageManager} install failed: ${error.message}`));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
const packageManager = await detectPackageManager(worktreePath);
|
|
334
|
+
if (packageManager) {
|
|
335
|
+
console.log(chalk.dim(`
|
|
336
|
+
Tip: Run '${packageManager} install' in the worktree, or use 'wt new --install' next time`));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (options.skipLaunch) {
|
|
340
|
+
console.log(chalk.green(`
|
|
341
|
+
\u2713 Worktree ready at: ${worktreePath}`));
|
|
342
|
+
console.log(chalk.dim(` cd "${worktreePath}"`));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
console.log("");
|
|
346
|
+
const selectedTool = await selectAITool();
|
|
347
|
+
const toolAvailable = await isToolAvailable(selectedTool);
|
|
348
|
+
if (!toolAvailable) {
|
|
349
|
+
console.error(chalk.red(`
|
|
350
|
+
Error: ${selectedTool} is not installed or not in PATH`));
|
|
351
|
+
console.log(chalk.dim(`Worktree is ready at: ${worktreePath}`));
|
|
352
|
+
console.log(chalk.dim(`You can manually launch your AI tool there.`));
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
console.log(chalk.cyan(`
|
|
356
|
+
Launching ${selectedTool} in worktree...`));
|
|
357
|
+
launchAITool({
|
|
358
|
+
cwd: worktreePath,
|
|
359
|
+
tool: selectedTool
|
|
360
|
+
});
|
|
361
|
+
console.log(chalk.green(`
|
|
362
|
+
\u2713 ${selectedTool} launched in: ${worktreePath}`));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/commands/list.ts
|
|
366
|
+
import chalk2 from "chalk";
|
|
367
|
+
import path5 from "path";
|
|
368
|
+
async function listCommand() {
|
|
369
|
+
if (!await isGitRepo()) {
|
|
370
|
+
console.error(chalk2.red("Error: Not a git repository"));
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
const mainRepoPath = await getGitRoot();
|
|
374
|
+
const worktrees = await listWorktrees();
|
|
375
|
+
if (worktrees.length === 0) {
|
|
376
|
+
console.log(chalk2.yellow("No worktrees found"));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
console.log(chalk2.cyan(`
|
|
380
|
+
Worktrees for: ${chalk2.bold(path5.basename(mainRepoPath))}
|
|
381
|
+
`));
|
|
382
|
+
console.log(
|
|
383
|
+
chalk2.dim("\u2500".repeat(100))
|
|
384
|
+
);
|
|
385
|
+
console.log(
|
|
386
|
+
chalk2.bold(padEnd("Path", 50)) + chalk2.bold(padEnd("Branch", 25)) + chalk2.bold("Status")
|
|
387
|
+
);
|
|
388
|
+
console.log(
|
|
389
|
+
chalk2.dim("\u2500".repeat(100))
|
|
390
|
+
);
|
|
391
|
+
for (const wt of worktrees) {
|
|
392
|
+
const isMain = wt.path === mainRepoPath;
|
|
393
|
+
const status = await getWorktreeStatus(wt.branch, wt.detached, isMain);
|
|
394
|
+
const displayPath = shortenPath(wt.path, 48);
|
|
395
|
+
const displayBranch = wt.detached ? chalk2.yellow("(detached)") : wt.branch || "N/A";
|
|
396
|
+
console.log(
|
|
397
|
+
padEnd(isMain ? chalk2.bold(displayPath) : displayPath, 50) + padEnd(displayBranch, 25) + status
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
console.log(chalk2.dim("\u2500".repeat(100)));
|
|
401
|
+
console.log(chalk2.dim(`
|
|
402
|
+
Total: ${worktrees.length} worktree(s)`));
|
|
403
|
+
}
|
|
404
|
+
async function getWorktreeStatus(branch, detached, isMain) {
|
|
405
|
+
if (isMain) {
|
|
406
|
+
return chalk2.blue("main");
|
|
407
|
+
}
|
|
408
|
+
if (detached) {
|
|
409
|
+
return chalk2.yellow("detached");
|
|
410
|
+
}
|
|
411
|
+
if (!branch) {
|
|
412
|
+
return chalk2.dim("unknown");
|
|
413
|
+
}
|
|
414
|
+
const existsOnRemote = await remoteBranchExists(branch);
|
|
415
|
+
const isMerged = await isBranchMerged(branch);
|
|
416
|
+
if (isMerged) {
|
|
417
|
+
return chalk2.green("merged") + chalk2.dim(" (can clean)");
|
|
418
|
+
}
|
|
419
|
+
if (!existsOnRemote) {
|
|
420
|
+
return chalk2.yellow("local only");
|
|
421
|
+
}
|
|
422
|
+
return chalk2.green("active");
|
|
423
|
+
}
|
|
424
|
+
function padEnd(str, length) {
|
|
425
|
+
const visibleLength = str.replace(/\x1B\[[0-9;]*m/g, "").length;
|
|
426
|
+
const padding = Math.max(0, length - visibleLength);
|
|
427
|
+
return str + " ".repeat(padding);
|
|
428
|
+
}
|
|
429
|
+
function shortenPath(p, maxLength) {
|
|
430
|
+
if (p.length <= maxLength) return p;
|
|
431
|
+
const parts = p.split(path5.sep);
|
|
432
|
+
let result = parts[parts.length - 1];
|
|
433
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
434
|
+
const newResult = path5.join(parts[i], result);
|
|
435
|
+
if (newResult.length > maxLength - 3) {
|
|
436
|
+
return "..." + path5.sep + result;
|
|
437
|
+
}
|
|
438
|
+
result = newResult;
|
|
439
|
+
}
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/commands/clean.ts
|
|
444
|
+
import chalk3 from "chalk";
|
|
445
|
+
import ora2 from "ora";
|
|
446
|
+
import path6 from "path";
|
|
447
|
+
async function cleanCommand() {
|
|
448
|
+
if (!await isGitRepo()) {
|
|
449
|
+
console.error(chalk3.red("Error: Not a git repository"));
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
const mainRepoPath = await getGitRoot();
|
|
453
|
+
const pruneSpinner = ora2("Pruning stale references...").start();
|
|
454
|
+
await pruneWorktrees();
|
|
455
|
+
pruneSpinner.succeed("Pruned stale references");
|
|
456
|
+
const worktrees = await listWorktrees();
|
|
457
|
+
const spinner = ora2("Checking worktree status...").start();
|
|
458
|
+
const staleWorktrees = [];
|
|
459
|
+
for (const wt of worktrees) {
|
|
460
|
+
if (wt.path === mainRepoPath) continue;
|
|
461
|
+
if (wt.detached || wt.bare || !wt.branch) continue;
|
|
462
|
+
const merged = await isBranchMerged(wt.branch);
|
|
463
|
+
if (merged) {
|
|
464
|
+
staleWorktrees.push({ ...wt, reason: "merged" });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const existsOnRemote = await remoteBranchExists(wt.branch);
|
|
468
|
+
if (!existsOnRemote) {
|
|
469
|
+
staleWorktrees.push({ ...wt, reason: "local-only" });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
spinner.stop();
|
|
473
|
+
if (staleWorktrees.length === 0) {
|
|
474
|
+
console.log(chalk3.green("\n\u2713 No stale worktrees found"));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
console.log(chalk3.yellow(`
|
|
478
|
+
Found ${staleWorktrees.length} potentially stale worktree(s):
|
|
479
|
+
`));
|
|
480
|
+
const choices = staleWorktrees.map((wt) => {
|
|
481
|
+
const reasonText = wt.reason === "merged" ? chalk3.green("merged") : chalk3.yellow("local only");
|
|
482
|
+
return {
|
|
483
|
+
name: `${path6.basename(wt.path)} (${wt.branch}) - ${reasonText}`,
|
|
484
|
+
value: wt,
|
|
485
|
+
checked: wt.reason === "merged"
|
|
486
|
+
// Pre-select merged branches
|
|
487
|
+
};
|
|
488
|
+
});
|
|
489
|
+
const selected = await selectMultiple(
|
|
490
|
+
"Select worktrees to remove:",
|
|
491
|
+
choices
|
|
492
|
+
);
|
|
493
|
+
if (selected.length === 0) {
|
|
494
|
+
console.log(chalk3.yellow("\nNo worktrees selected for removal"));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const confirmed = await confirm(
|
|
498
|
+
`Remove ${selected.length} worktree(s)?`,
|
|
499
|
+
true
|
|
500
|
+
);
|
|
501
|
+
if (!confirmed) {
|
|
502
|
+
console.log(chalk3.yellow("Cancelled"));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
console.log("");
|
|
506
|
+
let removed = 0;
|
|
507
|
+
let failed = 0;
|
|
508
|
+
for (const wt of selected) {
|
|
509
|
+
const removeSpinner = ora2(`Removing ${path6.basename(wt.path)}...`).start();
|
|
510
|
+
try {
|
|
511
|
+
await removeWorktree(wt.path, false);
|
|
512
|
+
removeSpinner.succeed(chalk3.green(`Removed ${path6.basename(wt.path)}`));
|
|
513
|
+
removed++;
|
|
514
|
+
} catch (error) {
|
|
515
|
+
try {
|
|
516
|
+
await removeWorktree(wt.path, true);
|
|
517
|
+
removeSpinner.succeed(chalk3.green(`Removed ${path6.basename(wt.path)} (forced)`));
|
|
518
|
+
removed++;
|
|
519
|
+
} catch (forceError) {
|
|
520
|
+
removeSpinner.fail(chalk3.red(`Failed to remove ${path6.basename(wt.path)}: ${forceError.message}`));
|
|
521
|
+
failed++;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
console.log("");
|
|
526
|
+
if (removed > 0) {
|
|
527
|
+
console.log(chalk3.green(`\u2713 Removed ${removed} worktree(s)`));
|
|
528
|
+
}
|
|
529
|
+
if (failed > 0) {
|
|
530
|
+
console.log(chalk3.red(`\u2717 Failed to remove ${failed} worktree(s)`));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/commands/remove.ts
|
|
535
|
+
import chalk4 from "chalk";
|
|
536
|
+
import ora3 from "ora";
|
|
537
|
+
import path7 from "path";
|
|
538
|
+
async function removeCommand(identifier, options) {
|
|
539
|
+
if (!await isGitRepo()) {
|
|
540
|
+
console.error(chalk4.red("Error: Not a git repository"));
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
543
|
+
const mainRepoPath = await getGitRoot();
|
|
544
|
+
const spinner = ora3("Finding worktree...").start();
|
|
545
|
+
const worktree = await findWorktree(identifier);
|
|
546
|
+
if (!worktree) {
|
|
547
|
+
spinner.fail(chalk4.red(`Worktree not found: ${identifier}`));
|
|
548
|
+
console.log(chalk4.dim('\nTip: Run "wt list" to see available worktrees'));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
spinner.stop();
|
|
552
|
+
if (worktree.path === mainRepoPath) {
|
|
553
|
+
console.error(chalk4.red("\nError: Cannot remove the main worktree"));
|
|
554
|
+
process.exit(1);
|
|
555
|
+
}
|
|
556
|
+
console.log(chalk4.cyan("\nWorktree to remove:"));
|
|
557
|
+
console.log(chalk4.dim(` Path: ${worktree.path}`));
|
|
558
|
+
console.log(chalk4.dim(` Branch: ${worktree.branch || "(detached)"}`));
|
|
559
|
+
if (!options.force) {
|
|
560
|
+
const confirmed = await confirm("\nRemove this worktree?", false);
|
|
561
|
+
if (!confirmed) {
|
|
562
|
+
console.log(chalk4.yellow("Cancelled"));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const removeSpinner = ora3("Removing worktree...").start();
|
|
567
|
+
try {
|
|
568
|
+
await removeWorktree(worktree.path, false);
|
|
569
|
+
removeSpinner.succeed(chalk4.green(`Removed worktree: ${path7.basename(worktree.path)}`));
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (options.force) {
|
|
572
|
+
try {
|
|
573
|
+
await removeWorktree(worktree.path, true);
|
|
574
|
+
removeSpinner.succeed(chalk4.green(`Removed worktree (forced): ${path7.basename(worktree.path)}`));
|
|
575
|
+
} catch (forceError) {
|
|
576
|
+
removeSpinner.fail(chalk4.red(`Failed to remove worktree: ${forceError.message}`));
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
removeSpinner.fail(chalk4.red(`Failed to remove worktree: ${error.message}`));
|
|
581
|
+
console.log(chalk4.dim("\nTip: Use --force to force removal"));
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/index.ts
|
|
588
|
+
var program = new Command();
|
|
589
|
+
program.name("wt").description("CLI tool to streamline git worktrees with AI coding assistants").version("1.0.0");
|
|
590
|
+
program.command("new <branch-name>").description("Create a new worktree and launch AI assistant").option("-i, --install", "Run package manager install after creating worktree").option("-s, --skip-launch", "Create worktree without launching AI assistant").action(async (branchName, options) => {
|
|
591
|
+
await newCommand(branchName, {
|
|
592
|
+
install: options.install,
|
|
593
|
+
skipLaunch: options.skipLaunch
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
program.command("list").alias("ls").description("List all worktrees for the current repository").action(async () => {
|
|
597
|
+
await listCommand();
|
|
598
|
+
});
|
|
599
|
+
program.command("clean").description("Remove worktrees for merged or deleted branches").action(async () => {
|
|
600
|
+
await cleanCommand();
|
|
601
|
+
});
|
|
602
|
+
program.command("remove <name>").alias("rm").description("Remove a specific worktree").option("-f, --force", "Force removal even if there are uncommitted changes").action(async (name, options) => {
|
|
603
|
+
await removeCommand(name, {
|
|
604
|
+
force: options.force
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "worktree-launcher",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to streamline git worktrees with AI coding assistants",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"wt": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
12
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"lint": "eslint src/",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"git",
|
|
19
|
+
"worktree",
|
|
20
|
+
"cli",
|
|
21
|
+
"claude",
|
|
22
|
+
"codex",
|
|
23
|
+
"ai"
|
|
24
|
+
],
|
|
25
|
+
"author": "",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"chalk": "^5.3.0",
|
|
29
|
+
"commander": "^12.1.0",
|
|
30
|
+
"glob": "^10.4.5",
|
|
31
|
+
"inquirer": "^9.3.7",
|
|
32
|
+
"ora": "^8.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/inquirer": "^9.0.7",
|
|
36
|
+
"@types/node": "^20.17.9",
|
|
37
|
+
"tsup": "^8.3.5",
|
|
38
|
+
"typescript": "^5.7.2"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist"
|
|
45
|
+
]
|
|
46
|
+
}
|