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