vde-worktree 0.0.18 → 0.0.20
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 +36 -10
- package/README.md +36 -12
- package/completions/fish/vw.fish +26 -7
- package/completions/zsh/_vw +29 -8
- package/dist/index.mjs +1026 -264
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
|
-
import { access, appendFile, chmod, cp, mkdir, open, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { access, appendFile, chmod, cp, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir, hostname } from "node:os";
|
|
6
6
|
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,6 +10,7 @@ import { parseArgs } from "citty";
|
|
|
10
10
|
import { execa } from "execa";
|
|
11
11
|
import stringWidth from "string-width";
|
|
12
12
|
import { getBorderCharacters, table } from "table";
|
|
13
|
+
import { parse } from "yaml";
|
|
13
14
|
import { createHash } from "node:crypto";
|
|
14
15
|
|
|
15
16
|
//#region src/core/constants.ts
|
|
@@ -74,6 +75,7 @@ const WRITE_COMMANDS = new Set([
|
|
|
74
75
|
const ERROR_CODE_TO_EXIT_CODE = {
|
|
75
76
|
NOT_GIT_REPOSITORY: EXIT_CODE.NOT_GIT_REPOSITORY,
|
|
76
77
|
INVALID_ARGUMENT: EXIT_CODE.INVALID_ARGUMENT,
|
|
78
|
+
INVALID_CONFIG: EXIT_CODE.INVALID_ARGUMENT,
|
|
77
79
|
UNKNOWN_COMMAND: EXIT_CODE.INVALID_ARGUMENT,
|
|
78
80
|
UNSAFE_FLAG_REQUIRED: EXIT_CODE.SAFETY_REJECTED,
|
|
79
81
|
NOT_INITIALIZED: EXIT_CODE.SAFETY_REJECTED,
|
|
@@ -130,6 +132,691 @@ const ensureCliError = (error) => {
|
|
|
130
132
|
});
|
|
131
133
|
};
|
|
132
134
|
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/config/git-boundary.ts
|
|
137
|
+
const hasGitMarker = async (directory) => {
|
|
138
|
+
try {
|
|
139
|
+
const stat = await lstat(join(directory, ".git"));
|
|
140
|
+
return stat.isDirectory() || stat.isFile();
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const findGitBoundaryDirectory = async (cwd) => {
|
|
146
|
+
let current = resolve(cwd);
|
|
147
|
+
while (true) {
|
|
148
|
+
if (await hasGitMarker(current)) return current;
|
|
149
|
+
const parent = dirname(current);
|
|
150
|
+
if (parent === current) return null;
|
|
151
|
+
current = parent;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const collectConfigSearchDirectories = async (cwd) => {
|
|
155
|
+
const absoluteCwd = resolve(cwd);
|
|
156
|
+
const boundary = await findGitBoundaryDirectory(absoluteCwd);
|
|
157
|
+
if (boundary === null) return [absoluteCwd];
|
|
158
|
+
const directories = [];
|
|
159
|
+
let current = absoluteCwd;
|
|
160
|
+
while (true) {
|
|
161
|
+
directories.push(current);
|
|
162
|
+
if (current === boundary) break;
|
|
163
|
+
const parent = dirname(current);
|
|
164
|
+
if (parent === current) break;
|
|
165
|
+
current = parent;
|
|
166
|
+
}
|
|
167
|
+
return directories.reverse();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region src/config/types.ts
|
|
172
|
+
const LIST_TABLE_COLUMNS = [
|
|
173
|
+
"branch",
|
|
174
|
+
"dirty",
|
|
175
|
+
"merged",
|
|
176
|
+
"pr",
|
|
177
|
+
"locked",
|
|
178
|
+
"ahead",
|
|
179
|
+
"behind",
|
|
180
|
+
"path"
|
|
181
|
+
];
|
|
182
|
+
const LIST_PATH_TRUNCATE_VALUES = ["auto", "never"];
|
|
183
|
+
const SELECTOR_CD_SURFACE_VALUES = [
|
|
184
|
+
"auto",
|
|
185
|
+
"inline",
|
|
186
|
+
"tmux-popup"
|
|
187
|
+
];
|
|
188
|
+
const DEFAULT_CONFIG = {
|
|
189
|
+
paths: { worktreeRoot: ".worktree" },
|
|
190
|
+
git: {
|
|
191
|
+
baseBranch: null,
|
|
192
|
+
baseRemote: "origin"
|
|
193
|
+
},
|
|
194
|
+
github: { enabled: true },
|
|
195
|
+
hooks: {
|
|
196
|
+
enabled: true,
|
|
197
|
+
timeoutMs: DEFAULT_HOOK_TIMEOUT_MS
|
|
198
|
+
},
|
|
199
|
+
locks: {
|
|
200
|
+
timeoutMs: DEFAULT_LOCK_TIMEOUT_MS,
|
|
201
|
+
staleLockTTLSeconds: DEFAULT_STALE_LOCK_TTL_SECONDS
|
|
202
|
+
},
|
|
203
|
+
list: { table: {
|
|
204
|
+
columns: [...LIST_TABLE_COLUMNS],
|
|
205
|
+
path: {
|
|
206
|
+
truncate: "auto",
|
|
207
|
+
minWidth: 12
|
|
208
|
+
}
|
|
209
|
+
} },
|
|
210
|
+
selector: { cd: {
|
|
211
|
+
prompt: "worktree> ",
|
|
212
|
+
surface: "auto",
|
|
213
|
+
tmuxPopupOpts: "80%,70%",
|
|
214
|
+
fzf: { extraArgs: [] }
|
|
215
|
+
} }
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/config/loader.ts
|
|
220
|
+
const CONFIG_FILE_BASENAME = "config.yml";
|
|
221
|
+
const LOCAL_CONFIG_PATH_SEGMENTS = [
|
|
222
|
+
".vde",
|
|
223
|
+
"worktree",
|
|
224
|
+
CONFIG_FILE_BASENAME
|
|
225
|
+
];
|
|
226
|
+
const GLOBAL_CONFIG_PATH_SEGMENTS = [
|
|
227
|
+
"vde",
|
|
228
|
+
"worktree",
|
|
229
|
+
CONFIG_FILE_BASENAME
|
|
230
|
+
];
|
|
231
|
+
const isRecord = (value) => {
|
|
232
|
+
return value !== null && typeof value === "object" && Array.isArray(value) !== true;
|
|
233
|
+
};
|
|
234
|
+
const toKeyPath = (segments) => {
|
|
235
|
+
if (segments.length === 0) return "<root>";
|
|
236
|
+
return segments.join(".");
|
|
237
|
+
};
|
|
238
|
+
const throwInvalidConfig = ({ file, keyPath, reason }) => {
|
|
239
|
+
throw createCliError("INVALID_CONFIG", {
|
|
240
|
+
message: `Invalid config: ${file} (${keyPath}: ${reason})`,
|
|
241
|
+
details: {
|
|
242
|
+
file,
|
|
243
|
+
keyPath,
|
|
244
|
+
reason
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
const expectRecord = ({ value, ctx, keyPath }) => {
|
|
249
|
+
if (isRecord(value)) return value;
|
|
250
|
+
return throwInvalidConfig({
|
|
251
|
+
file: ctx.file,
|
|
252
|
+
keyPath: toKeyPath(keyPath),
|
|
253
|
+
reason: "must be an object"
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
const ensureNoUnknownKeys = ({ record, allowedKeys, ctx, keyPath }) => {
|
|
257
|
+
const allowed = new Set(allowedKeys);
|
|
258
|
+
for (const key of Object.keys(record)) {
|
|
259
|
+
if (allowed.has(key)) continue;
|
|
260
|
+
const path = [...keyPath, key];
|
|
261
|
+
throwInvalidConfig({
|
|
262
|
+
file: ctx.file,
|
|
263
|
+
keyPath: toKeyPath(path),
|
|
264
|
+
reason: "unknown key"
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const parseBoolean = ({ value, ctx, keyPath }) => {
|
|
269
|
+
if (typeof value === "boolean") return value;
|
|
270
|
+
return throwInvalidConfig({
|
|
271
|
+
file: ctx.file,
|
|
272
|
+
keyPath: toKeyPath(keyPath),
|
|
273
|
+
reason: "must be boolean"
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
const parseNonEmptyString = ({ value, ctx, keyPath }) => {
|
|
277
|
+
if (typeof value === "string" && value.trim().length > 0) return value;
|
|
278
|
+
return throwInvalidConfig({
|
|
279
|
+
file: ctx.file,
|
|
280
|
+
keyPath: toKeyPath(keyPath),
|
|
281
|
+
reason: "must be a non-empty string"
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
const parsePositiveInteger = ({ value, ctx, keyPath }) => {
|
|
285
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
|
286
|
+
return throwInvalidConfig({
|
|
287
|
+
file: ctx.file,
|
|
288
|
+
keyPath: toKeyPath(keyPath),
|
|
289
|
+
reason: "must be a positive integer"
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
const parseStringArray = ({ value, ctx, keyPath }) => {
|
|
293
|
+
if (Array.isArray(value) !== true) return throwInvalidConfig({
|
|
294
|
+
file: ctx.file,
|
|
295
|
+
keyPath: toKeyPath(keyPath),
|
|
296
|
+
reason: "must be an array"
|
|
297
|
+
});
|
|
298
|
+
const values = value;
|
|
299
|
+
const result = [];
|
|
300
|
+
for (const [index, item] of values.entries()) {
|
|
301
|
+
if (typeof item !== "string" || item.length === 0) throwInvalidConfig({
|
|
302
|
+
file: ctx.file,
|
|
303
|
+
keyPath: toKeyPath([...keyPath, String(index)]),
|
|
304
|
+
reason: "must be a non-empty string"
|
|
305
|
+
});
|
|
306
|
+
result.push(item);
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
};
|
|
310
|
+
const parseColumns = ({ value, ctx, keyPath }) => {
|
|
311
|
+
if (Array.isArray(value) !== true) return throwInvalidConfig({
|
|
312
|
+
file: ctx.file,
|
|
313
|
+
keyPath: toKeyPath(keyPath),
|
|
314
|
+
reason: "must be an array"
|
|
315
|
+
});
|
|
316
|
+
const values = value;
|
|
317
|
+
if (values.length === 0) return throwInvalidConfig({
|
|
318
|
+
file: ctx.file,
|
|
319
|
+
keyPath: toKeyPath(keyPath),
|
|
320
|
+
reason: "must not be empty"
|
|
321
|
+
});
|
|
322
|
+
const allowed = new Set(LIST_TABLE_COLUMNS);
|
|
323
|
+
const seen = /* @__PURE__ */ new Set();
|
|
324
|
+
const parsed = [];
|
|
325
|
+
for (const [index, item] of values.entries()) {
|
|
326
|
+
if (typeof item !== "string") throwInvalidConfig({
|
|
327
|
+
file: ctx.file,
|
|
328
|
+
keyPath: toKeyPath([...keyPath, String(index)]),
|
|
329
|
+
reason: "must be a string"
|
|
330
|
+
});
|
|
331
|
+
if (allowed.has(item) !== true) throwInvalidConfig({
|
|
332
|
+
file: ctx.file,
|
|
333
|
+
keyPath: toKeyPath([...keyPath, String(index)]),
|
|
334
|
+
reason: `unsupported column: ${item}`
|
|
335
|
+
});
|
|
336
|
+
if (seen.has(item)) throwInvalidConfig({
|
|
337
|
+
file: ctx.file,
|
|
338
|
+
keyPath: toKeyPath([...keyPath, String(index)]),
|
|
339
|
+
reason: `duplicate column: ${item}`
|
|
340
|
+
});
|
|
341
|
+
seen.add(item);
|
|
342
|
+
parsed.push(item);
|
|
343
|
+
}
|
|
344
|
+
return parsed;
|
|
345
|
+
};
|
|
346
|
+
const parseListPathTruncate = ({ value, ctx, keyPath }) => {
|
|
347
|
+
if (typeof value !== "string" || LIST_PATH_TRUNCATE_VALUES.includes(value) !== true) throwInvalidConfig({
|
|
348
|
+
file: ctx.file,
|
|
349
|
+
keyPath: toKeyPath(keyPath),
|
|
350
|
+
reason: `must be one of: ${LIST_PATH_TRUNCATE_VALUES.join(", ")}`
|
|
351
|
+
});
|
|
352
|
+
return value;
|
|
353
|
+
};
|
|
354
|
+
const parseSelectorSurface = ({ value, ctx, keyPath }) => {
|
|
355
|
+
if (typeof value !== "string" || SELECTOR_CD_SURFACE_VALUES.includes(value) !== true) throwInvalidConfig({
|
|
356
|
+
file: ctx.file,
|
|
357
|
+
keyPath: toKeyPath(keyPath),
|
|
358
|
+
reason: `must be one of: ${SELECTOR_CD_SURFACE_VALUES.join(", ")}`
|
|
359
|
+
});
|
|
360
|
+
return value;
|
|
361
|
+
};
|
|
362
|
+
const validatePartialConfig = ({ rawConfig, ctx }) => {
|
|
363
|
+
if (rawConfig === null || rawConfig === void 0) return {};
|
|
364
|
+
const root = expectRecord({
|
|
365
|
+
value: rawConfig,
|
|
366
|
+
ctx,
|
|
367
|
+
keyPath: []
|
|
368
|
+
});
|
|
369
|
+
ensureNoUnknownKeys({
|
|
370
|
+
record: root,
|
|
371
|
+
allowedKeys: [
|
|
372
|
+
"paths",
|
|
373
|
+
"git",
|
|
374
|
+
"github",
|
|
375
|
+
"hooks",
|
|
376
|
+
"locks",
|
|
377
|
+
"list",
|
|
378
|
+
"selector"
|
|
379
|
+
],
|
|
380
|
+
ctx,
|
|
381
|
+
keyPath: []
|
|
382
|
+
});
|
|
383
|
+
const partial = {};
|
|
384
|
+
if (root.paths !== void 0) {
|
|
385
|
+
const paths = expectRecord({
|
|
386
|
+
value: root.paths,
|
|
387
|
+
ctx,
|
|
388
|
+
keyPath: ["paths"]
|
|
389
|
+
});
|
|
390
|
+
ensureNoUnknownKeys({
|
|
391
|
+
record: paths,
|
|
392
|
+
allowedKeys: ["worktreeRoot"],
|
|
393
|
+
ctx,
|
|
394
|
+
keyPath: ["paths"]
|
|
395
|
+
});
|
|
396
|
+
partial.paths = {};
|
|
397
|
+
if (paths.worktreeRoot !== void 0) partial.paths.worktreeRoot = parseNonEmptyString({
|
|
398
|
+
value: paths.worktreeRoot,
|
|
399
|
+
ctx,
|
|
400
|
+
keyPath: ["paths", "worktreeRoot"]
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (root.git !== void 0) {
|
|
404
|
+
const git = expectRecord({
|
|
405
|
+
value: root.git,
|
|
406
|
+
ctx,
|
|
407
|
+
keyPath: ["git"]
|
|
408
|
+
});
|
|
409
|
+
ensureNoUnknownKeys({
|
|
410
|
+
record: git,
|
|
411
|
+
allowedKeys: ["baseBranch", "baseRemote"],
|
|
412
|
+
ctx,
|
|
413
|
+
keyPath: ["git"]
|
|
414
|
+
});
|
|
415
|
+
partial.git = {};
|
|
416
|
+
if (git.baseBranch !== void 0) {
|
|
417
|
+
if (git.baseBranch !== null && typeof git.baseBranch !== "string") throwInvalidConfig({
|
|
418
|
+
file: ctx.file,
|
|
419
|
+
keyPath: toKeyPath(["git", "baseBranch"]),
|
|
420
|
+
reason: "must be a string or null"
|
|
421
|
+
});
|
|
422
|
+
partial.git.baseBranch = git.baseBranch === null ? null : parseNonEmptyString({
|
|
423
|
+
value: git.baseBranch,
|
|
424
|
+
ctx,
|
|
425
|
+
keyPath: ["git", "baseBranch"]
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
if (git.baseRemote !== void 0) partial.git.baseRemote = parseNonEmptyString({
|
|
429
|
+
value: git.baseRemote,
|
|
430
|
+
ctx,
|
|
431
|
+
keyPath: ["git", "baseRemote"]
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (root.github !== void 0) {
|
|
435
|
+
const github = expectRecord({
|
|
436
|
+
value: root.github,
|
|
437
|
+
ctx,
|
|
438
|
+
keyPath: ["github"]
|
|
439
|
+
});
|
|
440
|
+
ensureNoUnknownKeys({
|
|
441
|
+
record: github,
|
|
442
|
+
allowedKeys: ["enabled"],
|
|
443
|
+
ctx,
|
|
444
|
+
keyPath: ["github"]
|
|
445
|
+
});
|
|
446
|
+
partial.github = {};
|
|
447
|
+
if (github.enabled !== void 0) partial.github.enabled = parseBoolean({
|
|
448
|
+
value: github.enabled,
|
|
449
|
+
ctx,
|
|
450
|
+
keyPath: ["github", "enabled"]
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
if (root.hooks !== void 0) {
|
|
454
|
+
const hooks = expectRecord({
|
|
455
|
+
value: root.hooks,
|
|
456
|
+
ctx,
|
|
457
|
+
keyPath: ["hooks"]
|
|
458
|
+
});
|
|
459
|
+
ensureNoUnknownKeys({
|
|
460
|
+
record: hooks,
|
|
461
|
+
allowedKeys: ["enabled", "timeoutMs"],
|
|
462
|
+
ctx,
|
|
463
|
+
keyPath: ["hooks"]
|
|
464
|
+
});
|
|
465
|
+
partial.hooks = {};
|
|
466
|
+
if (hooks.enabled !== void 0) partial.hooks.enabled = parseBoolean({
|
|
467
|
+
value: hooks.enabled,
|
|
468
|
+
ctx,
|
|
469
|
+
keyPath: ["hooks", "enabled"]
|
|
470
|
+
});
|
|
471
|
+
if (hooks.timeoutMs !== void 0) partial.hooks.timeoutMs = parsePositiveInteger({
|
|
472
|
+
value: hooks.timeoutMs,
|
|
473
|
+
ctx,
|
|
474
|
+
keyPath: ["hooks", "timeoutMs"]
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (root.locks !== void 0) {
|
|
478
|
+
const locks = expectRecord({
|
|
479
|
+
value: root.locks,
|
|
480
|
+
ctx,
|
|
481
|
+
keyPath: ["locks"]
|
|
482
|
+
});
|
|
483
|
+
ensureNoUnknownKeys({
|
|
484
|
+
record: locks,
|
|
485
|
+
allowedKeys: ["timeoutMs", "staleLockTTLSeconds"],
|
|
486
|
+
ctx,
|
|
487
|
+
keyPath: ["locks"]
|
|
488
|
+
});
|
|
489
|
+
partial.locks = {};
|
|
490
|
+
if (locks.timeoutMs !== void 0) partial.locks.timeoutMs = parsePositiveInteger({
|
|
491
|
+
value: locks.timeoutMs,
|
|
492
|
+
ctx,
|
|
493
|
+
keyPath: ["locks", "timeoutMs"]
|
|
494
|
+
});
|
|
495
|
+
if (locks.staleLockTTLSeconds !== void 0) partial.locks.staleLockTTLSeconds = parsePositiveInteger({
|
|
496
|
+
value: locks.staleLockTTLSeconds,
|
|
497
|
+
ctx,
|
|
498
|
+
keyPath: ["locks", "staleLockTTLSeconds"]
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
if (root.list !== void 0) {
|
|
502
|
+
const list = expectRecord({
|
|
503
|
+
value: root.list,
|
|
504
|
+
ctx,
|
|
505
|
+
keyPath: ["list"]
|
|
506
|
+
});
|
|
507
|
+
ensureNoUnknownKeys({
|
|
508
|
+
record: list,
|
|
509
|
+
allowedKeys: ["table"],
|
|
510
|
+
ctx,
|
|
511
|
+
keyPath: ["list"]
|
|
512
|
+
});
|
|
513
|
+
partial.list = {};
|
|
514
|
+
if (list.table !== void 0) {
|
|
515
|
+
const table = expectRecord({
|
|
516
|
+
value: list.table,
|
|
517
|
+
ctx,
|
|
518
|
+
keyPath: ["list", "table"]
|
|
519
|
+
});
|
|
520
|
+
ensureNoUnknownKeys({
|
|
521
|
+
record: table,
|
|
522
|
+
allowedKeys: ["columns", "path"],
|
|
523
|
+
ctx,
|
|
524
|
+
keyPath: ["list", "table"]
|
|
525
|
+
});
|
|
526
|
+
partial.list.table = {};
|
|
527
|
+
if (table.columns !== void 0) partial.list.table.columns = parseColumns({
|
|
528
|
+
value: table.columns,
|
|
529
|
+
ctx,
|
|
530
|
+
keyPath: [
|
|
531
|
+
"list",
|
|
532
|
+
"table",
|
|
533
|
+
"columns"
|
|
534
|
+
]
|
|
535
|
+
});
|
|
536
|
+
if (table.path !== void 0) {
|
|
537
|
+
const pathConfig = expectRecord({
|
|
538
|
+
value: table.path,
|
|
539
|
+
ctx,
|
|
540
|
+
keyPath: [
|
|
541
|
+
"list",
|
|
542
|
+
"table",
|
|
543
|
+
"path"
|
|
544
|
+
]
|
|
545
|
+
});
|
|
546
|
+
ensureNoUnknownKeys({
|
|
547
|
+
record: pathConfig,
|
|
548
|
+
allowedKeys: ["truncate", "minWidth"],
|
|
549
|
+
ctx,
|
|
550
|
+
keyPath: [
|
|
551
|
+
"list",
|
|
552
|
+
"table",
|
|
553
|
+
"path"
|
|
554
|
+
]
|
|
555
|
+
});
|
|
556
|
+
partial.list.table.path = {};
|
|
557
|
+
if (pathConfig.truncate !== void 0) partial.list.table.path.truncate = parseListPathTruncate({
|
|
558
|
+
value: pathConfig.truncate,
|
|
559
|
+
ctx,
|
|
560
|
+
keyPath: [
|
|
561
|
+
"list",
|
|
562
|
+
"table",
|
|
563
|
+
"path",
|
|
564
|
+
"truncate"
|
|
565
|
+
]
|
|
566
|
+
});
|
|
567
|
+
if (pathConfig.minWidth !== void 0) {
|
|
568
|
+
const minWidth = parsePositiveInteger({
|
|
569
|
+
value: pathConfig.minWidth,
|
|
570
|
+
ctx,
|
|
571
|
+
keyPath: [
|
|
572
|
+
"list",
|
|
573
|
+
"table",
|
|
574
|
+
"path",
|
|
575
|
+
"minWidth"
|
|
576
|
+
]
|
|
577
|
+
});
|
|
578
|
+
if (minWidth < 8 || minWidth > 200) throwInvalidConfig({
|
|
579
|
+
file: ctx.file,
|
|
580
|
+
keyPath: toKeyPath([
|
|
581
|
+
"list",
|
|
582
|
+
"table",
|
|
583
|
+
"path",
|
|
584
|
+
"minWidth"
|
|
585
|
+
]),
|
|
586
|
+
reason: "must be in range 8..200"
|
|
587
|
+
});
|
|
588
|
+
partial.list.table.path.minWidth = minWidth;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (root.selector !== void 0) {
|
|
594
|
+
const selector = expectRecord({
|
|
595
|
+
value: root.selector,
|
|
596
|
+
ctx,
|
|
597
|
+
keyPath: ["selector"]
|
|
598
|
+
});
|
|
599
|
+
ensureNoUnknownKeys({
|
|
600
|
+
record: selector,
|
|
601
|
+
allowedKeys: ["cd"],
|
|
602
|
+
ctx,
|
|
603
|
+
keyPath: ["selector"]
|
|
604
|
+
});
|
|
605
|
+
partial.selector = {};
|
|
606
|
+
if (selector.cd !== void 0) {
|
|
607
|
+
const cd = expectRecord({
|
|
608
|
+
value: selector.cd,
|
|
609
|
+
ctx,
|
|
610
|
+
keyPath: ["selector", "cd"]
|
|
611
|
+
});
|
|
612
|
+
ensureNoUnknownKeys({
|
|
613
|
+
record: cd,
|
|
614
|
+
allowedKeys: [
|
|
615
|
+
"prompt",
|
|
616
|
+
"surface",
|
|
617
|
+
"tmuxPopupOpts",
|
|
618
|
+
"fzf"
|
|
619
|
+
],
|
|
620
|
+
ctx,
|
|
621
|
+
keyPath: ["selector", "cd"]
|
|
622
|
+
});
|
|
623
|
+
partial.selector.cd = {};
|
|
624
|
+
if (cd.prompt !== void 0) partial.selector.cd.prompt = parseNonEmptyString({
|
|
625
|
+
value: cd.prompt,
|
|
626
|
+
ctx,
|
|
627
|
+
keyPath: [
|
|
628
|
+
"selector",
|
|
629
|
+
"cd",
|
|
630
|
+
"prompt"
|
|
631
|
+
]
|
|
632
|
+
});
|
|
633
|
+
if (cd.surface !== void 0) partial.selector.cd.surface = parseSelectorSurface({
|
|
634
|
+
value: cd.surface,
|
|
635
|
+
ctx,
|
|
636
|
+
keyPath: [
|
|
637
|
+
"selector",
|
|
638
|
+
"cd",
|
|
639
|
+
"surface"
|
|
640
|
+
]
|
|
641
|
+
});
|
|
642
|
+
if (cd.tmuxPopupOpts !== void 0) partial.selector.cd.tmuxPopupOpts = parseNonEmptyString({
|
|
643
|
+
value: cd.tmuxPopupOpts,
|
|
644
|
+
ctx,
|
|
645
|
+
keyPath: [
|
|
646
|
+
"selector",
|
|
647
|
+
"cd",
|
|
648
|
+
"tmuxPopupOpts"
|
|
649
|
+
]
|
|
650
|
+
});
|
|
651
|
+
if (cd.fzf !== void 0) {
|
|
652
|
+
const fzf = expectRecord({
|
|
653
|
+
value: cd.fzf,
|
|
654
|
+
ctx,
|
|
655
|
+
keyPath: [
|
|
656
|
+
"selector",
|
|
657
|
+
"cd",
|
|
658
|
+
"fzf"
|
|
659
|
+
]
|
|
660
|
+
});
|
|
661
|
+
ensureNoUnknownKeys({
|
|
662
|
+
record: fzf,
|
|
663
|
+
allowedKeys: ["extraArgs"],
|
|
664
|
+
ctx,
|
|
665
|
+
keyPath: [
|
|
666
|
+
"selector",
|
|
667
|
+
"cd",
|
|
668
|
+
"fzf"
|
|
669
|
+
]
|
|
670
|
+
});
|
|
671
|
+
partial.selector.cd.fzf = {};
|
|
672
|
+
if (fzf.extraArgs !== void 0) partial.selector.cd.fzf.extraArgs = parseStringArray({
|
|
673
|
+
value: fzf.extraArgs,
|
|
674
|
+
ctx,
|
|
675
|
+
keyPath: [
|
|
676
|
+
"selector",
|
|
677
|
+
"cd",
|
|
678
|
+
"fzf",
|
|
679
|
+
"extraArgs"
|
|
680
|
+
]
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return partial;
|
|
686
|
+
};
|
|
687
|
+
const mergeConfig = (base, partial) => {
|
|
688
|
+
return {
|
|
689
|
+
paths: { worktreeRoot: partial.paths?.worktreeRoot ?? base.paths.worktreeRoot },
|
|
690
|
+
git: {
|
|
691
|
+
baseBranch: partial.git?.baseBranch === void 0 ? base.git.baseBranch : partial.git.baseBranch,
|
|
692
|
+
baseRemote: partial.git?.baseRemote ?? base.git.baseRemote
|
|
693
|
+
},
|
|
694
|
+
github: { enabled: partial.github?.enabled ?? base.github.enabled },
|
|
695
|
+
hooks: {
|
|
696
|
+
enabled: partial.hooks?.enabled ?? base.hooks.enabled,
|
|
697
|
+
timeoutMs: partial.hooks?.timeoutMs ?? base.hooks.timeoutMs
|
|
698
|
+
},
|
|
699
|
+
locks: {
|
|
700
|
+
timeoutMs: partial.locks?.timeoutMs ?? base.locks.timeoutMs,
|
|
701
|
+
staleLockTTLSeconds: partial.locks?.staleLockTTLSeconds ?? base.locks.staleLockTTLSeconds
|
|
702
|
+
},
|
|
703
|
+
list: { table: {
|
|
704
|
+
columns: partial.list?.table?.columns ? [...partial.list.table.columns] : [...base.list.table.columns],
|
|
705
|
+
path: {
|
|
706
|
+
truncate: partial.list?.table?.path?.truncate ?? base.list.table.path.truncate,
|
|
707
|
+
minWidth: partial.list?.table?.path?.minWidth ?? base.list.table.path.minWidth
|
|
708
|
+
}
|
|
709
|
+
} },
|
|
710
|
+
selector: { cd: {
|
|
711
|
+
prompt: partial.selector?.cd?.prompt ?? base.selector.cd.prompt,
|
|
712
|
+
surface: partial.selector?.cd?.surface ?? base.selector.cd.surface,
|
|
713
|
+
tmuxPopupOpts: partial.selector?.cd?.tmuxPopupOpts ?? base.selector.cd.tmuxPopupOpts,
|
|
714
|
+
fzf: { extraArgs: partial.selector?.cd?.fzf?.extraArgs ? [...partial.selector.cd.fzf.extraArgs] : [...base.selector.cd.fzf.extraArgs] }
|
|
715
|
+
} }
|
|
716
|
+
};
|
|
717
|
+
};
|
|
718
|
+
const configPathExists = async (filePath) => {
|
|
719
|
+
try {
|
|
720
|
+
await access(filePath, constants.F_OK);
|
|
721
|
+
return true;
|
|
722
|
+
} catch {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
const resolveLocalConfigPath = (directory) => {
|
|
727
|
+
return join(directory, ...LOCAL_CONFIG_PATH_SEGMENTS);
|
|
728
|
+
};
|
|
729
|
+
const resolveGlobalConfigPath = () => {
|
|
730
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
731
|
+
if (typeof xdgConfigHome === "string" && xdgConfigHome.length > 0) return join(resolve(xdgConfigHome), ...GLOBAL_CONFIG_PATH_SEGMENTS);
|
|
732
|
+
return join(homedir(), ".config", ...GLOBAL_CONFIG_PATH_SEGMENTS);
|
|
733
|
+
};
|
|
734
|
+
const resolveExistingConfigFiles = async ({ cwd, repoRoot }) => {
|
|
735
|
+
const localCandidates = (await collectConfigSearchDirectories(cwd)).map((directory) => resolveLocalConfigPath(directory));
|
|
736
|
+
const repoRootCandidate = resolveLocalConfigPath(repoRoot);
|
|
737
|
+
const lowToHighCandidates = [
|
|
738
|
+
resolveGlobalConfigPath(),
|
|
739
|
+
repoRootCandidate,
|
|
740
|
+
...localCandidates
|
|
741
|
+
];
|
|
742
|
+
const deduped = /* @__PURE__ */ new Map();
|
|
743
|
+
for (const [order, candidate] of lowToHighCandidates.entries()) {
|
|
744
|
+
if (await configPathExists(candidate) !== true) continue;
|
|
745
|
+
const canonical = await realpath(candidate).catch(() => resolve(candidate));
|
|
746
|
+
deduped.set(canonical, {
|
|
747
|
+
path: candidate,
|
|
748
|
+
order
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return [...deduped.values()].sort((a, b) => a.order - b.order).map((entry) => entry.path);
|
|
752
|
+
};
|
|
753
|
+
const isPathInsideOrEqual$1 = ({ parentPath, childPath }) => {
|
|
754
|
+
const rel = relative(parentPath, childPath);
|
|
755
|
+
if (rel.length === 0) return true;
|
|
756
|
+
return rel !== ".." && rel.startsWith(`..${sep}`) !== true;
|
|
757
|
+
};
|
|
758
|
+
const validateWorktreeRoot = async ({ repoRoot, config }) => {
|
|
759
|
+
const rawWorktreeRoot = config.paths.worktreeRoot;
|
|
760
|
+
const resolvedWorktreeRoot = isAbsolute(rawWorktreeRoot) ? resolve(rawWorktreeRoot) : resolve(repoRoot, rawWorktreeRoot);
|
|
761
|
+
if (isPathInsideOrEqual$1({
|
|
762
|
+
parentPath: resolve(repoRoot, ".git"),
|
|
763
|
+
childPath: resolvedWorktreeRoot
|
|
764
|
+
})) throwInvalidConfig({
|
|
765
|
+
file: "<resolved>",
|
|
766
|
+
keyPath: "paths.worktreeRoot",
|
|
767
|
+
reason: "must not point inside .git"
|
|
768
|
+
});
|
|
769
|
+
try {
|
|
770
|
+
if ((await lstat(resolvedWorktreeRoot)).isDirectory() !== true) throwInvalidConfig({
|
|
771
|
+
file: "<resolved>",
|
|
772
|
+
keyPath: "paths.worktreeRoot",
|
|
773
|
+
reason: "must not point to an existing file"
|
|
774
|
+
});
|
|
775
|
+
} catch (error) {
|
|
776
|
+
if (error.code === "ENOENT") return;
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
const parseConfigFile = async (file) => {
|
|
781
|
+
const rawContent = await readFile(file, "utf8");
|
|
782
|
+
let parsed;
|
|
783
|
+
try {
|
|
784
|
+
parsed = parse(rawContent);
|
|
785
|
+
} catch (error) {
|
|
786
|
+
throwInvalidConfig({
|
|
787
|
+
file,
|
|
788
|
+
keyPath: "<root>",
|
|
789
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
return validatePartialConfig({
|
|
793
|
+
rawConfig: parsed,
|
|
794
|
+
ctx: { file }
|
|
795
|
+
});
|
|
796
|
+
};
|
|
797
|
+
const cloneDefaultConfig = () => {
|
|
798
|
+
return mergeConfig(DEFAULT_CONFIG, {});
|
|
799
|
+
};
|
|
800
|
+
const loadResolvedConfig = async ({ cwd, repoRoot }) => {
|
|
801
|
+
const files = await resolveExistingConfigFiles({
|
|
802
|
+
cwd,
|
|
803
|
+
repoRoot
|
|
804
|
+
});
|
|
805
|
+
let config = cloneDefaultConfig();
|
|
806
|
+
for (const file of files) {
|
|
807
|
+
const partial = await parseConfigFile(file);
|
|
808
|
+
config = mergeConfig(config, partial);
|
|
809
|
+
}
|
|
810
|
+
await validateWorktreeRoot({
|
|
811
|
+
repoRoot,
|
|
812
|
+
config
|
|
813
|
+
});
|
|
814
|
+
return {
|
|
815
|
+
config,
|
|
816
|
+
loadedFiles: files
|
|
817
|
+
};
|
|
818
|
+
};
|
|
819
|
+
|
|
133
820
|
//#endregion
|
|
134
821
|
//#region src/git/exec.ts
|
|
135
822
|
const runGitCommand = async ({ cwd, args, reject = true }) => {
|
|
@@ -175,6 +862,7 @@ const doesGitRefExist = async (cwd, ref) => {
|
|
|
175
862
|
//#endregion
|
|
176
863
|
//#region src/core/paths.ts
|
|
177
864
|
const GIT_DIR_NAME = ".git";
|
|
865
|
+
const DEFAULT_WORKTREE_ROOT = ".worktree";
|
|
178
866
|
const WORKTREE_ID_HASH_LENGTH = 12;
|
|
179
867
|
const WORKTREE_ID_SLUG_MAX_LENGTH = 48;
|
|
180
868
|
const resolveRepoRootFromCommonDir = ({ currentWorktreeRoot, gitCommonDir }) => {
|
|
@@ -212,8 +900,9 @@ const resolveRepoContext = async (cwd) => {
|
|
|
212
900
|
gitCommonDir
|
|
213
901
|
};
|
|
214
902
|
};
|
|
215
|
-
const getWorktreeRootPath = (repoRoot) => {
|
|
216
|
-
return
|
|
903
|
+
const getWorktreeRootPath = (repoRoot, configuredWorktreeRoot = DEFAULT_WORKTREE_ROOT) => {
|
|
904
|
+
if (isAbsolute(configuredWorktreeRoot)) return resolve(configuredWorktreeRoot);
|
|
905
|
+
return resolve(repoRoot, configuredWorktreeRoot);
|
|
217
906
|
};
|
|
218
907
|
const getWorktreeMetaRootPath = (repoRoot) => {
|
|
219
908
|
return join(repoRoot, ".vde", "worktree");
|
|
@@ -233,25 +922,33 @@ const getStateDirectoryPath = (repoRoot) => {
|
|
|
233
922
|
const branchToWorktreeId = (branch) => {
|
|
234
923
|
return `${branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, WORKTREE_ID_SLUG_MAX_LENGTH) || "branch"}--${createHash("sha256").update(branch).digest("hex").slice(0, WORKTREE_ID_HASH_LENGTH)}`;
|
|
235
924
|
};
|
|
236
|
-
const branchToWorktreePath = (repoRoot, branch) => {
|
|
237
|
-
const worktreeRoot = getWorktreeRootPath(repoRoot);
|
|
238
|
-
return
|
|
239
|
-
|
|
240
|
-
path: join(worktreeRoot, ...branch.split("/"))
|
|
925
|
+
const branchToWorktreePath = (repoRoot, branch, configuredWorktreeRoot = DEFAULT_WORKTREE_ROOT) => {
|
|
926
|
+
const worktreeRoot = getWorktreeRootPath(repoRoot, configuredWorktreeRoot);
|
|
927
|
+
return ensurePathInsideRoot({
|
|
928
|
+
rootPath: worktreeRoot,
|
|
929
|
+
path: join(worktreeRoot, ...branch.split("/")),
|
|
930
|
+
message: "Path is outside managed worktree root"
|
|
241
931
|
});
|
|
242
932
|
};
|
|
243
|
-
const
|
|
244
|
-
const rel = relative(
|
|
933
|
+
const ensurePathInsideRoot = ({ rootPath, path, message = "Path is outside allowed root" }) => {
|
|
934
|
+
const rel = relative(rootPath, path);
|
|
245
935
|
if (rel === "") return path;
|
|
246
936
|
if (rel === ".." || rel.startsWith(`..${sep}`)) throw createCliError("PATH_OUTSIDE_REPO", {
|
|
247
|
-
message
|
|
937
|
+
message,
|
|
248
938
|
details: {
|
|
249
|
-
|
|
939
|
+
rootPath,
|
|
250
940
|
path
|
|
251
941
|
}
|
|
252
942
|
});
|
|
253
943
|
return path;
|
|
254
944
|
};
|
|
945
|
+
const ensurePathInsideRepo = ({ repoRoot, path }) => {
|
|
946
|
+
return ensurePathInsideRoot({
|
|
947
|
+
rootPath: repoRoot,
|
|
948
|
+
path,
|
|
949
|
+
message: "Path is outside repository root"
|
|
950
|
+
});
|
|
951
|
+
};
|
|
255
952
|
const resolveRepoRelativePath = ({ repoRoot, relativePath }) => {
|
|
256
953
|
if (isAbsolute(relativePath)) throw createCliError("ABSOLUTE_PATH_NOT_ALLOWED", {
|
|
257
954
|
message: "Absolute path is not allowed",
|
|
@@ -266,6 +963,11 @@ const resolvePathFromCwd = ({ cwd, path }) => {
|
|
|
266
963
|
if (isAbsolute(path)) return path;
|
|
267
964
|
return resolve(cwd, path);
|
|
268
965
|
};
|
|
966
|
+
const isManagedWorktreePath = ({ worktreePath, managedWorktreeRoot }) => {
|
|
967
|
+
const rel = relative(managedWorktreeRoot, worktreePath);
|
|
968
|
+
if (rel === "" || rel === "." || rel === "..") return false;
|
|
969
|
+
return rel.startsWith(`..${sep}`) !== true;
|
|
970
|
+
};
|
|
269
971
|
|
|
270
972
|
//#endregion
|
|
271
973
|
//#region src/core/hooks.ts
|
|
@@ -414,7 +1116,7 @@ const invokeHook = async ({ hookName, args, context }) => {
|
|
|
414
1116
|
|
|
415
1117
|
//#endregion
|
|
416
1118
|
//#region src/core/init.ts
|
|
417
|
-
const
|
|
1119
|
+
const EXCLUDE_MARKER = "# vde-worktree (managed)";
|
|
418
1120
|
const DEFAULT_HOOKS = [{
|
|
419
1121
|
name: "post-new",
|
|
420
1122
|
lines: [
|
|
@@ -448,7 +1150,27 @@ const createHookTemplate = async (hooksDir, name, lines) => {
|
|
|
448
1150
|
await chmod(targetPath, 493);
|
|
449
1151
|
}
|
|
450
1152
|
};
|
|
451
|
-
const
|
|
1153
|
+
const isPathInsideOrEqual = ({ rootPath, candidatePath }) => {
|
|
1154
|
+
const rel = relative(rootPath, candidatePath);
|
|
1155
|
+
if (rel.length === 0) return true;
|
|
1156
|
+
return rel !== ".." && rel.startsWith(`..${sep}`) !== true;
|
|
1157
|
+
};
|
|
1158
|
+
const toExcludeEntry = ({ repoRoot, managedWorktreeRoot }) => {
|
|
1159
|
+
if (isPathInsideOrEqual({
|
|
1160
|
+
rootPath: repoRoot,
|
|
1161
|
+
candidatePath: managedWorktreeRoot
|
|
1162
|
+
}) !== true) return null;
|
|
1163
|
+
const rel = relative(repoRoot, managedWorktreeRoot).split(sep).join("/");
|
|
1164
|
+
const normalized = rel.length === 0 ? "." : rel;
|
|
1165
|
+
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
1166
|
+
};
|
|
1167
|
+
const ensureExcludeBlock = async ({ repoRoot, managedWorktreeRoot }) => {
|
|
1168
|
+
const managedEntry = toExcludeEntry({
|
|
1169
|
+
repoRoot,
|
|
1170
|
+
managedWorktreeRoot
|
|
1171
|
+
});
|
|
1172
|
+
if (managedEntry === null) return;
|
|
1173
|
+
const managedExcludeBlock = `${EXCLUDE_MARKER}\n${managedEntry}\n.vde/worktree/\n`;
|
|
452
1174
|
const excludePath = join(repoRoot, ".git", "info", "exclude");
|
|
453
1175
|
let current = "";
|
|
454
1176
|
try {
|
|
@@ -456,8 +1178,8 @@ const ensureExcludeBlock = async (repoRoot) => {
|
|
|
456
1178
|
} catch {
|
|
457
1179
|
current = "";
|
|
458
1180
|
}
|
|
459
|
-
if (current.includes(
|
|
460
|
-
await writeFile(excludePath, `${current.endsWith("\n") || current.length === 0 ? current : `${current}\n`}${
|
|
1181
|
+
if (current.includes(managedExcludeBlock)) return;
|
|
1182
|
+
await writeFile(excludePath, `${current.endsWith("\n") || current.length === 0 ? current : `${current}\n`}${managedExcludeBlock}`, "utf8");
|
|
461
1183
|
};
|
|
462
1184
|
const isInitialized = async (repoRoot) => {
|
|
463
1185
|
try {
|
|
@@ -467,14 +1189,17 @@ const isInitialized = async (repoRoot) => {
|
|
|
467
1189
|
return false;
|
|
468
1190
|
}
|
|
469
1191
|
};
|
|
470
|
-
const initializeRepository = async (repoRoot) => {
|
|
1192
|
+
const initializeRepository = async ({ repoRoot, managedWorktreeRoot }) => {
|
|
471
1193
|
const wasInitialized = await isInitialized(repoRoot);
|
|
472
|
-
await mkdir(
|
|
1194
|
+
await mkdir(managedWorktreeRoot, { recursive: true });
|
|
473
1195
|
await mkdir(getHooksDirectoryPath(repoRoot), { recursive: true });
|
|
474
1196
|
await mkdir(getLogsDirectoryPath(repoRoot), { recursive: true });
|
|
475
1197
|
await mkdir(getLocksDirectoryPath(repoRoot), { recursive: true });
|
|
476
1198
|
await mkdir(getStateDirectoryPath(repoRoot), { recursive: true });
|
|
477
|
-
await ensureExcludeBlock(
|
|
1199
|
+
await ensureExcludeBlock({
|
|
1200
|
+
repoRoot,
|
|
1201
|
+
managedWorktreeRoot
|
|
1202
|
+
});
|
|
478
1203
|
for (const hook of DEFAULT_HOOKS) await createHookTemplate(getHooksDirectoryPath(repoRoot), hook.name, hook.lines);
|
|
479
1204
|
return { alreadyInitialized: wasInitialized };
|
|
480
1205
|
};
|
|
@@ -853,8 +1578,11 @@ const toTargetBranches = ({ branches, baseBranch }) => {
|
|
|
853
1578
|
}
|
|
854
1579
|
return [...uniqueBranches];
|
|
855
1580
|
};
|
|
856
|
-
const
|
|
857
|
-
return new Map(branches.map((branch) => [branch,
|
|
1581
|
+
const buildUnknownPrStateMap = (branches) => {
|
|
1582
|
+
return new Map(branches.map((branch) => [branch, {
|
|
1583
|
+
status: "unknown",
|
|
1584
|
+
url: null
|
|
1585
|
+
}]));
|
|
858
1586
|
};
|
|
859
1587
|
const parseUpdatedAtMillis = (value) => {
|
|
860
1588
|
if (typeof value !== "string" || value.length === 0) return Number.NEGATIVE_INFINITY;
|
|
@@ -870,7 +1598,11 @@ const toPrStatus = (record) => {
|
|
|
870
1598
|
if (state === "CLOSED") return "closed_unmerged";
|
|
871
1599
|
return "unknown";
|
|
872
1600
|
};
|
|
873
|
-
const
|
|
1601
|
+
const toPrUrl = (record) => {
|
|
1602
|
+
if (typeof record.url === "string" && record.url.length > 0) return record.url;
|
|
1603
|
+
return null;
|
|
1604
|
+
};
|
|
1605
|
+
const parsePrStateByBranch = ({ raw, targetBranches }) => {
|
|
874
1606
|
try {
|
|
875
1607
|
const parsed = JSON.parse(raw);
|
|
876
1608
|
if (Array.isArray(parsed) !== true) return null;
|
|
@@ -882,31 +1614,43 @@ const parsePrStatusByBranch = ({ raw, targetBranches }) => {
|
|
|
882
1614
|
if (targetBranchSet.has(record.headRefName) !== true) continue;
|
|
883
1615
|
const updatedAtMillis = parseUpdatedAtMillis(record.updatedAt);
|
|
884
1616
|
const status = toPrStatus(record);
|
|
1617
|
+
const url = toPrUrl(record);
|
|
885
1618
|
const current = latestByBranch.get(record.headRefName);
|
|
886
1619
|
if (current === void 0 || updatedAtMillis > current.updatedAtMillis || updatedAtMillis === current.updatedAtMillis && index > current.index) latestByBranch.set(record.headRefName, {
|
|
887
1620
|
updatedAtMillis,
|
|
888
1621
|
index,
|
|
889
|
-
status
|
|
1622
|
+
status,
|
|
1623
|
+
url
|
|
890
1624
|
});
|
|
891
1625
|
}
|
|
892
1626
|
const result = /* @__PURE__ */ new Map();
|
|
893
1627
|
for (const branch of targetBranches) {
|
|
894
1628
|
const latest = latestByBranch.get(branch);
|
|
895
|
-
|
|
1629
|
+
if (latest === void 0) {
|
|
1630
|
+
result.set(branch, {
|
|
1631
|
+
status: "none",
|
|
1632
|
+
url: null
|
|
1633
|
+
});
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
result.set(branch, {
|
|
1637
|
+
status: latest.status,
|
|
1638
|
+
url: latest.url
|
|
1639
|
+
});
|
|
896
1640
|
}
|
|
897
1641
|
return result;
|
|
898
1642
|
} catch {
|
|
899
1643
|
return null;
|
|
900
1644
|
}
|
|
901
1645
|
};
|
|
902
|
-
const
|
|
1646
|
+
const resolvePrStateByBranchBatch = async ({ repoRoot, baseBranch, branches, enabled = true, runGh = defaultRunGh }) => {
|
|
903
1647
|
if (baseBranch === null) return /* @__PURE__ */ new Map();
|
|
904
1648
|
const targetBranches = toTargetBranches({
|
|
905
1649
|
branches,
|
|
906
1650
|
baseBranch
|
|
907
1651
|
});
|
|
908
1652
|
if (targetBranches.length === 0) return /* @__PURE__ */ new Map();
|
|
909
|
-
if (enabled !== true) return
|
|
1653
|
+
if (enabled !== true) return buildUnknownPrStateMap(targetBranches);
|
|
910
1654
|
try {
|
|
911
1655
|
const result = await runGh({
|
|
912
1656
|
cwd: repoRoot,
|
|
@@ -922,19 +1666,19 @@ const resolvePrStatusByBranchBatch = async ({ repoRoot, baseBranch, branches, en
|
|
|
922
1666
|
"--limit",
|
|
923
1667
|
"1000",
|
|
924
1668
|
"--json",
|
|
925
|
-
"headRefName,state,mergedAt,updatedAt"
|
|
1669
|
+
"headRefName,state,mergedAt,updatedAt,url"
|
|
926
1670
|
]
|
|
927
1671
|
});
|
|
928
|
-
if (result.exitCode !== 0) return
|
|
929
|
-
const prStatusByBranch =
|
|
1672
|
+
if (result.exitCode !== 0) return buildUnknownPrStateMap(targetBranches);
|
|
1673
|
+
const prStatusByBranch = parsePrStateByBranch({
|
|
930
1674
|
raw: result.stdout,
|
|
931
1675
|
targetBranches
|
|
932
1676
|
});
|
|
933
|
-
if (prStatusByBranch === null) return
|
|
1677
|
+
if (prStatusByBranch === null) return buildUnknownPrStateMap(targetBranches);
|
|
934
1678
|
return prStatusByBranch;
|
|
935
1679
|
} catch (error) {
|
|
936
|
-
if (error.code === "ENOENT") return
|
|
937
|
-
return
|
|
1680
|
+
if (error.code === "ENOENT") return buildUnknownPrStateMap(targetBranches);
|
|
1681
|
+
return buildUnknownPrStateMap(targetBranches);
|
|
938
1682
|
}
|
|
939
1683
|
};
|
|
940
1684
|
|
|
@@ -999,36 +1743,6 @@ const listGitWorktrees = async (repoRoot) => {
|
|
|
999
1743
|
|
|
1000
1744
|
//#endregion
|
|
1001
1745
|
//#region src/core/worktree-state.ts
|
|
1002
|
-
const resolveBaseBranch$1 = async (repoRoot) => {
|
|
1003
|
-
const explicit = await runGitCommand({
|
|
1004
|
-
cwd: repoRoot,
|
|
1005
|
-
args: [
|
|
1006
|
-
"config",
|
|
1007
|
-
"--get",
|
|
1008
|
-
"vde-worktree.baseBranch"
|
|
1009
|
-
],
|
|
1010
|
-
reject: false
|
|
1011
|
-
});
|
|
1012
|
-
if (explicit.exitCode === 0 && explicit.stdout.trim().length > 0) return explicit.stdout.trim();
|
|
1013
|
-
for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
|
|
1014
|
-
return null;
|
|
1015
|
-
};
|
|
1016
|
-
const resolveEnableGh = async (repoRoot) => {
|
|
1017
|
-
const result = await runGitCommand({
|
|
1018
|
-
cwd: repoRoot,
|
|
1019
|
-
args: [
|
|
1020
|
-
"config",
|
|
1021
|
-
"--bool",
|
|
1022
|
-
"--get",
|
|
1023
|
-
"vde-worktree.enableGh"
|
|
1024
|
-
],
|
|
1025
|
-
reject: false
|
|
1026
|
-
});
|
|
1027
|
-
if (result.exitCode !== 0) return true;
|
|
1028
|
-
const value = result.stdout.trim().toLowerCase();
|
|
1029
|
-
if (value === "false" || value === "no" || value === "off" || value === "0") return false;
|
|
1030
|
-
return true;
|
|
1031
|
-
};
|
|
1032
1746
|
const resolveDirty = async (worktreePath) => {
|
|
1033
1747
|
return (await runGitCommand({
|
|
1034
1748
|
cwd: worktreePath,
|
|
@@ -1133,7 +1847,7 @@ const resolveLifecycleFromReflog = async ({ repoRoot, branch, baseBranch }) => {
|
|
|
1133
1847
|
divergedHead: latestWorkHead
|
|
1134
1848
|
};
|
|
1135
1849
|
};
|
|
1136
|
-
const resolveMergedState = async ({ repoRoot, branch, head, baseBranch,
|
|
1850
|
+
const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStateByBranch }) => {
|
|
1137
1851
|
if (branch === null) return {
|
|
1138
1852
|
byAncestry: null,
|
|
1139
1853
|
byPR: null,
|
|
@@ -1154,7 +1868,7 @@ const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStatus
|
|
|
1154
1868
|
if (result.exitCode === 0) byAncestry = true;
|
|
1155
1869
|
else if (result.exitCode === 1) byAncestry = false;
|
|
1156
1870
|
}
|
|
1157
|
-
const prStatus = branch === baseBranch ? null :
|
|
1871
|
+
const prStatus = branch === baseBranch ? null : prStateByBranch.get(branch)?.status ?? null;
|
|
1158
1872
|
let byPR = null;
|
|
1159
1873
|
if (prStatus === "merged") byPR = true;
|
|
1160
1874
|
else if (prStatus === "none" || prStatus === "open" || prStatus === "closed_unmerged") byPR = false;
|
|
@@ -1208,9 +1922,16 @@ const resolveMergedState = async ({ repoRoot, branch, head, baseBranch, prStatus
|
|
|
1208
1922
|
})
|
|
1209
1923
|
};
|
|
1210
1924
|
};
|
|
1211
|
-
const
|
|
1212
|
-
if (branch === null || branch === baseBranch) return {
|
|
1213
|
-
|
|
1925
|
+
const resolveWorktreePrState = ({ branch, baseBranch, prStateByBranch }) => {
|
|
1926
|
+
if (branch === null || branch === baseBranch) return {
|
|
1927
|
+
status: null,
|
|
1928
|
+
url: null
|
|
1929
|
+
};
|
|
1930
|
+
const prState = prStateByBranch.get(branch);
|
|
1931
|
+
return {
|
|
1932
|
+
status: prState?.status ?? null,
|
|
1933
|
+
url: prState?.url ?? null
|
|
1934
|
+
};
|
|
1214
1935
|
};
|
|
1215
1936
|
const resolveMergedOverall = ({ byAncestry, byPR, byLifecycle }) => {
|
|
1216
1937
|
if (byPR === true || byLifecycle === true) return true;
|
|
@@ -1258,7 +1979,7 @@ const resolveUpstreamState = async (worktreePath) => {
|
|
|
1258
1979
|
remote: upstreamRef.stdout.trim()
|
|
1259
1980
|
};
|
|
1260
1981
|
};
|
|
1261
|
-
const enrichWorktree = async ({ repoRoot, worktree, baseBranch,
|
|
1982
|
+
const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStateByBranch }) => {
|
|
1262
1983
|
const [dirty, locked, merged, upstream] = await Promise.all([
|
|
1263
1984
|
resolveDirty(worktree.path),
|
|
1264
1985
|
resolveLockState({
|
|
@@ -1270,14 +1991,14 @@ const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStatusByBranch
|
|
|
1270
1991
|
branch: worktree.branch,
|
|
1271
1992
|
head: worktree.head,
|
|
1272
1993
|
baseBranch,
|
|
1273
|
-
|
|
1994
|
+
prStateByBranch
|
|
1274
1995
|
}),
|
|
1275
1996
|
resolveUpstreamState(worktree.path)
|
|
1276
1997
|
]);
|
|
1277
|
-
const pr =
|
|
1998
|
+
const pr = resolveWorktreePrState({
|
|
1278
1999
|
branch: worktree.branch,
|
|
1279
2000
|
baseBranch,
|
|
1280
|
-
|
|
2001
|
+
prStateByBranch
|
|
1281
2002
|
});
|
|
1282
2003
|
return {
|
|
1283
2004
|
branch: worktree.branch,
|
|
@@ -1290,17 +2011,13 @@ const enrichWorktree = async ({ repoRoot, worktree, baseBranch, prStatusByBranch
|
|
|
1290
2011
|
upstream
|
|
1291
2012
|
};
|
|
1292
2013
|
};
|
|
1293
|
-
const collectWorktreeSnapshot = async (repoRoot, { noGh = false } = {}) => {
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
listGitWorktrees(repoRoot),
|
|
1297
|
-
resolveEnableGh(repoRoot)
|
|
1298
|
-
]);
|
|
1299
|
-
const prStatusByBranch = await resolvePrStatusByBranchBatch({
|
|
2014
|
+
const collectWorktreeSnapshot = async (repoRoot, { baseBranch = null, ghEnabled = true, noGh = false } = {}) => {
|
|
2015
|
+
const worktrees = await listGitWorktrees(repoRoot);
|
|
2016
|
+
const prStateByBranch = await resolvePrStateByBranchBatch({
|
|
1300
2017
|
repoRoot,
|
|
1301
2018
|
baseBranch,
|
|
1302
2019
|
branches: worktrees.map((worktree) => worktree.branch),
|
|
1303
|
-
enabled:
|
|
2020
|
+
enabled: ghEnabled && noGh !== true
|
|
1304
2021
|
});
|
|
1305
2022
|
return {
|
|
1306
2023
|
repoRoot,
|
|
@@ -1310,7 +2027,7 @@ const collectWorktreeSnapshot = async (repoRoot, { noGh = false } = {}) => {
|
|
|
1310
2027
|
repoRoot,
|
|
1311
2028
|
worktree,
|
|
1312
2029
|
baseBranch,
|
|
1313
|
-
|
|
2030
|
+
prStateByBranch
|
|
1314
2031
|
});
|
|
1315
2032
|
}))
|
|
1316
2033
|
};
|
|
@@ -1324,7 +2041,8 @@ const RESERVED_FZF_ARGS = new Set([
|
|
|
1324
2041
|
"prompt",
|
|
1325
2042
|
"layout",
|
|
1326
2043
|
"height",
|
|
1327
|
-
"border"
|
|
2044
|
+
"border",
|
|
2045
|
+
"tmux"
|
|
1328
2046
|
]);
|
|
1329
2047
|
const ANSI_ESCAPE_SEQUENCE_PATTERN = String.raw`\u001B\[[0-?]*[ -/]*[@-~]`;
|
|
1330
2048
|
const ANSI_ESCAPE_SEQUENCE_REGEX = new RegExp(ANSI_ESCAPE_SEQUENCE_PATTERN, "g");
|
|
@@ -1364,6 +2082,13 @@ const defaultCheckFzfAvailability = async () => {
|
|
|
1364
2082
|
throw error;
|
|
1365
2083
|
}
|
|
1366
2084
|
};
|
|
2085
|
+
const defaultCheckFzfTmuxSupport = async () => {
|
|
2086
|
+
try {
|
|
2087
|
+
return (await execa(FZF_BINARY, ["--help"], { timeout: FZF_CHECK_TIMEOUT_MS })).stdout.includes("--tmux");
|
|
2088
|
+
} catch {
|
|
2089
|
+
return false;
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
1367
2092
|
const defaultRunFzf = async ({ args, input, cwd, env }) => {
|
|
1368
2093
|
return { stdout: (await execa(FZF_BINARY, args, {
|
|
1369
2094
|
input,
|
|
@@ -1376,20 +2101,46 @@ const ensureFzfAvailable = async (checkFzfAvailability) => {
|
|
|
1376
2101
|
if (await checkFzfAvailability()) return;
|
|
1377
2102
|
throw new Error("fzf is required for interactive selection");
|
|
1378
2103
|
};
|
|
1379
|
-
const
|
|
2104
|
+
const shouldTryTmuxPopup = async ({ surface, env, checkFzfTmuxSupport }) => {
|
|
2105
|
+
if (surface === "inline") return false;
|
|
2106
|
+
if (surface === "tmux-popup") return true;
|
|
2107
|
+
if (typeof env.TMUX !== "string" || env.TMUX.length === 0) return false;
|
|
2108
|
+
try {
|
|
2109
|
+
return await checkFzfTmuxSupport();
|
|
2110
|
+
} catch {
|
|
2111
|
+
return false;
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
const isTmuxUnknownOptionError = (error) => {
|
|
2115
|
+
const execaError = error;
|
|
2116
|
+
const text = [
|
|
2117
|
+
execaError.message,
|
|
2118
|
+
execaError.shortMessage,
|
|
2119
|
+
execaError.stderr,
|
|
2120
|
+
execaError.stdout
|
|
2121
|
+
].filter((value) => typeof value === "string" && value.length > 0).join("\n");
|
|
2122
|
+
return /unknown option.*--tmux|--tmux.*unknown option/i.test(text);
|
|
2123
|
+
};
|
|
2124
|
+
const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", surface = "inline", tmuxPopupOpts = "80%,70%", fzfExtraArgs = [], cwd = process.cwd(), env = process.env, isInteractive = () => process.stdout.isTTY === true && process.stderr.isTTY === true, checkFzfAvailability = defaultCheckFzfAvailability, checkFzfTmuxSupport = defaultCheckFzfTmuxSupport, runFzf = defaultRunFzf }) => {
|
|
1380
2125
|
if (candidates.length === 0) throw new Error("No candidates provided for fzf selection");
|
|
1381
2126
|
if (isInteractive() !== true) throw new Error("fzf selection requires an interactive terminal");
|
|
1382
2127
|
await ensureFzfAvailable(checkFzfAvailability);
|
|
1383
|
-
const
|
|
2128
|
+
const baseArgs = buildFzfArgs({
|
|
1384
2129
|
prompt,
|
|
1385
2130
|
fzfExtraArgs
|
|
1386
2131
|
});
|
|
2132
|
+
const tryTmuxPopup = await shouldTryTmuxPopup({
|
|
2133
|
+
surface,
|
|
2134
|
+
env,
|
|
2135
|
+
checkFzfTmuxSupport
|
|
2136
|
+
});
|
|
2137
|
+
const args = tryTmuxPopup ? [...baseArgs, `--tmux=${tmuxPopupOpts}`] : baseArgs;
|
|
1387
2138
|
const input = buildFzfInput(candidates);
|
|
1388
2139
|
if (input.length === 0) throw new Error("All candidates are empty after sanitization");
|
|
1389
2140
|
const candidateSet = new Set(input.split("\n").map((candidate) => stripAnsi(candidate)));
|
|
1390
|
-
|
|
2141
|
+
const runWithValidation = async (fzfArgs) => {
|
|
1391
2142
|
const selectedPath = stripAnsi(stripTrailingNewlines((await runFzf({
|
|
1392
|
-
args,
|
|
2143
|
+
args: fzfArgs,
|
|
1393
2144
|
input,
|
|
1394
2145
|
cwd,
|
|
1395
2146
|
env
|
|
@@ -1400,7 +2151,16 @@ const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", fzfExtraAr
|
|
|
1400
2151
|
status: "selected",
|
|
1401
2152
|
path: selectedPath
|
|
1402
2153
|
};
|
|
2154
|
+
};
|
|
2155
|
+
try {
|
|
2156
|
+
return await runWithValidation(args);
|
|
1403
2157
|
} catch (error) {
|
|
2158
|
+
if (tryTmuxPopup && isTmuxUnknownOptionError(error)) try {
|
|
2159
|
+
return await runWithValidation(baseArgs);
|
|
2160
|
+
} catch (fallbackError) {
|
|
2161
|
+
if (fallbackError.exitCode === 130) return { status: "cancelled" };
|
|
2162
|
+
throw fallbackError;
|
|
2163
|
+
}
|
|
1404
2164
|
if (error.exitCode === 130) return { status: "cancelled" };
|
|
1405
2165
|
throw error;
|
|
1406
2166
|
}
|
|
@@ -1488,9 +2248,7 @@ const CD_FZF_EXTRA_ARGS = [
|
|
|
1488
2248
|
"--preview-window=right,60%,wrap",
|
|
1489
2249
|
"--ansi"
|
|
1490
2250
|
];
|
|
1491
|
-
const
|
|
1492
|
-
const LIST_TABLE_PATH_COLUMN_INDEX = 7;
|
|
1493
|
-
const LIST_TABLE_PATH_MIN_WIDTH = 12;
|
|
2251
|
+
const DEFAULT_LIST_TABLE_COLUMNS = [...LIST_TABLE_COLUMNS];
|
|
1494
2252
|
const LIST_TABLE_CELL_HORIZONTAL_PADDING = 2;
|
|
1495
2253
|
const COMPLETION_SHELLS = ["zsh", "fish"];
|
|
1496
2254
|
const COMPLETION_FILE_BY_SHELL = {
|
|
@@ -1512,6 +2270,10 @@ const CATPPUCCIN_MOCHA = {
|
|
|
1512
2270
|
overlay0: "#6c7086"
|
|
1513
2271
|
};
|
|
1514
2272
|
const identityColor = (value) => value;
|
|
2273
|
+
const hasDefaultListColumnOrder = (columns) => {
|
|
2274
|
+
if (columns.length !== DEFAULT_LIST_TABLE_COLUMNS.length) return false;
|
|
2275
|
+
return columns.every((column, index) => column === DEFAULT_LIST_TABLE_COLUMNS[index]);
|
|
2276
|
+
};
|
|
1515
2277
|
const createCatppuccinTheme = ({ enabled }) => {
|
|
1516
2278
|
if (enabled !== true) return {
|
|
1517
2279
|
header: identityColor,
|
|
@@ -1665,7 +2427,7 @@ const commandHelpEntries = [
|
|
|
1665
2427
|
details: [
|
|
1666
2428
|
"Table output includes branch, path, dirty, lock, merged, PR state, and ahead/behind vs base branch.",
|
|
1667
2429
|
"By default, long path values are truncated to fit terminal width.",
|
|
1668
|
-
"JSON output includes PR and upstream metadata fields."
|
|
2430
|
+
"JSON output includes PR status/url and upstream metadata fields."
|
|
1669
2431
|
],
|
|
1670
2432
|
options: ["--full-path"]
|
|
1671
2433
|
},
|
|
@@ -1959,85 +2721,29 @@ const ensureArgumentCount = ({ command, args, min, max }) => {
|
|
|
1959
2721
|
const ensureHasCommandAfterDoubleDash = ({ command, argsAfterDoubleDash }) => {
|
|
1960
2722
|
if (argsAfterDoubleDash.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `${command} requires arguments after --` });
|
|
1961
2723
|
};
|
|
1962
|
-
const
|
|
1963
|
-
|
|
2724
|
+
const resolveBaseBranch = async ({ repoRoot, config }) => {
|
|
2725
|
+
if (typeof config.git.baseBranch === "string" && config.git.baseBranch.length > 0) return config.git.baseBranch;
|
|
2726
|
+
const remote = config.git.baseRemote;
|
|
2727
|
+
const resolved = await runGitCommand({
|
|
1964
2728
|
cwd: repoRoot,
|
|
1965
2729
|
args: [
|
|
1966
|
-
"
|
|
1967
|
-
"--
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
reject: false
|
|
1971
|
-
});
|
|
1972
|
-
if (result.exitCode !== 0) return;
|
|
1973
|
-
const parsed = Number.parseInt(result.stdout.trim(), 10);
|
|
1974
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
1975
|
-
};
|
|
1976
|
-
const readGitConfigBoolean = async (repoRoot, key) => {
|
|
1977
|
-
const result = await runGitCommand({
|
|
1978
|
-
cwd: repoRoot,
|
|
1979
|
-
args: [
|
|
1980
|
-
"config",
|
|
1981
|
-
"--bool",
|
|
1982
|
-
"--get",
|
|
1983
|
-
key
|
|
1984
|
-
],
|
|
1985
|
-
reject: false
|
|
1986
|
-
});
|
|
1987
|
-
if (result.exitCode !== 0) return;
|
|
1988
|
-
const value = result.stdout.trim().toLowerCase();
|
|
1989
|
-
if (value === "true" || value === "yes" || value === "on" || value === "1") return true;
|
|
1990
|
-
if (value === "false" || value === "no" || value === "off" || value === "0") return false;
|
|
1991
|
-
};
|
|
1992
|
-
const resolveConfiguredBaseRemote = async (repoRoot) => {
|
|
1993
|
-
const configured = await runGitCommand({
|
|
1994
|
-
cwd: repoRoot,
|
|
1995
|
-
args: [
|
|
1996
|
-
"config",
|
|
1997
|
-
"--get",
|
|
1998
|
-
"vde-worktree.baseRemote"
|
|
1999
|
-
],
|
|
2000
|
-
reject: false
|
|
2001
|
-
});
|
|
2002
|
-
if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
|
|
2003
|
-
return "origin";
|
|
2004
|
-
};
|
|
2005
|
-
const resolveBaseBranch = async (repoRoot) => {
|
|
2006
|
-
const configured = await runGitCommand({
|
|
2007
|
-
cwd: repoRoot,
|
|
2008
|
-
args: [
|
|
2009
|
-
"config",
|
|
2010
|
-
"--get",
|
|
2011
|
-
"vde-worktree.baseBranch"
|
|
2730
|
+
"symbolic-ref",
|
|
2731
|
+
"--quiet",
|
|
2732
|
+
"--short",
|
|
2733
|
+
`refs/remotes/${remote}/HEAD`
|
|
2012
2734
|
],
|
|
2013
2735
|
reject: false
|
|
2014
2736
|
});
|
|
2015
|
-
if (
|
|
2016
|
-
const remotesToProbe = [
|
|
2017
|
-
await resolveConfiguredBaseRemote(repoRoot),
|
|
2018
|
-
"origin",
|
|
2019
|
-
"upstream"
|
|
2020
|
-
].filter((value, index, arr) => {
|
|
2021
|
-
return arr.indexOf(value) === index;
|
|
2022
|
-
});
|
|
2023
|
-
for (const remote of remotesToProbe) {
|
|
2024
|
-
const resolved = await runGitCommand({
|
|
2025
|
-
cwd: repoRoot,
|
|
2026
|
-
args: [
|
|
2027
|
-
"symbolic-ref",
|
|
2028
|
-
"--quiet",
|
|
2029
|
-
"--short",
|
|
2030
|
-
`refs/remotes/${remote}/HEAD`
|
|
2031
|
-
],
|
|
2032
|
-
reject: false
|
|
2033
|
-
});
|
|
2034
|
-
if (resolved.exitCode !== 0) continue;
|
|
2737
|
+
if (resolved.exitCode === 0) {
|
|
2035
2738
|
const raw = resolved.stdout.trim();
|
|
2036
2739
|
const prefix = `${remote}/`;
|
|
2037
2740
|
if (raw.startsWith(prefix)) return raw.slice(prefix.length);
|
|
2038
2741
|
}
|
|
2039
2742
|
for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
|
|
2040
|
-
throw createCliError("INVALID_ARGUMENT", {
|
|
2743
|
+
throw createCliError("INVALID_ARGUMENT", {
|
|
2744
|
+
message: "Unable to resolve base branch from config.yml (baseRemote/HEAD -> main/master).",
|
|
2745
|
+
details: { remote }
|
|
2746
|
+
});
|
|
2041
2747
|
};
|
|
2042
2748
|
const ensureTargetPathWritable = async (targetPath) => {
|
|
2043
2749
|
try {
|
|
@@ -2230,7 +2936,7 @@ const validateDeleteSafety = ({ target, forceFlags }) => {
|
|
|
2230
2936
|
const resolveLinkTargetPath = ({ sourcePath, destinationPath }) => {
|
|
2231
2937
|
return relative(dirname(destinationPath), sourcePath);
|
|
2232
2938
|
};
|
|
2233
|
-
const resolveFileCopyTargets = ({ repoRoot,
|
|
2939
|
+
const resolveFileCopyTargets = ({ repoRoot, targetWorktreeRoot, relativePath }) => {
|
|
2234
2940
|
const sourcePath = resolveRepoRelativePath({
|
|
2235
2941
|
repoRoot,
|
|
2236
2942
|
relativePath
|
|
@@ -2238,13 +2944,32 @@ const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
|
|
|
2238
2944
|
const relativeFromRoot = relative(repoRoot, sourcePath);
|
|
2239
2945
|
return {
|
|
2240
2946
|
sourcePath,
|
|
2241
|
-
destinationPath:
|
|
2242
|
-
|
|
2243
|
-
path: resolve(
|
|
2947
|
+
destinationPath: ensurePathInsideRoot({
|
|
2948
|
+
rootPath: targetWorktreeRoot,
|
|
2949
|
+
path: resolve(targetWorktreeRoot, relativeFromRoot),
|
|
2950
|
+
message: "Path is outside target worktree root"
|
|
2244
2951
|
}),
|
|
2245
2952
|
relativeFromRoot
|
|
2246
2953
|
};
|
|
2247
2954
|
};
|
|
2955
|
+
const resolveTargetWorktreeRootForCopyLink = ({ repoContext, snapshot }) => {
|
|
2956
|
+
const rawTarget = process.env.WT_WORKTREE_PATH ?? repoContext.currentWorktreeRoot;
|
|
2957
|
+
const resolvedTarget = resolvePathFromCwd({
|
|
2958
|
+
cwd: repoContext.currentWorktreeRoot,
|
|
2959
|
+
path: rawTarget
|
|
2960
|
+
});
|
|
2961
|
+
const matched = snapshot.worktrees.filter((worktree) => {
|
|
2962
|
+
return worktree.path === resolvedTarget || resolvedTarget.startsWith(`${worktree.path}${sep}`);
|
|
2963
|
+
}).sort((a, b) => b.path.length - a.path.length)[0];
|
|
2964
|
+
if (matched === void 0) throw createCliError("WORKTREE_NOT_FOUND", {
|
|
2965
|
+
message: "copy/link target worktree not found",
|
|
2966
|
+
details: {
|
|
2967
|
+
rawTarget,
|
|
2968
|
+
resolvedTarget
|
|
2969
|
+
}
|
|
2970
|
+
});
|
|
2971
|
+
return matched.path;
|
|
2972
|
+
};
|
|
2248
2973
|
const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
|
|
2249
2974
|
if (branch !== baseBranch) return;
|
|
2250
2975
|
throw createCliError("INVALID_ARGUMENT", {
|
|
@@ -2255,12 +2980,14 @@ const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
|
|
|
2255
2980
|
}
|
|
2256
2981
|
});
|
|
2257
2982
|
};
|
|
2258
|
-
const toManagedWorktreeName = ({
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2983
|
+
const toManagedWorktreeName = ({ managedWorktreeRoot, worktreePath }) => {
|
|
2984
|
+
if (isManagedWorktreePath({
|
|
2985
|
+
worktreePath,
|
|
2986
|
+
managedWorktreeRoot
|
|
2987
|
+
}) !== true) return null;
|
|
2988
|
+
return relative(managedWorktreeRoot, worktreePath).split(sep).join("/");
|
|
2262
2989
|
};
|
|
2263
|
-
const resolveManagedWorktreePathFromName = ({
|
|
2990
|
+
const resolveManagedWorktreePathFromName = ({ managedWorktreeRoot, optionName, worktreeName }) => {
|
|
2264
2991
|
const normalized = worktreeName.trim();
|
|
2265
2992
|
if (normalized.length === 0) throw createCliError("INVALID_ARGUMENT", {
|
|
2266
2993
|
message: `${optionName} requires non-empty worktree name`,
|
|
@@ -2269,18 +2996,10 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
|
|
|
2269
2996
|
worktreeName
|
|
2270
2997
|
}
|
|
2271
2998
|
});
|
|
2272
|
-
if (normalized === ".worktree" || normalized.startsWith(".worktree/") || normalized.startsWith(".worktree\\")) throw createCliError("INVALID_ARGUMENT", {
|
|
2273
|
-
message: `${optionName} expects vw-managed worktree name (without .worktree/ prefix)`,
|
|
2274
|
-
details: {
|
|
2275
|
-
optionName,
|
|
2276
|
-
worktreeName
|
|
2277
|
-
}
|
|
2278
|
-
});
|
|
2279
|
-
const worktreeRoot = getWorktreeRootPath(repoRoot);
|
|
2280
2999
|
let resolvedPath;
|
|
2281
3000
|
try {
|
|
2282
3001
|
resolvedPath = resolveRepoRelativePath({
|
|
2283
|
-
repoRoot:
|
|
3002
|
+
repoRoot: managedWorktreeRoot,
|
|
2284
3003
|
relativePath: normalized
|
|
2285
3004
|
});
|
|
2286
3005
|
} catch (error) {
|
|
@@ -2293,7 +3012,7 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
|
|
|
2293
3012
|
cause: error
|
|
2294
3013
|
});
|
|
2295
3014
|
}
|
|
2296
|
-
if (resolvedPath ===
|
|
3015
|
+
if (resolvedPath === managedWorktreeRoot) throw createCliError("INVALID_ARGUMENT", {
|
|
2297
3016
|
message: `${optionName} expects vw-managed worktree name`,
|
|
2298
3017
|
details: {
|
|
2299
3018
|
optionName,
|
|
@@ -2302,16 +3021,16 @@ const resolveManagedWorktreePathFromName = ({ repoRoot, optionName, worktreeName
|
|
|
2302
3021
|
});
|
|
2303
3022
|
return resolvedPath;
|
|
2304
3023
|
};
|
|
2305
|
-
const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, branch, worktrees, optionName, worktreeName, role }) => {
|
|
3024
|
+
const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, managedWorktreeRoot, branch, worktrees, optionName, worktreeName, role }) => {
|
|
2306
3025
|
const managedCandidates = worktrees.filter((worktree) => {
|
|
2307
3026
|
return worktree.branch === branch && worktree.path !== repoRoot && toManagedWorktreeName({
|
|
2308
|
-
|
|
3027
|
+
managedWorktreeRoot,
|
|
2309
3028
|
worktreePath: worktree.path
|
|
2310
3029
|
}) !== null;
|
|
2311
3030
|
});
|
|
2312
3031
|
if (typeof worktreeName === "string") {
|
|
2313
3032
|
const resolvedPath = resolveManagedWorktreePathFromName({
|
|
2314
|
-
|
|
3033
|
+
managedWorktreeRoot,
|
|
2315
3034
|
optionName,
|
|
2316
3035
|
worktreeName
|
|
2317
3036
|
});
|
|
@@ -2342,7 +3061,7 @@ const resolveManagedNonPrimaryWorktreeByBranch = ({ repoRoot, branch, worktrees,
|
|
|
2342
3061
|
optionName,
|
|
2343
3062
|
candidates: managedCandidates.map((worktree) => {
|
|
2344
3063
|
return toManagedWorktreeName({
|
|
2345
|
-
|
|
3064
|
+
managedWorktreeRoot,
|
|
2346
3065
|
worktreePath: worktree.path
|
|
2347
3066
|
}) ?? worktree.path;
|
|
2348
3067
|
})
|
|
@@ -2499,19 +3218,24 @@ const resolveListColumnContentWidth = ({ rows, columnIndex }) => {
|
|
|
2499
3218
|
return Math.max(width, stringWidth(cell));
|
|
2500
3219
|
}, 0);
|
|
2501
3220
|
};
|
|
2502
|
-
const resolveListPathColumnWidth = ({ rows,
|
|
2503
|
-
|
|
3221
|
+
const resolveListPathColumnWidth = ({ rows, columns, truncateMode, fullPath, minWidth }) => {
|
|
3222
|
+
const pathColumnIndex = columns.indexOf("path");
|
|
3223
|
+
if (pathColumnIndex < 0) return null;
|
|
3224
|
+
if (fullPath || truncateMode === "never") return null;
|
|
2504
3225
|
if (process.stdout.isTTY !== true) return null;
|
|
2505
3226
|
const terminalColumns = process.stdout.columns;
|
|
2506
3227
|
if (typeof terminalColumns !== "number" || Number.isFinite(terminalColumns) !== true || terminalColumns <= 0) return null;
|
|
2507
|
-
const measuredNonPathWidth =
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
3228
|
+
const measuredNonPathWidth = columns.map((_, index) => {
|
|
3229
|
+
if (index === pathColumnIndex) return 0;
|
|
3230
|
+
return resolveListColumnContentWidth({
|
|
3231
|
+
rows,
|
|
3232
|
+
columnIndex: index
|
|
3233
|
+
});
|
|
3234
|
+
}).reduce((sum, width) => sum + width, 0);
|
|
3235
|
+
const borderWidth = columns.length + 1;
|
|
3236
|
+
const paddingWidth = columns.length * LIST_TABLE_CELL_HORIZONTAL_PADDING;
|
|
2513
3237
|
const availablePathWidth = Math.floor(terminalColumns) - borderWidth - paddingWidth - measuredNonPathWidth;
|
|
2514
|
-
return Math.max(
|
|
3238
|
+
return Math.max(minWidth, availablePathWidth);
|
|
2515
3239
|
};
|
|
2516
3240
|
const resolveAheadBehindAgainstBaseBranch = async ({ repoRoot, baseBranch, worktree }) => {
|
|
2517
3241
|
if (baseBranch === null) return {
|
|
@@ -2852,7 +3576,7 @@ const createCli = (options = {}) => {
|
|
|
2852
3576
|
from: {
|
|
2853
3577
|
type: "string",
|
|
2854
3578
|
valueHint: "value",
|
|
2855
|
-
description: "For extract: filesystem path. For absorb: managed worktree name
|
|
3579
|
+
description: "For extract: filesystem path. For absorb: managed worktree name."
|
|
2856
3580
|
},
|
|
2857
3581
|
to: {
|
|
2858
3582
|
type: "string",
|
|
@@ -3001,39 +3725,47 @@ const createCli = (options = {}) => {
|
|
|
3001
3725
|
const repoContext = await resolveRepoContext(runtimeCwd);
|
|
3002
3726
|
const repoRoot = repoContext.repoRoot;
|
|
3003
3727
|
repoRootForJson = repoRoot;
|
|
3004
|
-
const
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3728
|
+
const { config: resolvedConfig } = await loadResolvedConfig({
|
|
3729
|
+
cwd: runtimeCwd,
|
|
3730
|
+
repoRoot
|
|
3731
|
+
});
|
|
3732
|
+
const managedWorktreeRoot = getWorktreeRootPath(repoRoot, resolvedConfig.paths.worktreeRoot);
|
|
3008
3733
|
const runtime = {
|
|
3009
3734
|
command,
|
|
3010
3735
|
json: jsonEnabled,
|
|
3011
|
-
hooksEnabled: parsedArgs.hooks !== false &&
|
|
3012
|
-
ghEnabled: parsedArgs.gh !== false,
|
|
3736
|
+
hooksEnabled: parsedArgs.hooks !== false && resolvedConfig.hooks.enabled,
|
|
3737
|
+
ghEnabled: parsedArgs.gh !== false && resolvedConfig.github.enabled,
|
|
3013
3738
|
strictPostHooks: parsedArgs.strictPostHooks === true,
|
|
3014
3739
|
hookTimeoutMs: readNumberFromEnvOrDefault({
|
|
3015
3740
|
rawValue: toNumberOption({
|
|
3016
3741
|
value: parsedArgs.hookTimeoutMs,
|
|
3017
3742
|
optionName: "--hook-timeout-ms"
|
|
3018
|
-
}) ??
|
|
3743
|
+
}) ?? resolvedConfig.hooks.timeoutMs,
|
|
3019
3744
|
defaultValue: DEFAULT_HOOK_TIMEOUT_MS
|
|
3020
3745
|
}),
|
|
3021
3746
|
lockTimeoutMs: readNumberFromEnvOrDefault({
|
|
3022
3747
|
rawValue: toNumberOption({
|
|
3023
3748
|
value: parsedArgs.lockTimeoutMs,
|
|
3024
3749
|
optionName: "--lock-timeout-ms"
|
|
3025
|
-
}) ??
|
|
3750
|
+
}) ?? resolvedConfig.locks.timeoutMs,
|
|
3026
3751
|
defaultValue: DEFAULT_LOCK_TIMEOUT_MS
|
|
3027
3752
|
}),
|
|
3028
3753
|
allowUnsafe,
|
|
3029
3754
|
isInteractive: isInteractiveFn()
|
|
3030
3755
|
};
|
|
3031
3756
|
const staleLockTTLSeconds = readNumberFromEnvOrDefault({
|
|
3032
|
-
rawValue:
|
|
3757
|
+
rawValue: resolvedConfig.locks.staleLockTTLSeconds,
|
|
3033
3758
|
defaultValue: DEFAULT_STALE_LOCK_TTL_SECONDS
|
|
3034
3759
|
});
|
|
3035
3760
|
const collectWorktreeSnapshot$1 = async (_ignoredRepoRoot) => {
|
|
3036
|
-
return collectWorktreeSnapshot(repoRoot, {
|
|
3761
|
+
return collectWorktreeSnapshot(repoRoot, {
|
|
3762
|
+
baseBranch: await resolveBaseBranch({
|
|
3763
|
+
repoRoot,
|
|
3764
|
+
config: resolvedConfig
|
|
3765
|
+
}),
|
|
3766
|
+
ghEnabled: runtime.ghEnabled,
|
|
3767
|
+
noGh: runtime.ghEnabled !== true
|
|
3768
|
+
});
|
|
3037
3769
|
};
|
|
3038
3770
|
const runWriteOperation = async (task) => {
|
|
3039
3771
|
if (WRITE_COMMANDS.has(command) !== true) return task();
|
|
@@ -3065,7 +3797,10 @@ const createCli = (options = {}) => {
|
|
|
3065
3797
|
name: "init",
|
|
3066
3798
|
context: hookContext
|
|
3067
3799
|
});
|
|
3068
|
-
const initialized = await initializeRepository(
|
|
3800
|
+
const initialized = await initializeRepository({
|
|
3801
|
+
repoRoot,
|
|
3802
|
+
managedWorktreeRoot
|
|
3803
|
+
});
|
|
3069
3804
|
await runPostHook({
|
|
3070
3805
|
name: "init",
|
|
3071
3806
|
context: hookContext
|
|
@@ -3101,22 +3836,15 @@ const createCli = (options = {}) => {
|
|
|
3101
3836
|
repoRoot,
|
|
3102
3837
|
details: {
|
|
3103
3838
|
baseBranch: snapshot.baseBranch,
|
|
3839
|
+
managedWorktreeRoot,
|
|
3104
3840
|
worktrees: snapshot.worktrees
|
|
3105
3841
|
}
|
|
3106
3842
|
})));
|
|
3107
3843
|
return EXIT_CODE.OK;
|
|
3108
3844
|
}
|
|
3109
3845
|
const theme = createCatppuccinTheme({ enabled: shouldUseAnsiColors({ interactive: runtime.isInteractive }) });
|
|
3110
|
-
const
|
|
3111
|
-
|
|
3112
|
-
"dirty",
|
|
3113
|
-
"merged",
|
|
3114
|
-
"pr",
|
|
3115
|
-
"locked",
|
|
3116
|
-
"ahead",
|
|
3117
|
-
"behind",
|
|
3118
|
-
"path"
|
|
3119
|
-
], ...await Promise.all(snapshot.worktrees.map(async (worktree) => {
|
|
3846
|
+
const columns = resolvedConfig.list.table.columns;
|
|
3847
|
+
const rows = [[...columns], ...await Promise.all(snapshot.worktrees.map(async (worktree) => {
|
|
3120
3848
|
const distanceFromBase = await resolveAheadBehindAgainstBaseBranch({
|
|
3121
3849
|
repoRoot,
|
|
3122
3850
|
baseBranch: snapshot.baseBranch,
|
|
@@ -3128,35 +3856,41 @@ const createCli = (options = {}) => {
|
|
|
3128
3856
|
prStatus: worktree.pr.status,
|
|
3129
3857
|
isBaseBranch
|
|
3130
3858
|
});
|
|
3131
|
-
|
|
3132
|
-
`${worktree.path === repoContext.currentWorktreeRoot ? "*" : " "} ${worktree.branch ?? "(detached)"}`,
|
|
3133
|
-
worktree.dirty ? "dirty" : "clean",
|
|
3134
|
-
mergedState,
|
|
3135
|
-
prState,
|
|
3136
|
-
worktree.locked.value ? "locked" : "-",
|
|
3137
|
-
formatListUpstreamCount(distanceFromBase.ahead),
|
|
3138
|
-
formatListUpstreamCount(distanceFromBase.behind),
|
|
3139
|
-
formatDisplayPath(worktree.path)
|
|
3140
|
-
|
|
3859
|
+
const valuesByColumn = {
|
|
3860
|
+
branch: `${worktree.path === repoContext.currentWorktreeRoot ? "*" : " "} ${worktree.branch ?? "(detached)"}`,
|
|
3861
|
+
dirty: worktree.dirty ? "dirty" : "clean",
|
|
3862
|
+
merged: mergedState,
|
|
3863
|
+
pr: prState,
|
|
3864
|
+
locked: worktree.locked.value ? "locked" : "-",
|
|
3865
|
+
ahead: formatListUpstreamCount(distanceFromBase.ahead),
|
|
3866
|
+
behind: formatListUpstreamCount(distanceFromBase.behind),
|
|
3867
|
+
path: formatDisplayPath(worktree.path)
|
|
3868
|
+
};
|
|
3869
|
+
return columns.map((column) => valuesByColumn[column]);
|
|
3141
3870
|
}))];
|
|
3142
3871
|
const pathColumnWidth = resolveListPathColumnWidth({
|
|
3143
3872
|
rows,
|
|
3144
|
-
|
|
3873
|
+
columns,
|
|
3874
|
+
truncateMode: resolvedConfig.list.table.path.truncate,
|
|
3875
|
+
fullPath: parsedArgs.fullPath === true,
|
|
3876
|
+
minWidth: resolvedConfig.list.table.path.minWidth
|
|
3145
3877
|
});
|
|
3146
|
-
const
|
|
3878
|
+
const pathColumnIndex = columns.indexOf("path");
|
|
3879
|
+
const columnsConfig = pathColumnWidth === null || pathColumnIndex < 0 ? void 0 : { [pathColumnIndex]: {
|
|
3147
3880
|
width: pathColumnWidth,
|
|
3148
3881
|
truncate: pathColumnWidth
|
|
3149
3882
|
} };
|
|
3150
|
-
const
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
columns: columnsConfig
|
|
3157
|
-
}),
|
|
3158
|
-
theme
|
|
3883
|
+
const rendered = table(rows, {
|
|
3884
|
+
border: getBorderCharacters("norc"),
|
|
3885
|
+
drawHorizontalLine: (lineIndex, rowCount) => {
|
|
3886
|
+
return lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount;
|
|
3887
|
+
},
|
|
3888
|
+
columns: columnsConfig
|
|
3159
3889
|
});
|
|
3890
|
+
const colorized = hasDefaultListColumnOrder(columns) ? colorizeListTable({
|
|
3891
|
+
rendered,
|
|
3892
|
+
theme
|
|
3893
|
+
}) : rendered.trimEnd();
|
|
3160
3894
|
for (const line of colorized.split("\n")) stdout(line);
|
|
3161
3895
|
return EXIT_CODE.OK;
|
|
3162
3896
|
}
|
|
@@ -3238,9 +3972,12 @@ const createCli = (options = {}) => {
|
|
|
3238
3972
|
message: `Branch already exists locally: ${branch}`,
|
|
3239
3973
|
details: { branch }
|
|
3240
3974
|
});
|
|
3241
|
-
const targetPath = branchToWorktreePath(repoRoot, branch);
|
|
3975
|
+
const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
|
|
3242
3976
|
await ensureTargetPathWritable(targetPath);
|
|
3243
|
-
const baseBranch = await resolveBaseBranch(
|
|
3977
|
+
const baseBranch = await resolveBaseBranch({
|
|
3978
|
+
repoRoot,
|
|
3979
|
+
config: resolvedConfig
|
|
3980
|
+
});
|
|
3244
3981
|
const hookContext = createHookContext({
|
|
3245
3982
|
runtime,
|
|
3246
3983
|
repoRoot,
|
|
@@ -3315,7 +4052,7 @@ const createCli = (options = {}) => {
|
|
|
3315
4052
|
path: existing.path
|
|
3316
4053
|
};
|
|
3317
4054
|
}
|
|
3318
|
-
const targetPath = branchToWorktreePath(repoRoot, branch);
|
|
4055
|
+
const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
|
|
3319
4056
|
await ensureTargetPathWritable(targetPath);
|
|
3320
4057
|
const hookContext = createHookContext({
|
|
3321
4058
|
runtime,
|
|
@@ -3340,7 +4077,10 @@ const createCli = (options = {}) => {
|
|
|
3340
4077
|
]
|
|
3341
4078
|
});
|
|
3342
4079
|
else {
|
|
3343
|
-
const baseBranch = await resolveBaseBranch(
|
|
4080
|
+
const baseBranch = await resolveBaseBranch({
|
|
4081
|
+
repoRoot,
|
|
4082
|
+
config: resolvedConfig
|
|
4083
|
+
});
|
|
3344
4084
|
lifecycleBaseBranch = baseBranch;
|
|
3345
4085
|
await runGitCommand({
|
|
3346
4086
|
cwd: repoRoot,
|
|
@@ -3423,7 +4163,7 @@ const createCli = (options = {}) => {
|
|
|
3423
4163
|
message: `Branch already exists locally: ${newBranch}`,
|
|
3424
4164
|
details: { branch: newBranch }
|
|
3425
4165
|
});
|
|
3426
|
-
const newPath = branchToWorktreePath(repoRoot, newBranch);
|
|
4166
|
+
const newPath = branchToWorktreePath(repoRoot, newBranch, resolvedConfig.paths.worktreeRoot);
|
|
3427
4167
|
await ensureTargetPathWritable(newPath);
|
|
3428
4168
|
const hookContext = createHookContext({
|
|
3429
4169
|
runtime,
|
|
@@ -3517,6 +4257,17 @@ const createCli = (options = {}) => {
|
|
|
3517
4257
|
message: "Cannot delete the primary worktree",
|
|
3518
4258
|
details: { path: target.path }
|
|
3519
4259
|
});
|
|
4260
|
+
if (isManagedWorktreePath({
|
|
4261
|
+
worktreePath: target.path,
|
|
4262
|
+
managedWorktreeRoot
|
|
4263
|
+
}) !== true) throw createCliError("WORKTREE_NOT_FOUND", {
|
|
4264
|
+
message: "Target branch is not in managed worktree root",
|
|
4265
|
+
details: {
|
|
4266
|
+
branch: target.branch,
|
|
4267
|
+
path: target.path,
|
|
4268
|
+
managedWorktreeRoot
|
|
4269
|
+
}
|
|
4270
|
+
});
|
|
3520
4271
|
validateDeleteSafety({
|
|
3521
4272
|
target,
|
|
3522
4273
|
forceFlags
|
|
@@ -3590,7 +4341,10 @@ const createCli = (options = {}) => {
|
|
|
3590
4341
|
if (parsedArgs.apply === true && parsedArgs.dryRun === true) throw createCliError("INVALID_ARGUMENT", { message: "Cannot use --apply and --dry-run together" });
|
|
3591
4342
|
const dryRun = parsedArgs.apply !== true;
|
|
3592
4343
|
const execute = async () => {
|
|
3593
|
-
const candidates = (await collectWorktreeSnapshot$1(repoRoot)).worktrees.filter((worktree) => worktree.branch !== null).filter((worktree) => worktree.path !== repoRoot).filter((worktree) =>
|
|
4344
|
+
const candidates = (await collectWorktreeSnapshot$1(repoRoot)).worktrees.filter((worktree) => worktree.branch !== null).filter((worktree) => worktree.path !== repoRoot).filter((worktree) => isManagedWorktreePath({
|
|
4345
|
+
worktreePath: worktree.path,
|
|
4346
|
+
managedWorktreeRoot
|
|
4347
|
+
})).filter((worktree) => worktree.dirty === false).filter((worktree) => worktree.locked.value === false).filter((worktree) => worktree.merged.overall === true).map((worktree) => worktree.branch);
|
|
3594
4348
|
if (dryRun) return {
|
|
3595
4349
|
deleted: [],
|
|
3596
4350
|
candidates,
|
|
@@ -3695,7 +4449,7 @@ const createCli = (options = {}) => {
|
|
|
3695
4449
|
repoRoot,
|
|
3696
4450
|
action: "get",
|
|
3697
4451
|
branch,
|
|
3698
|
-
worktreePath: branchToWorktreePath(repoRoot, branch),
|
|
4452
|
+
worktreePath: branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot),
|
|
3699
4453
|
stderr
|
|
3700
4454
|
});
|
|
3701
4455
|
await runPreHook({
|
|
@@ -3746,7 +4500,7 @@ const createCli = (options = {}) => {
|
|
|
3746
4500
|
path: existing.path
|
|
3747
4501
|
};
|
|
3748
4502
|
}
|
|
3749
|
-
const targetPath = branchToWorktreePath(repoRoot, branch);
|
|
4503
|
+
const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
|
|
3750
4504
|
await ensureTargetPathWritable(targetPath);
|
|
3751
4505
|
await runGitCommand({
|
|
3752
4506
|
cwd: repoRoot,
|
|
@@ -3820,12 +4574,15 @@ const createCli = (options = {}) => {
|
|
|
3820
4574
|
details: { path: sourceWorktree.path }
|
|
3821
4575
|
});
|
|
3822
4576
|
const branch = sourceWorktree.branch;
|
|
3823
|
-
const baseBranch = await resolveBaseBranch(
|
|
4577
|
+
const baseBranch = await resolveBaseBranch({
|
|
4578
|
+
repoRoot,
|
|
4579
|
+
config: resolvedConfig
|
|
4580
|
+
});
|
|
3824
4581
|
ensureBranchIsNotPrimary({
|
|
3825
4582
|
branch,
|
|
3826
4583
|
baseBranch
|
|
3827
4584
|
});
|
|
3828
|
-
const targetPath = branchToWorktreePath(repoRoot, branch);
|
|
4585
|
+
const targetPath = branchToWorktreePath(repoRoot, branch, resolvedConfig.paths.worktreeRoot);
|
|
3829
4586
|
await ensureTargetPathWritable(targetPath);
|
|
3830
4587
|
const dirty = (await runGitCommand({
|
|
3831
4588
|
cwd: repoRoot,
|
|
@@ -3946,6 +4703,7 @@ const createCli = (options = {}) => {
|
|
|
3946
4703
|
});
|
|
3947
4704
|
const sourceWorktree = resolveManagedNonPrimaryWorktreeByBranch({
|
|
3948
4705
|
repoRoot,
|
|
4706
|
+
managedWorktreeRoot,
|
|
3949
4707
|
branch,
|
|
3950
4708
|
worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees,
|
|
3951
4709
|
optionName: "--from",
|
|
@@ -4080,6 +4838,7 @@ const createCli = (options = {}) => {
|
|
|
4080
4838
|
});
|
|
4081
4839
|
const targetWorktree = resolveManagedNonPrimaryWorktreeByBranch({
|
|
4082
4840
|
repoRoot,
|
|
4841
|
+
managedWorktreeRoot,
|
|
4083
4842
|
branch,
|
|
4084
4843
|
worktrees: (await collectWorktreeSnapshot$1(repoRoot)).worktrees,
|
|
4085
4844
|
optionName: "--to",
|
|
@@ -4367,17 +5126,14 @@ const createCli = (options = {}) => {
|
|
|
4367
5126
|
min: 1,
|
|
4368
5127
|
max: Number.MAX_SAFE_INTEGER
|
|
4369
5128
|
});
|
|
4370
|
-
const
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
cwd: repoContext.currentWorktreeRoot,
|
|
4374
|
-
path: process.env.WT_WORKTREE_PATH ?? "."
|
|
4375
|
-
})
|
|
5129
|
+
const targetWorktreeRoot = resolveTargetWorktreeRootForCopyLink({
|
|
5130
|
+
repoContext,
|
|
5131
|
+
snapshot: await collectWorktreeSnapshot$1(repoRoot)
|
|
4376
5132
|
});
|
|
4377
5133
|
for (const relativePath of commandArgs) {
|
|
4378
5134
|
const { sourcePath, destinationPath } = resolveFileCopyTargets({
|
|
4379
5135
|
repoRoot,
|
|
4380
|
-
|
|
5136
|
+
targetWorktreeRoot,
|
|
4381
5137
|
relativePath
|
|
4382
5138
|
});
|
|
4383
5139
|
await access(sourcePath, constants.F_OK);
|
|
@@ -4396,7 +5152,7 @@ const createCli = (options = {}) => {
|
|
|
4396
5152
|
repoRoot,
|
|
4397
5153
|
details: {
|
|
4398
5154
|
copied: commandArgs,
|
|
4399
|
-
worktreePath
|
|
5155
|
+
worktreePath: targetWorktreeRoot
|
|
4400
5156
|
}
|
|
4401
5157
|
})));
|
|
4402
5158
|
return EXIT_CODE.OK;
|
|
@@ -4410,18 +5166,15 @@ const createCli = (options = {}) => {
|
|
|
4410
5166
|
min: 1,
|
|
4411
5167
|
max: Number.MAX_SAFE_INTEGER
|
|
4412
5168
|
});
|
|
4413
|
-
const
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
cwd: repoContext.currentWorktreeRoot,
|
|
4417
|
-
path: process.env.WT_WORKTREE_PATH ?? "."
|
|
4418
|
-
})
|
|
5169
|
+
const targetWorktreeRoot = resolveTargetWorktreeRootForCopyLink({
|
|
5170
|
+
repoContext,
|
|
5171
|
+
snapshot: await collectWorktreeSnapshot$1(repoRoot)
|
|
4419
5172
|
});
|
|
4420
5173
|
const fallbackEnabled = parsedArgs.fallback !== false;
|
|
4421
5174
|
for (const relativePath of commandArgs) {
|
|
4422
5175
|
const { sourcePath, destinationPath } = resolveFileCopyTargets({
|
|
4423
5176
|
repoRoot,
|
|
4424
|
-
|
|
5177
|
+
targetWorktreeRoot,
|
|
4425
5178
|
relativePath
|
|
4426
5179
|
});
|
|
4427
5180
|
await access(sourcePath, constants.F_OK);
|
|
@@ -4459,7 +5212,7 @@ const createCli = (options = {}) => {
|
|
|
4459
5212
|
repoRoot,
|
|
4460
5213
|
details: {
|
|
4461
5214
|
linked: commandArgs,
|
|
4462
|
-
worktreePath,
|
|
5215
|
+
worktreePath: targetWorktreeRoot,
|
|
4463
5216
|
fallback: fallbackEnabled
|
|
4464
5217
|
}
|
|
4465
5218
|
})));
|
|
@@ -4614,16 +5367,25 @@ const createCli = (options = {}) => {
|
|
|
4614
5367
|
}));
|
|
4615
5368
|
if (candidates.length === 0) throw createCliError("WORKTREE_NOT_FOUND", { message: "No worktree candidates found" });
|
|
4616
5369
|
const promptValue = readStringOption(parsedArgsRecord, "prompt");
|
|
5370
|
+
const prompt = typeof promptValue === "string" && promptValue.length > 0 ? promptValue : resolvedConfig.selector.cd.prompt;
|
|
5371
|
+
const cliFzfExtraArgs = collectOptionValues({
|
|
5372
|
+
args: beforeDoubleDash,
|
|
5373
|
+
optionNames: ["fzfArg", "fzf-arg"]
|
|
5374
|
+
});
|
|
5375
|
+
const mergedConfigFzfArgs = mergeFzfArgs({
|
|
5376
|
+
defaults: resolvedConfig.selector.cd.fzf.extraArgs,
|
|
5377
|
+
extras: cliFzfExtraArgs
|
|
5378
|
+
});
|
|
5379
|
+
const surface = resolvedConfig.selector.cd.surface;
|
|
4617
5380
|
const selection = await selectPathWithFzf$1({
|
|
4618
5381
|
candidates,
|
|
4619
|
-
prompt
|
|
5382
|
+
prompt,
|
|
4620
5383
|
fzfExtraArgs: mergeFzfArgs({
|
|
4621
5384
|
defaults: CD_FZF_EXTRA_ARGS,
|
|
4622
|
-
extras:
|
|
4623
|
-
args: beforeDoubleDash,
|
|
4624
|
-
optionNames: ["fzfArg", "fzf-arg"]
|
|
4625
|
-
})
|
|
5385
|
+
extras: mergedConfigFzfArgs
|
|
4626
5386
|
}),
|
|
5387
|
+
surface,
|
|
5388
|
+
tmuxPopupOpts: resolvedConfig.selector.cd.tmuxPopupOpts,
|
|
4627
5389
|
cwd: repoRoot,
|
|
4628
5390
|
isInteractive: () => runtime.isInteractive || process.stderr.isTTY === true
|
|
4629
5391
|
}).catch((error) => {
|