@aku11i/phantom 0.4.0 → 0.6.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.ja.md +101 -4
- package/README.md +104 -4
- package/dist/phantom.js +595 -126
- package/dist/phantom.js.map +4 -4
- package/package.json +7 -5
package/dist/phantom.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
// src/bin/phantom.ts
|
|
4
4
|
import { argv, exit } from "node:process";
|
|
5
5
|
|
|
6
|
-
// src/cli/handlers/
|
|
6
|
+
// src/cli/handlers/attach.ts
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
|
|
9
|
+
// src/core/git/libs/get-git-root.ts
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
|
|
9
12
|
// src/core/git/executor.ts
|
|
10
13
|
import { execFile as execFileCallback } from "node:child_process";
|
|
11
14
|
import { promisify } from "node:util";
|
|
@@ -41,8 +44,15 @@ async function executeGitCommandInDirectory(directory, args2) {
|
|
|
41
44
|
|
|
42
45
|
// src/core/git/libs/get-git-root.ts
|
|
43
46
|
async function getGitRoot() {
|
|
44
|
-
const { stdout } = await executeGitCommand(["rev-parse", "--
|
|
45
|
-
|
|
47
|
+
const { stdout } = await executeGitCommand(["rev-parse", "--git-common-dir"]);
|
|
48
|
+
if (stdout.endsWith("/.git") || stdout === ".git") {
|
|
49
|
+
return resolve(process.cwd(), dirname(stdout));
|
|
50
|
+
}
|
|
51
|
+
const { stdout: toplevel } = await executeGitCommand([
|
|
52
|
+
"rev-parse",
|
|
53
|
+
"--show-toplevel"
|
|
54
|
+
]);
|
|
55
|
+
return toplevel;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
// src/core/types/result.ts
|
|
@@ -82,6 +92,12 @@ var GitOperationError = class extends WorktreeError {
|
|
|
82
92
|
this.name = "GitOperationError";
|
|
83
93
|
}
|
|
84
94
|
};
|
|
95
|
+
var BranchNotFoundError = class extends WorktreeError {
|
|
96
|
+
constructor(branchName) {
|
|
97
|
+
super(`Branch '${branchName}' not found`);
|
|
98
|
+
this.name = "BranchNotFoundError";
|
|
99
|
+
}
|
|
100
|
+
};
|
|
85
101
|
|
|
86
102
|
// src/core/worktree/validate.ts
|
|
87
103
|
import fs from "node:fs/promises";
|
|
@@ -126,33 +142,17 @@ async function validateWorktreeDoesNotExist(gitRoot, name) {
|
|
|
126
142
|
};
|
|
127
143
|
}
|
|
128
144
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
await fs.access(phantomDir);
|
|
133
|
-
return true;
|
|
134
|
-
} catch {
|
|
135
|
-
return false;
|
|
145
|
+
function validateWorktreeName(name) {
|
|
146
|
+
if (!name || name.trim() === "") {
|
|
147
|
+
return err(new Error("Phantom name cannot be empty"));
|
|
136
148
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const phantomDir = getPhantomDirectory(gitRoot);
|
|
140
|
-
if (!await validatePhantomDirectoryExists(gitRoot)) {
|
|
141
|
-
return [];
|
|
149
|
+
if (name.includes("/")) {
|
|
150
|
+
return err(new Error("Phantom name cannot contain slashes"));
|
|
142
151
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const validWorktrees = [];
|
|
146
|
-
for (const entry of entries) {
|
|
147
|
-
const result = await validateWorktreeExists(gitRoot, entry);
|
|
148
|
-
if (result.exists) {
|
|
149
|
-
validWorktrees.push(entry);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return validWorktrees;
|
|
153
|
-
} catch {
|
|
154
|
-
return [];
|
|
152
|
+
if (name.startsWith(".")) {
|
|
153
|
+
return err(new Error("Phantom name cannot start with a dot"));
|
|
155
154
|
}
|
|
155
|
+
return ok(void 0);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
// src/core/process/spawn.ts
|
|
@@ -191,24 +191,24 @@ var ProcessSpawnError = class extends ProcessError {
|
|
|
191
191
|
|
|
192
192
|
// src/core/process/spawn.ts
|
|
193
193
|
async function spawnProcess(config) {
|
|
194
|
-
return new Promise((
|
|
194
|
+
return new Promise((resolve2) => {
|
|
195
195
|
const { command: command2, args: args2 = [], options = {} } = config;
|
|
196
196
|
const childProcess = nodeSpawn(command2, args2, {
|
|
197
197
|
stdio: "inherit",
|
|
198
198
|
...options
|
|
199
199
|
});
|
|
200
200
|
childProcess.on("error", (error) => {
|
|
201
|
-
|
|
201
|
+
resolve2(err(new ProcessSpawnError(command2, error.message)));
|
|
202
202
|
});
|
|
203
203
|
childProcess.on("exit", (code, signal) => {
|
|
204
204
|
if (signal) {
|
|
205
|
-
|
|
205
|
+
resolve2(err(new ProcessSignalError(signal)));
|
|
206
206
|
} else {
|
|
207
207
|
const exitCode = code ?? 0;
|
|
208
208
|
if (exitCode === 0) {
|
|
209
|
-
|
|
209
|
+
resolve2(ok({ exitCode }));
|
|
210
210
|
} else {
|
|
211
|
-
|
|
211
|
+
resolve2(err(new ProcessExecutionError(command2, exitCode)));
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
});
|
|
@@ -255,43 +255,68 @@ async function shellInWorktree(gitRoot, worktreeName) {
|
|
|
255
255
|
});
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
// src/core/worktree/
|
|
259
|
-
import
|
|
258
|
+
// src/core/worktree/attach.ts
|
|
259
|
+
import { existsSync } from "node:fs";
|
|
260
260
|
|
|
261
|
-
// src/core/git/libs/
|
|
262
|
-
async function
|
|
263
|
-
|
|
264
|
-
|
|
261
|
+
// src/core/git/libs/attach-worktree.ts
|
|
262
|
+
async function attachWorktree(gitRoot, worktreePath, branchName) {
|
|
263
|
+
try {
|
|
264
|
+
await executeGitCommand(["worktree", "add", worktreePath, branchName], {
|
|
265
|
+
cwd: gitRoot
|
|
266
|
+
});
|
|
267
|
+
return ok(void 0);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return err(
|
|
270
|
+
error instanceof Error ? error : new Error(`Failed to attach worktree: ${String(error)}`)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
265
273
|
}
|
|
266
274
|
|
|
267
|
-
// src/core/
|
|
268
|
-
async function
|
|
269
|
-
const { branch = name, commitish = "HEAD" } = options;
|
|
270
|
-
const worktreesPath = getPhantomDirectory(gitRoot);
|
|
271
|
-
const worktreePath = getWorktreePath(gitRoot, name);
|
|
275
|
+
// src/core/git/libs/branch-exists.ts
|
|
276
|
+
async function branchExists(gitRoot, branchName) {
|
|
272
277
|
try {
|
|
273
|
-
await
|
|
274
|
-
|
|
275
|
-
|
|
278
|
+
await executeGitCommand(
|
|
279
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
|
|
280
|
+
{ cwd: gitRoot }
|
|
281
|
+
);
|
|
282
|
+
return ok(true);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
285
|
+
const execError = error;
|
|
286
|
+
if (execError.code === 1) {
|
|
287
|
+
return ok(false);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return err(
|
|
291
|
+
new Error(
|
|
292
|
+
`Failed to check branch existence: ${error instanceof Error ? error.message : String(error)}`
|
|
293
|
+
)
|
|
294
|
+
);
|
|
276
295
|
}
|
|
277
|
-
|
|
278
|
-
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/core/worktree/attach.ts
|
|
299
|
+
async function attachWorktreeCore(gitRoot, name) {
|
|
300
|
+
const validation = validateWorktreeName(name);
|
|
301
|
+
if (isErr(validation)) {
|
|
302
|
+
return validation;
|
|
303
|
+
}
|
|
304
|
+
const worktreePath = getWorktreePath(gitRoot, name);
|
|
305
|
+
if (existsSync(worktreePath)) {
|
|
279
306
|
return err(new WorktreeAlreadyExistsError(name));
|
|
280
307
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
} catch (error) {
|
|
292
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
293
|
-
return err(new GitOperationError("worktree add", errorMessage));
|
|
308
|
+
const branchCheckResult = await branchExists(gitRoot, name);
|
|
309
|
+
if (isErr(branchCheckResult)) {
|
|
310
|
+
return err(branchCheckResult.error);
|
|
311
|
+
}
|
|
312
|
+
if (!branchCheckResult.value) {
|
|
313
|
+
return err(new BranchNotFoundError(name));
|
|
314
|
+
}
|
|
315
|
+
const attachResult = await attachWorktree(gitRoot, worktreePath, name);
|
|
316
|
+
if (isErr(attachResult)) {
|
|
317
|
+
return err(attachResult.error);
|
|
294
318
|
}
|
|
319
|
+
return ok(worktreePath);
|
|
295
320
|
}
|
|
296
321
|
|
|
297
322
|
// src/cli/output.ts
|
|
@@ -302,6 +327,9 @@ var output = {
|
|
|
302
327
|
error: (message) => {
|
|
303
328
|
console.error(message);
|
|
304
329
|
},
|
|
330
|
+
warn: (message) => {
|
|
331
|
+
console.warn(message);
|
|
332
|
+
},
|
|
305
333
|
table: (data) => {
|
|
306
334
|
console.table(data);
|
|
307
335
|
},
|
|
@@ -326,9 +354,295 @@ function exitWithError(message, exitCode = exitCodes.generalError) {
|
|
|
326
354
|
process.exit(exitCode);
|
|
327
355
|
}
|
|
328
356
|
|
|
357
|
+
// src/cli/handlers/attach.ts
|
|
358
|
+
async function attachHandler(args2) {
|
|
359
|
+
const { positionals, values } = parseArgs({
|
|
360
|
+
args: args2,
|
|
361
|
+
strict: true,
|
|
362
|
+
allowPositionals: true,
|
|
363
|
+
options: {
|
|
364
|
+
shell: {
|
|
365
|
+
type: "boolean",
|
|
366
|
+
short: "s"
|
|
367
|
+
},
|
|
368
|
+
exec: {
|
|
369
|
+
type: "string",
|
|
370
|
+
short: "e"
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
if (positionals.length === 0) {
|
|
375
|
+
exitWithError(
|
|
376
|
+
"Missing required argument: branch name",
|
|
377
|
+
exitCodes.validationError
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const [branchName] = positionals;
|
|
381
|
+
if (values.shell && values.exec) {
|
|
382
|
+
exitWithError(
|
|
383
|
+
"Cannot use both --shell and --exec options",
|
|
384
|
+
exitCodes.validationError
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
const gitRoot = await getGitRoot();
|
|
388
|
+
const result = await attachWorktreeCore(gitRoot, branchName);
|
|
389
|
+
if (isErr(result)) {
|
|
390
|
+
const error = result.error;
|
|
391
|
+
if (error instanceof WorktreeAlreadyExistsError) {
|
|
392
|
+
exitWithError(error.message, exitCodes.validationError);
|
|
393
|
+
}
|
|
394
|
+
if (error instanceof BranchNotFoundError) {
|
|
395
|
+
exitWithError(error.message, exitCodes.notFound);
|
|
396
|
+
}
|
|
397
|
+
exitWithError(error.message, exitCodes.generalError);
|
|
398
|
+
}
|
|
399
|
+
const worktreePath = result.value;
|
|
400
|
+
output.log(`Attached phantom: ${branchName}`);
|
|
401
|
+
if (values.shell) {
|
|
402
|
+
const shellResult = await shellInWorktree(gitRoot, branchName);
|
|
403
|
+
if (isErr(shellResult)) {
|
|
404
|
+
exitWithError(shellResult.error.message, exitCodes.generalError);
|
|
405
|
+
}
|
|
406
|
+
} else if (values.exec) {
|
|
407
|
+
const execResult = await execInWorktree(
|
|
408
|
+
gitRoot,
|
|
409
|
+
branchName,
|
|
410
|
+
values.exec.split(" ")
|
|
411
|
+
);
|
|
412
|
+
if (isErr(execResult)) {
|
|
413
|
+
exitWithError(execResult.error.message, exitCodes.generalError);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/cli/handlers/create.ts
|
|
419
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
420
|
+
|
|
421
|
+
// src/core/config/loader.ts
|
|
422
|
+
import fs2 from "node:fs/promises";
|
|
423
|
+
import path from "node:path";
|
|
424
|
+
|
|
425
|
+
// src/core/utils/type-guards.ts
|
|
426
|
+
function isObject(value) {
|
|
427
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// src/core/config/validate.ts
|
|
431
|
+
var ConfigValidationError = class extends Error {
|
|
432
|
+
constructor(message) {
|
|
433
|
+
super(`Invalid phantom.config.json: ${message}`);
|
|
434
|
+
this.name = "ConfigValidationError";
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
function validateConfig(config) {
|
|
438
|
+
if (!isObject(config)) {
|
|
439
|
+
return err(new ConfigValidationError("Configuration must be an object"));
|
|
440
|
+
}
|
|
441
|
+
const cfg = config;
|
|
442
|
+
if (cfg.postCreate !== void 0) {
|
|
443
|
+
if (!isObject(cfg.postCreate)) {
|
|
444
|
+
return err(new ConfigValidationError("postCreate must be an object"));
|
|
445
|
+
}
|
|
446
|
+
const postCreate = cfg.postCreate;
|
|
447
|
+
if (postCreate.copyFiles !== void 0) {
|
|
448
|
+
if (!Array.isArray(postCreate.copyFiles)) {
|
|
449
|
+
return err(
|
|
450
|
+
new ConfigValidationError("postCreate.copyFiles must be an array")
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (!postCreate.copyFiles.every((f) => typeof f === "string")) {
|
|
454
|
+
return err(
|
|
455
|
+
new ConfigValidationError(
|
|
456
|
+
"postCreate.copyFiles must contain only strings"
|
|
457
|
+
)
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return ok(config);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/core/config/loader.ts
|
|
466
|
+
var ConfigNotFoundError = class extends Error {
|
|
467
|
+
constructor() {
|
|
468
|
+
super("phantom.config.json not found");
|
|
469
|
+
this.name = "ConfigNotFoundError";
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
var ConfigParseError = class extends Error {
|
|
473
|
+
constructor(message) {
|
|
474
|
+
super(`Failed to parse phantom.config.json: ${message}`);
|
|
475
|
+
this.name = "ConfigParseError";
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
async function loadConfig(gitRoot) {
|
|
479
|
+
const configPath = path.join(gitRoot, "phantom.config.json");
|
|
480
|
+
try {
|
|
481
|
+
const content = await fs2.readFile(configPath, "utf-8");
|
|
482
|
+
try {
|
|
483
|
+
const parsed = JSON.parse(content);
|
|
484
|
+
const validationResult = validateConfig(parsed);
|
|
485
|
+
if (!validationResult.ok) {
|
|
486
|
+
return err(validationResult.error);
|
|
487
|
+
}
|
|
488
|
+
return ok(validationResult.value);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return err(
|
|
491
|
+
new ConfigParseError(
|
|
492
|
+
error instanceof Error ? error.message : String(error)
|
|
493
|
+
)
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
498
|
+
return err(new ConfigNotFoundError());
|
|
499
|
+
}
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/core/process/tmux.ts
|
|
505
|
+
async function isInsideTmux() {
|
|
506
|
+
return process.env.TMUX !== void 0;
|
|
507
|
+
}
|
|
508
|
+
async function executeTmuxCommand(options) {
|
|
509
|
+
const { direction, command: command2, cwd, env } = options;
|
|
510
|
+
const tmuxArgs = [];
|
|
511
|
+
switch (direction) {
|
|
512
|
+
case "new":
|
|
513
|
+
tmuxArgs.push("new-window");
|
|
514
|
+
break;
|
|
515
|
+
case "vertical":
|
|
516
|
+
tmuxArgs.push("split-window", "-v");
|
|
517
|
+
break;
|
|
518
|
+
case "horizontal":
|
|
519
|
+
tmuxArgs.push("split-window", "-h");
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
if (cwd) {
|
|
523
|
+
tmuxArgs.push("-c", cwd);
|
|
524
|
+
}
|
|
525
|
+
if (env) {
|
|
526
|
+
for (const [key, value] of Object.entries(env)) {
|
|
527
|
+
tmuxArgs.push("-e", `${key}=${value}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
tmuxArgs.push(command2);
|
|
531
|
+
const result = await spawnProcess({
|
|
532
|
+
command: "tmux",
|
|
533
|
+
args: tmuxArgs
|
|
534
|
+
});
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/core/worktree/create.ts
|
|
539
|
+
import fs3 from "node:fs/promises";
|
|
540
|
+
|
|
541
|
+
// src/core/git/libs/add-worktree.ts
|
|
542
|
+
async function addWorktree(options) {
|
|
543
|
+
const { path: path3, branch, commitish = "HEAD" } = options;
|
|
544
|
+
await executeGitCommand(["worktree", "add", path3, "-b", branch, commitish]);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/core/worktree/file-copier.ts
|
|
548
|
+
import { copyFile, mkdir, stat } from "node:fs/promises";
|
|
549
|
+
import path2 from "node:path";
|
|
550
|
+
var FileCopyError = class extends Error {
|
|
551
|
+
file;
|
|
552
|
+
constructor(file, message) {
|
|
553
|
+
super(`Failed to copy ${file}: ${message}`);
|
|
554
|
+
this.name = "FileCopyError";
|
|
555
|
+
this.file = file;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
async function copyFiles(sourceDir, targetDir, files) {
|
|
559
|
+
const copiedFiles = [];
|
|
560
|
+
const skippedFiles = [];
|
|
561
|
+
for (const file of files) {
|
|
562
|
+
const sourcePath = path2.join(sourceDir, file);
|
|
563
|
+
const targetPath = path2.join(targetDir, file);
|
|
564
|
+
try {
|
|
565
|
+
const stats = await stat(sourcePath);
|
|
566
|
+
if (!stats.isFile()) {
|
|
567
|
+
skippedFiles.push(file);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const targetDirPath = path2.dirname(targetPath);
|
|
571
|
+
await mkdir(targetDirPath, { recursive: true });
|
|
572
|
+
await copyFile(sourcePath, targetPath);
|
|
573
|
+
copiedFiles.push(file);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
576
|
+
skippedFiles.push(file);
|
|
577
|
+
} else {
|
|
578
|
+
return err(
|
|
579
|
+
new FileCopyError(
|
|
580
|
+
file,
|
|
581
|
+
error instanceof Error ? error.message : String(error)
|
|
582
|
+
)
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return ok({ copiedFiles, skippedFiles });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/core/worktree/create.ts
|
|
591
|
+
async function createWorktree(gitRoot, name, options = {}) {
|
|
592
|
+
const nameValidation = validateWorktreeName(name);
|
|
593
|
+
if (isErr(nameValidation)) {
|
|
594
|
+
return nameValidation;
|
|
595
|
+
}
|
|
596
|
+
const { branch = name, commitish = "HEAD" } = options;
|
|
597
|
+
const worktreesPath = getPhantomDirectory(gitRoot);
|
|
598
|
+
const worktreePath = getWorktreePath(gitRoot, name);
|
|
599
|
+
try {
|
|
600
|
+
await fs3.access(worktreesPath);
|
|
601
|
+
} catch {
|
|
602
|
+
await fs3.mkdir(worktreesPath, { recursive: true });
|
|
603
|
+
}
|
|
604
|
+
const validation = await validateWorktreeDoesNotExist(gitRoot, name);
|
|
605
|
+
if (validation.exists) {
|
|
606
|
+
return err(new WorktreeAlreadyExistsError(name));
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
await addWorktree({
|
|
610
|
+
path: worktreePath,
|
|
611
|
+
branch,
|
|
612
|
+
commitish
|
|
613
|
+
});
|
|
614
|
+
let copiedFiles;
|
|
615
|
+
let skippedFiles;
|
|
616
|
+
let copyError;
|
|
617
|
+
if (options.copyFiles && options.copyFiles.length > 0) {
|
|
618
|
+
const copyResult = await copyFiles(
|
|
619
|
+
gitRoot,
|
|
620
|
+
worktreePath,
|
|
621
|
+
options.copyFiles
|
|
622
|
+
);
|
|
623
|
+
if (isOk(copyResult)) {
|
|
624
|
+
copiedFiles = copyResult.value.copiedFiles;
|
|
625
|
+
skippedFiles = copyResult.value.skippedFiles;
|
|
626
|
+
} else {
|
|
627
|
+
copyError = copyResult.error.message;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return ok({
|
|
631
|
+
message: `Created worktree '${name}' at ${worktreePath}`,
|
|
632
|
+
path: worktreePath,
|
|
633
|
+
copiedFiles,
|
|
634
|
+
skippedFiles,
|
|
635
|
+
copyError
|
|
636
|
+
});
|
|
637
|
+
} catch (error) {
|
|
638
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
639
|
+
return err(new GitOperationError("worktree add", errorMessage));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
329
643
|
// src/cli/handlers/create.ts
|
|
330
644
|
async function createHandler(args2) {
|
|
331
|
-
const { values, positionals } =
|
|
645
|
+
const { values, positionals } = parseArgs2({
|
|
332
646
|
args: args2,
|
|
333
647
|
options: {
|
|
334
648
|
shell: {
|
|
@@ -338,6 +652,26 @@ async function createHandler(args2) {
|
|
|
338
652
|
exec: {
|
|
339
653
|
type: "string",
|
|
340
654
|
short: "x"
|
|
655
|
+
},
|
|
656
|
+
tmux: {
|
|
657
|
+
type: "boolean",
|
|
658
|
+
short: "t"
|
|
659
|
+
},
|
|
660
|
+
"tmux-vertical": {
|
|
661
|
+
type: "boolean"
|
|
662
|
+
},
|
|
663
|
+
"tmux-v": {
|
|
664
|
+
type: "boolean"
|
|
665
|
+
},
|
|
666
|
+
"tmux-horizontal": {
|
|
667
|
+
type: "boolean"
|
|
668
|
+
},
|
|
669
|
+
"tmux-h": {
|
|
670
|
+
type: "boolean"
|
|
671
|
+
},
|
|
672
|
+
"copy-file": {
|
|
673
|
+
type: "string",
|
|
674
|
+
multiple: true
|
|
341
675
|
}
|
|
342
676
|
},
|
|
343
677
|
strict: true,
|
|
@@ -352,20 +686,61 @@ async function createHandler(args2) {
|
|
|
352
686
|
const worktreeName = positionals[0];
|
|
353
687
|
const openShell = values.shell ?? false;
|
|
354
688
|
const execCommand = values.exec;
|
|
355
|
-
|
|
689
|
+
const copyFileOptions = values["copy-file"];
|
|
690
|
+
const tmuxOption = values.tmux || values["tmux-vertical"] || values["tmux-v"] || values["tmux-horizontal"] || values["tmux-h"];
|
|
691
|
+
let tmuxDirection;
|
|
692
|
+
if (values.tmux) {
|
|
693
|
+
tmuxDirection = "new";
|
|
694
|
+
} else if (values["tmux-vertical"] || values["tmux-v"]) {
|
|
695
|
+
tmuxDirection = "vertical";
|
|
696
|
+
} else if (values["tmux-horizontal"] || values["tmux-h"]) {
|
|
697
|
+
tmuxDirection = "horizontal";
|
|
698
|
+
}
|
|
699
|
+
if ([openShell, execCommand !== void 0, tmuxOption].filter(Boolean).length > 1) {
|
|
700
|
+
exitWithError(
|
|
701
|
+
"Cannot use --shell, --exec, and --tmux options together",
|
|
702
|
+
exitCodes.validationError
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
if (tmuxOption && !await isInsideTmux()) {
|
|
356
706
|
exitWithError(
|
|
357
|
-
"
|
|
707
|
+
"The --tmux option can only be used inside a tmux session",
|
|
358
708
|
exitCodes.validationError
|
|
359
709
|
);
|
|
360
710
|
}
|
|
361
711
|
try {
|
|
362
712
|
const gitRoot = await getGitRoot();
|
|
363
|
-
|
|
713
|
+
let filesToCopy = [];
|
|
714
|
+
const configResult = await loadConfig(gitRoot);
|
|
715
|
+
if (isOk(configResult)) {
|
|
716
|
+
if (configResult.value.postCreate?.copyFiles) {
|
|
717
|
+
filesToCopy = [...configResult.value.postCreate.copyFiles];
|
|
718
|
+
}
|
|
719
|
+
} else {
|
|
720
|
+
if (configResult.error instanceof ConfigValidationError) {
|
|
721
|
+
output.warn(`Configuration warning: ${configResult.error.message}`);
|
|
722
|
+
} else if (configResult.error instanceof ConfigParseError) {
|
|
723
|
+
output.warn(`Configuration warning: ${configResult.error.message}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (copyFileOptions && copyFileOptions.length > 0) {
|
|
727
|
+
const cliFiles = Array.isArray(copyFileOptions) ? copyFileOptions : [copyFileOptions];
|
|
728
|
+
filesToCopy = [.../* @__PURE__ */ new Set([...filesToCopy, ...cliFiles])];
|
|
729
|
+
}
|
|
730
|
+
const result = await createWorktree(gitRoot, worktreeName, {
|
|
731
|
+
copyFiles: filesToCopy.length > 0 ? filesToCopy : void 0
|
|
732
|
+
});
|
|
364
733
|
if (isErr(result)) {
|
|
365
734
|
const exitCode = result.error instanceof WorktreeAlreadyExistsError ? exitCodes.validationError : exitCodes.generalError;
|
|
366
735
|
exitWithError(result.error.message, exitCode);
|
|
367
736
|
}
|
|
368
737
|
output.log(result.value.message);
|
|
738
|
+
if (result.value.copyError) {
|
|
739
|
+
output.error(
|
|
740
|
+
`
|
|
741
|
+
Warning: Failed to copy some files: ${result.value.copyError}`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
369
744
|
if (execCommand && isOk(result)) {
|
|
370
745
|
output.log(
|
|
371
746
|
`
|
|
@@ -398,6 +773,28 @@ Entering worktree '${worktreeName}' at ${result.value.path}`
|
|
|
398
773
|
}
|
|
399
774
|
process.exit(shellResult.value.exitCode ?? 0);
|
|
400
775
|
}
|
|
776
|
+
if (tmuxDirection && isOk(result)) {
|
|
777
|
+
output.log(
|
|
778
|
+
`
|
|
779
|
+
Opening worktree '${worktreeName}' in tmux ${tmuxDirection === "new" ? "window" : "pane"}...`
|
|
780
|
+
);
|
|
781
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
782
|
+
const tmuxResult = await executeTmuxCommand({
|
|
783
|
+
direction: tmuxDirection,
|
|
784
|
+
command: shell,
|
|
785
|
+
cwd: result.value.path,
|
|
786
|
+
env: {
|
|
787
|
+
PHANTOM: "1",
|
|
788
|
+
PHANTOM_NAME: worktreeName,
|
|
789
|
+
PHANTOM_PATH: result.value.path
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
if (isErr(tmuxResult)) {
|
|
793
|
+
output.error(tmuxResult.error.message);
|
|
794
|
+
const exitCode = "exitCode" in tmuxResult.error ? tmuxResult.error.exitCode ?? exitCodes.generalError : exitCodes.generalError;
|
|
795
|
+
exitWithError("", exitCode);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
401
798
|
exitWithSuccess();
|
|
402
799
|
} catch (error) {
|
|
403
800
|
exitWithError(
|
|
@@ -408,7 +805,67 @@ Entering worktree '${worktreeName}' at ${result.value.path}`
|
|
|
408
805
|
}
|
|
409
806
|
|
|
410
807
|
// src/cli/handlers/delete.ts
|
|
411
|
-
import { parseArgs as
|
|
808
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
809
|
+
|
|
810
|
+
// src/core/git/libs/list-worktrees.ts
|
|
811
|
+
async function listWorktrees(gitRoot) {
|
|
812
|
+
const { stdout } = await executeGitCommand([
|
|
813
|
+
"worktree",
|
|
814
|
+
"list",
|
|
815
|
+
"--porcelain"
|
|
816
|
+
]);
|
|
817
|
+
const worktrees = [];
|
|
818
|
+
let currentWorktree = {};
|
|
819
|
+
const lines = stdout.split("\n").filter((line) => line.length > 0);
|
|
820
|
+
for (const line of lines) {
|
|
821
|
+
if (line.startsWith("worktree ")) {
|
|
822
|
+
if (currentWorktree.path) {
|
|
823
|
+
worktrees.push(currentWorktree);
|
|
824
|
+
}
|
|
825
|
+
currentWorktree = {
|
|
826
|
+
path: line.substring("worktree ".length),
|
|
827
|
+
isLocked: false,
|
|
828
|
+
isPrunable: false
|
|
829
|
+
};
|
|
830
|
+
} else if (line.startsWith("HEAD ")) {
|
|
831
|
+
currentWorktree.head = line.substring("HEAD ".length);
|
|
832
|
+
} else if (line.startsWith("branch ")) {
|
|
833
|
+
const fullBranch = line.substring("branch ".length);
|
|
834
|
+
currentWorktree.branch = fullBranch.startsWith("refs/heads/") ? fullBranch.substring("refs/heads/".length) : fullBranch;
|
|
835
|
+
} else if (line === "detached") {
|
|
836
|
+
currentWorktree.branch = "(detached HEAD)";
|
|
837
|
+
} else if (line === "locked") {
|
|
838
|
+
currentWorktree.isLocked = true;
|
|
839
|
+
} else if (line === "prunable") {
|
|
840
|
+
currentWorktree.isPrunable = true;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (currentWorktree.path) {
|
|
844
|
+
worktrees.push(currentWorktree);
|
|
845
|
+
}
|
|
846
|
+
return worktrees;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/core/git/libs/get-current-worktree.ts
|
|
850
|
+
async function getCurrentWorktree(gitRoot) {
|
|
851
|
+
try {
|
|
852
|
+
const { stdout: currentPath } = await executeGitCommand([
|
|
853
|
+
"rev-parse",
|
|
854
|
+
"--show-toplevel"
|
|
855
|
+
]);
|
|
856
|
+
const currentPathTrimmed = currentPath.trim();
|
|
857
|
+
const worktrees = await listWorktrees(gitRoot);
|
|
858
|
+
const currentWorktree = worktrees.find(
|
|
859
|
+
(wt) => wt.path === currentPathTrimmed
|
|
860
|
+
);
|
|
861
|
+
if (!currentWorktree || currentWorktree.path === gitRoot) {
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
return currentWorktree.branch;
|
|
865
|
+
} catch {
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
412
869
|
|
|
413
870
|
// src/core/worktree/delete.ts
|
|
414
871
|
async function getWorktreeStatus(worktreePath) {
|
|
@@ -498,27 +955,49 @@ ${message}`;
|
|
|
498
955
|
|
|
499
956
|
// src/cli/handlers/delete.ts
|
|
500
957
|
async function deleteHandler(args2) {
|
|
501
|
-
const { values, positionals } =
|
|
958
|
+
const { values, positionals } = parseArgs3({
|
|
502
959
|
args: args2,
|
|
503
960
|
options: {
|
|
504
961
|
force: {
|
|
505
962
|
type: "boolean",
|
|
506
963
|
short: "f"
|
|
964
|
+
},
|
|
965
|
+
current: {
|
|
966
|
+
type: "boolean"
|
|
507
967
|
}
|
|
508
968
|
},
|
|
509
969
|
strict: true,
|
|
510
970
|
allowPositionals: true
|
|
511
971
|
});
|
|
512
|
-
|
|
972
|
+
const deleteCurrent = values.current ?? false;
|
|
973
|
+
if (positionals.length === 0 && !deleteCurrent) {
|
|
513
974
|
exitWithError(
|
|
514
|
-
"Please provide a worktree name to delete",
|
|
975
|
+
"Please provide a worktree name to delete or use --current to delete the current worktree",
|
|
976
|
+
exitCodes.validationError
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
if (positionals.length > 0 && deleteCurrent) {
|
|
980
|
+
exitWithError(
|
|
981
|
+
"Cannot specify both a worktree name and --current option",
|
|
515
982
|
exitCodes.validationError
|
|
516
983
|
);
|
|
517
984
|
}
|
|
518
|
-
const worktreeName = positionals[0];
|
|
519
985
|
const forceDelete = values.force ?? false;
|
|
520
986
|
try {
|
|
521
987
|
const gitRoot = await getGitRoot();
|
|
988
|
+
let worktreeName;
|
|
989
|
+
if (deleteCurrent) {
|
|
990
|
+
const currentWorktree = await getCurrentWorktree(gitRoot);
|
|
991
|
+
if (!currentWorktree) {
|
|
992
|
+
exitWithError(
|
|
993
|
+
"Not in a worktree directory. The --current option can only be used from within a worktree.",
|
|
994
|
+
exitCodes.validationError
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
worktreeName = currentWorktree;
|
|
998
|
+
} else {
|
|
999
|
+
worktreeName = positionals[0];
|
|
1000
|
+
}
|
|
522
1001
|
const result = await deleteWorktree(gitRoot, worktreeName, {
|
|
523
1002
|
force: forceDelete
|
|
524
1003
|
});
|
|
@@ -537,9 +1016,9 @@ async function deleteHandler(args2) {
|
|
|
537
1016
|
}
|
|
538
1017
|
|
|
539
1018
|
// src/cli/handlers/exec.ts
|
|
540
|
-
import { parseArgs as
|
|
1019
|
+
import { parseArgs as parseArgs4 } from "node:util";
|
|
541
1020
|
async function execHandler(args2) {
|
|
542
|
-
const { positionals } =
|
|
1021
|
+
const { positionals } = parseArgs4({
|
|
543
1022
|
args: args2,
|
|
544
1023
|
options: {},
|
|
545
1024
|
strict: true,
|
|
@@ -569,20 +1048,9 @@ async function execHandler(args2) {
|
|
|
569
1048
|
}
|
|
570
1049
|
|
|
571
1050
|
// src/cli/handlers/list.ts
|
|
572
|
-
import { parseArgs as
|
|
1051
|
+
import { parseArgs as parseArgs5 } from "node:util";
|
|
573
1052
|
|
|
574
1053
|
// src/core/worktree/list.ts
|
|
575
|
-
async function getWorktreeBranch(worktreePath) {
|
|
576
|
-
try {
|
|
577
|
-
const { stdout } = await executeGitCommandInDirectory(worktreePath, [
|
|
578
|
-
"branch",
|
|
579
|
-
"--show-current"
|
|
580
|
-
]);
|
|
581
|
-
return stdout || "(detached HEAD)";
|
|
582
|
-
} catch {
|
|
583
|
-
return "unknown";
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
1054
|
async function getWorktreeStatus2(worktreePath) {
|
|
587
1055
|
try {
|
|
588
1056
|
const { stdout } = await executeGitCommandInDirectory(worktreePath, [
|
|
@@ -594,36 +1062,30 @@ async function getWorktreeStatus2(worktreePath) {
|
|
|
594
1062
|
return true;
|
|
595
1063
|
}
|
|
596
1064
|
}
|
|
597
|
-
async function
|
|
598
|
-
const worktreePath = getWorktreePath(gitRoot, name);
|
|
599
|
-
const [branch, isClean] = await Promise.all([
|
|
600
|
-
getWorktreeBranch(worktreePath),
|
|
601
|
-
getWorktreeStatus2(worktreePath)
|
|
602
|
-
]);
|
|
603
|
-
return {
|
|
604
|
-
name,
|
|
605
|
-
path: worktreePath,
|
|
606
|
-
branch,
|
|
607
|
-
isClean
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
async function listWorktrees(gitRoot) {
|
|
611
|
-
if (!await validatePhantomDirectoryExists(gitRoot)) {
|
|
612
|
-
return ok({
|
|
613
|
-
worktrees: [],
|
|
614
|
-
message: "No worktrees found (worktrees directory doesn't exist)"
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
const worktreeNames = await listValidWorktrees(gitRoot);
|
|
618
|
-
if (worktreeNames.length === 0) {
|
|
619
|
-
return ok({
|
|
620
|
-
worktrees: [],
|
|
621
|
-
message: "No worktrees found"
|
|
622
|
-
});
|
|
623
|
-
}
|
|
1065
|
+
async function listWorktrees2(gitRoot) {
|
|
624
1066
|
try {
|
|
1067
|
+
const gitWorktrees = await listWorktrees(gitRoot);
|
|
1068
|
+
const phantomDir = getPhantomDirectory(gitRoot);
|
|
1069
|
+
const phantomWorktrees = gitWorktrees.filter(
|
|
1070
|
+
(worktree) => worktree.path.startsWith(phantomDir)
|
|
1071
|
+
);
|
|
1072
|
+
if (phantomWorktrees.length === 0) {
|
|
1073
|
+
return ok({
|
|
1074
|
+
worktrees: [],
|
|
1075
|
+
message: "No worktrees found"
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
625
1078
|
const worktrees = await Promise.all(
|
|
626
|
-
|
|
1079
|
+
phantomWorktrees.map(async (gitWorktree) => {
|
|
1080
|
+
const name = gitWorktree.path.substring(phantomDir.length + 1);
|
|
1081
|
+
const isClean = await getWorktreeStatus2(gitWorktree.path);
|
|
1082
|
+
return {
|
|
1083
|
+
name,
|
|
1084
|
+
path: gitWorktree.path,
|
|
1085
|
+
branch: gitWorktree.branch || "(detached HEAD)",
|
|
1086
|
+
isClean
|
|
1087
|
+
};
|
|
1088
|
+
})
|
|
627
1089
|
);
|
|
628
1090
|
return ok({
|
|
629
1091
|
worktrees
|
|
@@ -636,7 +1098,7 @@ async function listWorktrees(gitRoot) {
|
|
|
636
1098
|
|
|
637
1099
|
// src/cli/handlers/list.ts
|
|
638
1100
|
async function listHandler(args2 = []) {
|
|
639
|
-
|
|
1101
|
+
parseArgs5({
|
|
640
1102
|
args: args2,
|
|
641
1103
|
options: {},
|
|
642
1104
|
strict: true,
|
|
@@ -644,7 +1106,7 @@ async function listHandler(args2 = []) {
|
|
|
644
1106
|
});
|
|
645
1107
|
try {
|
|
646
1108
|
const gitRoot = await getGitRoot();
|
|
647
|
-
const result = await
|
|
1109
|
+
const result = await listWorktrees2(gitRoot);
|
|
648
1110
|
if (isErr(result)) {
|
|
649
1111
|
exitWithError("Failed to list worktrees", exitCodes.generalError);
|
|
650
1112
|
}
|
|
@@ -670,9 +1132,9 @@ async function listHandler(args2 = []) {
|
|
|
670
1132
|
}
|
|
671
1133
|
|
|
672
1134
|
// src/cli/handlers/shell.ts
|
|
673
|
-
import { parseArgs as
|
|
1135
|
+
import { parseArgs as parseArgs6 } from "node:util";
|
|
674
1136
|
async function shellHandler(args2) {
|
|
675
|
-
const { positionals } =
|
|
1137
|
+
const { positionals } = parseArgs6({
|
|
676
1138
|
args: args2,
|
|
677
1139
|
options: {},
|
|
678
1140
|
strict: true,
|
|
@@ -711,13 +1173,13 @@ async function shellHandler(args2) {
|
|
|
711
1173
|
}
|
|
712
1174
|
|
|
713
1175
|
// src/cli/handlers/version.ts
|
|
714
|
-
import { parseArgs as
|
|
1176
|
+
import { parseArgs as parseArgs7 } from "node:util";
|
|
715
1177
|
|
|
716
1178
|
// package.json
|
|
717
1179
|
var package_default = {
|
|
718
1180
|
name: "@aku11i/phantom",
|
|
719
1181
|
packageManager: "pnpm@10.8.1",
|
|
720
|
-
version: "0.
|
|
1182
|
+
version: "0.6.0",
|
|
721
1183
|
description: "A powerful CLI tool for managing Git worktrees for parallel development",
|
|
722
1184
|
keywords: [
|
|
723
1185
|
"git",
|
|
@@ -746,12 +1208,14 @@ var package_default = {
|
|
|
746
1208
|
start: "node ./src/bin/phantom.ts",
|
|
747
1209
|
phantom: "node ./src/bin/phantom.ts",
|
|
748
1210
|
build: "node build.ts",
|
|
749
|
-
|
|
750
|
-
test:
|
|
1211
|
+
typecheck: "tsgo --noEmit",
|
|
1212
|
+
test: 'node --test --experimental-strip-types --experimental-test-module-mocks "src/**/*.test.js"',
|
|
1213
|
+
"test:coverage": 'node --experimental-test-coverage --test --experimental-strip-types --experimental-test-module-mocks "src/**/*.test.js"',
|
|
1214
|
+
"test:file": "node --test --experimental-strip-types --experimental-test-module-mocks",
|
|
751
1215
|
lint: "biome check .",
|
|
752
1216
|
fix: "biome check --write .",
|
|
753
|
-
ready: "pnpm fix && pnpm
|
|
754
|
-
"ready:check": "pnpm lint && pnpm
|
|
1217
|
+
ready: "pnpm fix && pnpm typecheck && pnpm test",
|
|
1218
|
+
"ready:check": "pnpm lint && pnpm typecheck && pnpm test",
|
|
755
1219
|
prepublishOnly: "pnpm ready:check && pnpm build"
|
|
756
1220
|
},
|
|
757
1221
|
engines: {
|
|
@@ -778,7 +1242,7 @@ function getVersion() {
|
|
|
778
1242
|
|
|
779
1243
|
// src/cli/handlers/version.ts
|
|
780
1244
|
function versionHandler(args2 = []) {
|
|
781
|
-
|
|
1245
|
+
parseArgs7({
|
|
782
1246
|
args: args2,
|
|
783
1247
|
options: {},
|
|
784
1248
|
strict: true,
|
|
@@ -790,7 +1254,7 @@ function versionHandler(args2 = []) {
|
|
|
790
1254
|
}
|
|
791
1255
|
|
|
792
1256
|
// src/cli/handlers/where.ts
|
|
793
|
-
import { parseArgs as
|
|
1257
|
+
import { parseArgs as parseArgs8 } from "node:util";
|
|
794
1258
|
|
|
795
1259
|
// src/core/worktree/where.ts
|
|
796
1260
|
async function whereWorktree(gitRoot, name) {
|
|
@@ -805,7 +1269,7 @@ async function whereWorktree(gitRoot, name) {
|
|
|
805
1269
|
|
|
806
1270
|
// src/cli/handlers/where.ts
|
|
807
1271
|
async function whereHandler(args2) {
|
|
808
|
-
const { positionals } =
|
|
1272
|
+
const { positionals } = parseArgs8({
|
|
809
1273
|
args: args2,
|
|
810
1274
|
options: {},
|
|
811
1275
|
strict: true,
|
|
@@ -835,9 +1299,14 @@ async function whereHandler(args2) {
|
|
|
835
1299
|
var commands = [
|
|
836
1300
|
{
|
|
837
1301
|
name: "create",
|
|
838
|
-
description: "Create a new worktree [--shell | --exec <command>]",
|
|
1302
|
+
description: "Create a new worktree [--shell | --exec <command> | --tmux | --tmux-vertical | --tmux-horizontal] [--copy-file <file>]...",
|
|
839
1303
|
handler: createHandler
|
|
840
1304
|
},
|
|
1305
|
+
{
|
|
1306
|
+
name: "attach",
|
|
1307
|
+
description: "Attach to an existing branch [--shell | --exec <command>]",
|
|
1308
|
+
handler: attachHandler
|
|
1309
|
+
},
|
|
841
1310
|
{
|
|
842
1311
|
name: "list",
|
|
843
1312
|
description: "List all worktrees",
|