vde-worktree 0.0.1 → 0.0.3
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 +395 -0
- package/README.md +376 -28
- package/bin/vde-worktree +38 -0
- package/bin/vw +38 -0
- package/completions/fish/vw.fish +170 -0
- package/completions/zsh/_vw +287 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +3355 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +78 -6
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { access, appendFile, chmod, cp, mkdir, open, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseArgs } from "citty";
|
|
9
|
+
import { execa } from "execa";
|
|
10
|
+
import stringWidth from "string-width";
|
|
11
|
+
import chalk from "chalk";
|
|
12
|
+
|
|
13
|
+
//#region src/core/constants.ts
|
|
14
|
+
const SCHEMA_VERSION = 1;
|
|
15
|
+
const EXIT_CODE = {
|
|
16
|
+
OK: 0,
|
|
17
|
+
NOT_GIT_REPOSITORY: 2,
|
|
18
|
+
INVALID_ARGUMENT: 3,
|
|
19
|
+
SAFETY_REJECTED: 4,
|
|
20
|
+
DEPENDENCY_MISSING: 5,
|
|
21
|
+
LOCK_FAILED: 6,
|
|
22
|
+
HOOK_FAILED: 10,
|
|
23
|
+
GIT_COMMAND_FAILED: 20,
|
|
24
|
+
CHILD_PROCESS_FAILED: 21,
|
|
25
|
+
INTERNAL_ERROR: 30
|
|
26
|
+
};
|
|
27
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 3e4;
|
|
28
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 15e3;
|
|
29
|
+
const DEFAULT_STALE_LOCK_TTL_SECONDS = 1800;
|
|
30
|
+
const COMMAND_NAMES = {
|
|
31
|
+
INIT: "init",
|
|
32
|
+
LIST: "list",
|
|
33
|
+
STATUS: "status",
|
|
34
|
+
PATH: "path",
|
|
35
|
+
SWITCH: "switch",
|
|
36
|
+
NEW: "new",
|
|
37
|
+
MV: "mv",
|
|
38
|
+
DEL: "del",
|
|
39
|
+
GONE: "gone",
|
|
40
|
+
GET: "get",
|
|
41
|
+
EXTRACT: "extract",
|
|
42
|
+
USE: "use",
|
|
43
|
+
EXEC: "exec",
|
|
44
|
+
INVOKE: "invoke",
|
|
45
|
+
COPY: "copy",
|
|
46
|
+
LINK: "link",
|
|
47
|
+
LOCK: "lock",
|
|
48
|
+
UNLOCK: "unlock",
|
|
49
|
+
CD: "cd",
|
|
50
|
+
COMPLETION: "completion"
|
|
51
|
+
};
|
|
52
|
+
const WRITE_COMMANDS = new Set([
|
|
53
|
+
COMMAND_NAMES.INIT,
|
|
54
|
+
COMMAND_NAMES.SWITCH,
|
|
55
|
+
COMMAND_NAMES.NEW,
|
|
56
|
+
COMMAND_NAMES.MV,
|
|
57
|
+
COMMAND_NAMES.DEL,
|
|
58
|
+
COMMAND_NAMES.GONE,
|
|
59
|
+
COMMAND_NAMES.GET,
|
|
60
|
+
COMMAND_NAMES.EXTRACT,
|
|
61
|
+
COMMAND_NAMES.USE,
|
|
62
|
+
COMMAND_NAMES.LOCK,
|
|
63
|
+
COMMAND_NAMES.UNLOCK
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/core/errors.ts
|
|
68
|
+
const ERROR_CODE_TO_EXIT_CODE = {
|
|
69
|
+
NOT_GIT_REPOSITORY: EXIT_CODE.NOT_GIT_REPOSITORY,
|
|
70
|
+
INVALID_ARGUMENT: EXIT_CODE.INVALID_ARGUMENT,
|
|
71
|
+
UNKNOWN_COMMAND: EXIT_CODE.INVALID_ARGUMENT,
|
|
72
|
+
UNSAFE_FLAG_REQUIRED: EXIT_CODE.SAFETY_REJECTED,
|
|
73
|
+
NOT_INITIALIZED: EXIT_CODE.SAFETY_REJECTED,
|
|
74
|
+
WORKTREE_NOT_FOUND: EXIT_CODE.SAFETY_REJECTED,
|
|
75
|
+
BRANCH_ALREADY_ATTACHED: EXIT_CODE.SAFETY_REJECTED,
|
|
76
|
+
BRANCH_ALREADY_EXISTS: EXIT_CODE.SAFETY_REJECTED,
|
|
77
|
+
BRANCH_IN_USE: EXIT_CODE.SAFETY_REJECTED,
|
|
78
|
+
TARGET_PATH_NOT_EMPTY: EXIT_CODE.SAFETY_REJECTED,
|
|
79
|
+
PATH_OUTSIDE_REPO: EXIT_CODE.SAFETY_REJECTED,
|
|
80
|
+
ABSOLUTE_PATH_NOT_ALLOWED: EXIT_CODE.SAFETY_REJECTED,
|
|
81
|
+
LOCK_CONFLICT: EXIT_CODE.SAFETY_REJECTED,
|
|
82
|
+
DETACHED_HEAD: EXIT_CODE.SAFETY_REJECTED,
|
|
83
|
+
DIRTY_WORKTREE: EXIT_CODE.SAFETY_REJECTED,
|
|
84
|
+
UNMERGED_WORKTREE: EXIT_CODE.SAFETY_REJECTED,
|
|
85
|
+
UNPUSHED_WORKTREE: EXIT_CODE.SAFETY_REJECTED,
|
|
86
|
+
LOCKED_WORKTREE: EXIT_CODE.SAFETY_REJECTED,
|
|
87
|
+
STASH_APPLY_FAILED: EXIT_CODE.SAFETY_REJECTED,
|
|
88
|
+
REMOTE_NOT_FOUND: EXIT_CODE.SAFETY_REJECTED,
|
|
89
|
+
REMOTE_BRANCH_NOT_FOUND: EXIT_CODE.SAFETY_REJECTED,
|
|
90
|
+
INVALID_REMOTE_BRANCH_FORMAT: EXIT_CODE.INVALID_ARGUMENT,
|
|
91
|
+
HOOK_NOT_FOUND: EXIT_CODE.SAFETY_REJECTED,
|
|
92
|
+
DEPENDENCY_MISSING: EXIT_CODE.DEPENDENCY_MISSING,
|
|
93
|
+
REPO_LOCK_TIMEOUT: EXIT_CODE.LOCK_FAILED,
|
|
94
|
+
REPO_LOCK_STALE_RECOVERY_FAILED: EXIT_CODE.LOCK_FAILED,
|
|
95
|
+
HOOK_NOT_EXECUTABLE: EXIT_CODE.HOOK_FAILED,
|
|
96
|
+
HOOK_TIMEOUT: EXIT_CODE.HOOK_FAILED,
|
|
97
|
+
HOOK_FAILED: EXIT_CODE.HOOK_FAILED,
|
|
98
|
+
GIT_COMMAND_FAILED: EXIT_CODE.GIT_COMMAND_FAILED,
|
|
99
|
+
INTERNAL_ERROR: EXIT_CODE.INTERNAL_ERROR
|
|
100
|
+
};
|
|
101
|
+
var CliError = class extends Error {
|
|
102
|
+
code;
|
|
103
|
+
exitCode;
|
|
104
|
+
details;
|
|
105
|
+
constructor(code, options) {
|
|
106
|
+
super(options.message, { cause: options.cause });
|
|
107
|
+
this.code = code;
|
|
108
|
+
this.exitCode = ERROR_CODE_TO_EXIT_CODE[code];
|
|
109
|
+
this.details = options.details ?? {};
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const createCliError = (code, options) => {
|
|
113
|
+
return new CliError(code, options);
|
|
114
|
+
};
|
|
115
|
+
const ensureCliError = (error) => {
|
|
116
|
+
if (error instanceof CliError) return error;
|
|
117
|
+
if (error instanceof Error) return createCliError("INTERNAL_ERROR", {
|
|
118
|
+
message: error.message,
|
|
119
|
+
cause: error
|
|
120
|
+
});
|
|
121
|
+
return createCliError("INTERNAL_ERROR", {
|
|
122
|
+
message: "An unexpected error occurred",
|
|
123
|
+
details: { value: String(error) }
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/git/exec.ts
|
|
129
|
+
const runGitCommand = async ({ cwd, args, reject = true }) => {
|
|
130
|
+
try {
|
|
131
|
+
const result = await execa("git", [...args], {
|
|
132
|
+
cwd,
|
|
133
|
+
reject
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
stdout: result.stdout,
|
|
137
|
+
stderr: result.stderr,
|
|
138
|
+
exitCode: result.exitCode ?? 0
|
|
139
|
+
};
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const execaError = error;
|
|
142
|
+
throw createCliError("GIT_COMMAND_FAILED", {
|
|
143
|
+
message: "git command failed",
|
|
144
|
+
details: {
|
|
145
|
+
command: ["git", ...args],
|
|
146
|
+
cwd,
|
|
147
|
+
exitCode: execaError.exitCode,
|
|
148
|
+
stdout: execaError.stdout ?? "",
|
|
149
|
+
stderr: execaError.stderr ?? "",
|
|
150
|
+
shortMessage: execaError.shortMessage ?? execaError.message
|
|
151
|
+
},
|
|
152
|
+
cause: error
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const doesGitRefExist = async (cwd, ref) => {
|
|
157
|
+
return (await runGitCommand({
|
|
158
|
+
cwd,
|
|
159
|
+
args: [
|
|
160
|
+
"show-ref",
|
|
161
|
+
"--verify",
|
|
162
|
+
"--quiet",
|
|
163
|
+
ref
|
|
164
|
+
],
|
|
165
|
+
reject: false
|
|
166
|
+
})).exitCode === 0;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/core/paths.ts
|
|
171
|
+
const GIT_DIR_NAME = ".git";
|
|
172
|
+
const resolveRepoRootFromCommonDir = ({ currentWorktreeRoot, gitCommonDir }) => {
|
|
173
|
+
if (gitCommonDir.endsWith(`/${GIT_DIR_NAME}`)) return dirname(gitCommonDir);
|
|
174
|
+
if (gitCommonDir.endsWith(`\\${GIT_DIR_NAME}`)) return dirname(gitCommonDir);
|
|
175
|
+
return currentWorktreeRoot;
|
|
176
|
+
};
|
|
177
|
+
const resolveRepoContext = async (cwd) => {
|
|
178
|
+
const toplevelResult = await runGitCommand({
|
|
179
|
+
cwd,
|
|
180
|
+
args: ["rev-parse", "--show-toplevel"],
|
|
181
|
+
reject: false
|
|
182
|
+
});
|
|
183
|
+
if (toplevelResult.exitCode !== 0) throw createCliError("NOT_GIT_REPOSITORY", {
|
|
184
|
+
message: "Current directory is not inside a Git repository",
|
|
185
|
+
details: { cwd }
|
|
186
|
+
});
|
|
187
|
+
const currentWorktreeRoot = toplevelResult.stdout.trim();
|
|
188
|
+
const commonDirResult = await runGitCommand({
|
|
189
|
+
cwd,
|
|
190
|
+
args: [
|
|
191
|
+
"rev-parse",
|
|
192
|
+
"--path-format=absolute",
|
|
193
|
+
"--git-common-dir"
|
|
194
|
+
],
|
|
195
|
+
reject: false
|
|
196
|
+
});
|
|
197
|
+
const gitCommonDir = commonDirResult.exitCode === 0 ? commonDirResult.stdout.trim() : join(currentWorktreeRoot, GIT_DIR_NAME);
|
|
198
|
+
return {
|
|
199
|
+
repoRoot: resolveRepoRootFromCommonDir({
|
|
200
|
+
currentWorktreeRoot,
|
|
201
|
+
gitCommonDir
|
|
202
|
+
}),
|
|
203
|
+
currentWorktreeRoot,
|
|
204
|
+
gitCommonDir
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
const getWorktreeRootPath = (repoRoot) => {
|
|
208
|
+
return join(repoRoot, ".worktree");
|
|
209
|
+
};
|
|
210
|
+
const getWorktreeMetaRootPath = (repoRoot) => {
|
|
211
|
+
return join(repoRoot, ".vde", "worktree");
|
|
212
|
+
};
|
|
213
|
+
const getHooksDirectoryPath = (repoRoot) => {
|
|
214
|
+
return join(getWorktreeMetaRootPath(repoRoot), "hooks");
|
|
215
|
+
};
|
|
216
|
+
const getLogsDirectoryPath = (repoRoot) => {
|
|
217
|
+
return join(getWorktreeMetaRootPath(repoRoot), "logs");
|
|
218
|
+
};
|
|
219
|
+
const getLocksDirectoryPath = (repoRoot) => {
|
|
220
|
+
return join(getWorktreeMetaRootPath(repoRoot), "locks");
|
|
221
|
+
};
|
|
222
|
+
const getStateDirectoryPath = (repoRoot) => {
|
|
223
|
+
return join(getWorktreeMetaRootPath(repoRoot), "state");
|
|
224
|
+
};
|
|
225
|
+
const branchToWorktreeId = (branch) => {
|
|
226
|
+
return encodeURIComponent(branch);
|
|
227
|
+
};
|
|
228
|
+
const branchToWorktreePath = (repoRoot, branch) => {
|
|
229
|
+
return join(getWorktreeRootPath(repoRoot), branchToWorktreeId(branch));
|
|
230
|
+
};
|
|
231
|
+
const ensurePathInsideRepo = ({ repoRoot, path }) => {
|
|
232
|
+
const rel = relative(repoRoot, path);
|
|
233
|
+
if (rel === "") return path;
|
|
234
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) throw createCliError("PATH_OUTSIDE_REPO", {
|
|
235
|
+
message: "Path is outside repository root",
|
|
236
|
+
details: {
|
|
237
|
+
repoRoot,
|
|
238
|
+
path
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return path;
|
|
242
|
+
};
|
|
243
|
+
const resolveRepoRelativePath = ({ repoRoot, relativePath }) => {
|
|
244
|
+
if (isAbsolute(relativePath)) throw createCliError("ABSOLUTE_PATH_NOT_ALLOWED", {
|
|
245
|
+
message: "Absolute path is not allowed",
|
|
246
|
+
details: { path: relativePath }
|
|
247
|
+
});
|
|
248
|
+
return ensurePathInsideRepo({
|
|
249
|
+
repoRoot,
|
|
250
|
+
path: resolve(repoRoot, normalize(relativePath))
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
const resolvePathFromCwd = ({ cwd, path }) => {
|
|
254
|
+
if (isAbsolute(path)) return path;
|
|
255
|
+
return resolve(cwd, path);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/core/hooks.ts
|
|
260
|
+
const nowTimestamp = () => {
|
|
261
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[^\d]/g, "").slice(0, 14);
|
|
262
|
+
};
|
|
263
|
+
const toLogFileName = ({ action, branch }) => {
|
|
264
|
+
const safeBranch = typeof branch === "string" && branch.length > 0 ? branch.replace(/[^\w.-]/g, "_") : "none";
|
|
265
|
+
return `${nowTimestamp()}_${action}_${safeBranch}.log`;
|
|
266
|
+
};
|
|
267
|
+
const hookPath = (repoRoot, hookName) => {
|
|
268
|
+
return join(getHooksDirectoryPath(repoRoot), hookName);
|
|
269
|
+
};
|
|
270
|
+
const appendHookLog = async ({ repoRoot, action, branch, content }) => {
|
|
271
|
+
const logsDir = getLogsDirectoryPath(repoRoot);
|
|
272
|
+
await mkdir(logsDir, { recursive: true });
|
|
273
|
+
await appendFile(join(logsDir, toLogFileName({
|
|
274
|
+
action,
|
|
275
|
+
branch
|
|
276
|
+
})), content, "utf8");
|
|
277
|
+
};
|
|
278
|
+
const runHook = async ({ phase, hookName, args, context, requireExists = false }) => {
|
|
279
|
+
if (context.enabled !== true) return;
|
|
280
|
+
const path = hookPath(context.repoRoot, hookName);
|
|
281
|
+
try {
|
|
282
|
+
await access(path, constants.F_OK);
|
|
283
|
+
} catch {
|
|
284
|
+
if (requireExists) throw createCliError("HOOK_NOT_FOUND", {
|
|
285
|
+
message: `Hook not found: ${hookName}`,
|
|
286
|
+
details: {
|
|
287
|
+
hook: hookName,
|
|
288
|
+
path
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
await access(path, constants.X_OK);
|
|
295
|
+
} catch {
|
|
296
|
+
throw createCliError("HOOK_NOT_EXECUTABLE", {
|
|
297
|
+
message: `Hook is not executable: ${hookName}`,
|
|
298
|
+
details: {
|
|
299
|
+
hook: hookName,
|
|
300
|
+
path
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
305
|
+
try {
|
|
306
|
+
const result = await execa(path, [...args], {
|
|
307
|
+
cwd: context.worktreePath ?? context.repoRoot,
|
|
308
|
+
env: {
|
|
309
|
+
...process.env,
|
|
310
|
+
WT_REPO_ROOT: context.repoRoot,
|
|
311
|
+
WT_ACTION: context.action,
|
|
312
|
+
WT_BRANCH: context.branch ?? "",
|
|
313
|
+
WT_WORKTREE_PATH: context.worktreePath ?? "",
|
|
314
|
+
WT_IS_TTY: process.stdout.isTTY === true ? "1" : "0",
|
|
315
|
+
WT_TOOL: "vde-worktree",
|
|
316
|
+
...context.extraEnv ?? {}
|
|
317
|
+
},
|
|
318
|
+
timeout: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
|
|
319
|
+
reject: false
|
|
320
|
+
});
|
|
321
|
+
const endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
322
|
+
const logContent = [
|
|
323
|
+
`hook=${hookName}`,
|
|
324
|
+
`phase=${phase}`,
|
|
325
|
+
`start=${startedAt}`,
|
|
326
|
+
`end=${endedAt}`,
|
|
327
|
+
`exitCode=${String(result.exitCode ?? 0)}`,
|
|
328
|
+
`stderr=${result.stderr ?? ""}`,
|
|
329
|
+
""
|
|
330
|
+
].join("\n");
|
|
331
|
+
await appendHookLog({
|
|
332
|
+
repoRoot: context.repoRoot,
|
|
333
|
+
action: context.action,
|
|
334
|
+
branch: context.branch,
|
|
335
|
+
content: logContent
|
|
336
|
+
});
|
|
337
|
+
if ((result.exitCode ?? 0) === 0) return;
|
|
338
|
+
const message = `Hook failed: ${hookName} (exitCode=${String(result.exitCode ?? 1)})`;
|
|
339
|
+
if (phase === "post" && context.strictPostHooks !== true) {
|
|
340
|
+
context.stderr(message);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
throw createCliError("HOOK_FAILED", {
|
|
344
|
+
message,
|
|
345
|
+
details: {
|
|
346
|
+
hook: hookName,
|
|
347
|
+
exitCode: result.exitCode,
|
|
348
|
+
stderr: result.stderr
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error instanceof CliError) throw error;
|
|
353
|
+
const hookError = error;
|
|
354
|
+
if (hookError.code === "ETIMEDOUT" || hookError.code === "ERR_EXECA_TIMEOUT") throw createCliError("HOOK_TIMEOUT", {
|
|
355
|
+
message: `Hook timed out: ${hookName}`,
|
|
356
|
+
details: {
|
|
357
|
+
hook: hookName,
|
|
358
|
+
timeoutMs: context.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
|
|
359
|
+
stderr: hookError.stderr ?? ""
|
|
360
|
+
},
|
|
361
|
+
cause: error
|
|
362
|
+
});
|
|
363
|
+
if (phase === "post" && context.strictPostHooks !== true) {
|
|
364
|
+
context.stderr(`Hook failed: ${hookName}`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
throw createCliError("HOOK_FAILED", {
|
|
368
|
+
message: `Hook failed: ${hookName}`,
|
|
369
|
+
details: {
|
|
370
|
+
hook: hookName,
|
|
371
|
+
stderr: hookError.stderr ?? hookError.message
|
|
372
|
+
},
|
|
373
|
+
cause: error
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
const runPreHook = async ({ name, context }) => {
|
|
378
|
+
await runHook({
|
|
379
|
+
phase: "pre",
|
|
380
|
+
hookName: `pre-${name}`,
|
|
381
|
+
args: [],
|
|
382
|
+
context
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
const runPostHook = async ({ name, context }) => {
|
|
386
|
+
await runHook({
|
|
387
|
+
phase: "post",
|
|
388
|
+
hookName: `post-${name}`,
|
|
389
|
+
args: [],
|
|
390
|
+
context
|
|
391
|
+
});
|
|
392
|
+
};
|
|
393
|
+
const invokeHook = async ({ hookName, args, context }) => {
|
|
394
|
+
await runHook({
|
|
395
|
+
phase: hookName.startsWith("pre-") ? "pre" : "post",
|
|
396
|
+
hookName,
|
|
397
|
+
args,
|
|
398
|
+
context,
|
|
399
|
+
requireExists: true
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/core/init.ts
|
|
405
|
+
const MANAGED_EXCLUDE_BLOCK = `# vde-worktree (managed)\n.worktree/\n.vde/worktree/\n`;
|
|
406
|
+
const DEFAULT_HOOKS = [{
|
|
407
|
+
name: "post-new",
|
|
408
|
+
lines: [
|
|
409
|
+
"#!/usr/bin/env bash",
|
|
410
|
+
"set -eu",
|
|
411
|
+
"",
|
|
412
|
+
"# example:",
|
|
413
|
+
"# vde-worktree copy .envrc .claude/settings.local.json",
|
|
414
|
+
"",
|
|
415
|
+
"exit 0"
|
|
416
|
+
]
|
|
417
|
+
}, {
|
|
418
|
+
name: "post-switch",
|
|
419
|
+
lines: [
|
|
420
|
+
"#!/usr/bin/env bash",
|
|
421
|
+
"set -eu",
|
|
422
|
+
"",
|
|
423
|
+
"# example:",
|
|
424
|
+
"# vde-worktree link .envrc",
|
|
425
|
+
"",
|
|
426
|
+
"exit 0"
|
|
427
|
+
]
|
|
428
|
+
}];
|
|
429
|
+
const createHookTemplate = async (hooksDir, name, lines) => {
|
|
430
|
+
const targetPath = join(hooksDir, name);
|
|
431
|
+
try {
|
|
432
|
+
await access(targetPath, constants.F_OK);
|
|
433
|
+
return;
|
|
434
|
+
} catch {
|
|
435
|
+
await writeFile(targetPath, `${lines.join("\n")}\n`, "utf8");
|
|
436
|
+
await chmod(targetPath, 493);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
const ensureExcludeBlock = async (repoRoot) => {
|
|
440
|
+
const excludePath = join(repoRoot, ".git", "info", "exclude");
|
|
441
|
+
let current = "";
|
|
442
|
+
try {
|
|
443
|
+
current = await readFile(excludePath, "utf8");
|
|
444
|
+
} catch {
|
|
445
|
+
current = "";
|
|
446
|
+
}
|
|
447
|
+
if (current.includes(MANAGED_EXCLUDE_BLOCK)) return;
|
|
448
|
+
await writeFile(excludePath, `${current.endsWith("\n") || current.length === 0 ? current : `${current}\n`}${MANAGED_EXCLUDE_BLOCK}`, "utf8");
|
|
449
|
+
};
|
|
450
|
+
const isInitialized = async (repoRoot) => {
|
|
451
|
+
try {
|
|
452
|
+
await access(getWorktreeMetaRootPath(repoRoot), constants.F_OK);
|
|
453
|
+
return true;
|
|
454
|
+
} catch {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
const initializeRepository = async (repoRoot) => {
|
|
459
|
+
const wasInitialized = await isInitialized(repoRoot);
|
|
460
|
+
await mkdir(getWorktreeRootPath(repoRoot), { recursive: true });
|
|
461
|
+
await mkdir(getHooksDirectoryPath(repoRoot), { recursive: true });
|
|
462
|
+
await mkdir(getLogsDirectoryPath(repoRoot), { recursive: true });
|
|
463
|
+
await mkdir(getLocksDirectoryPath(repoRoot), { recursive: true });
|
|
464
|
+
await mkdir(getStateDirectoryPath(repoRoot), { recursive: true });
|
|
465
|
+
await ensureExcludeBlock(repoRoot);
|
|
466
|
+
for (const hook of DEFAULT_HOOKS) await createHookTemplate(getHooksDirectoryPath(repoRoot), hook.name, hook.lines);
|
|
467
|
+
return { alreadyInitialized: wasInitialized };
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/core/repo-lock.ts
|
|
472
|
+
const sleep = async (ms) => {
|
|
473
|
+
await new Promise((resolve) => {
|
|
474
|
+
setTimeout(resolve, ms);
|
|
475
|
+
});
|
|
476
|
+
};
|
|
477
|
+
const isProcessAlive = (pid) => {
|
|
478
|
+
if (pid <= 0 || Number.isFinite(pid) !== true) return false;
|
|
479
|
+
try {
|
|
480
|
+
process.kill(pid, 0);
|
|
481
|
+
return true;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error.code === "ESRCH") return false;
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const safeParseLockFile = (content) => {
|
|
488
|
+
try {
|
|
489
|
+
const parsed = JSON.parse(content);
|
|
490
|
+
if (parsed.schemaVersion !== 1) return null;
|
|
491
|
+
if (typeof parsed.command !== "string" || typeof parsed.owner !== "string") return null;
|
|
492
|
+
if (typeof parsed.pid !== "number" || typeof parsed.host !== "string" || typeof parsed.startedAt !== "string") return null;
|
|
493
|
+
return parsed;
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
const lockFilePath$1 = async (repoRoot) => {
|
|
499
|
+
const stateDir = getStateDirectoryPath(repoRoot);
|
|
500
|
+
try {
|
|
501
|
+
await access(stateDir, constants.F_OK);
|
|
502
|
+
return join(stateDir, "repo.lock");
|
|
503
|
+
} catch {
|
|
504
|
+
return join(repoRoot, ".git", "vde-worktree.init.lock");
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
const buildLockPayload = (command) => {
|
|
508
|
+
return {
|
|
509
|
+
schemaVersion: 1,
|
|
510
|
+
owner: "vde-worktree",
|
|
511
|
+
command,
|
|
512
|
+
pid: process.pid,
|
|
513
|
+
host: hostname(),
|
|
514
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
515
|
+
};
|
|
516
|
+
};
|
|
517
|
+
const canRecoverStaleLock = ({ lock, staleLockTTLSeconds }) => {
|
|
518
|
+
if (lock === null) return true;
|
|
519
|
+
const startedAtMs = Date.parse(lock.startedAt);
|
|
520
|
+
if (Number.isFinite(startedAtMs) !== true) return true;
|
|
521
|
+
if (startedAtMs + staleLockTTLSeconds * 1e3 > Date.now()) return false;
|
|
522
|
+
if (lock.host === hostname() && isProcessAlive(lock.pid)) return false;
|
|
523
|
+
return true;
|
|
524
|
+
};
|
|
525
|
+
const writeNewLockFile = async (path, payload) => {
|
|
526
|
+
try {
|
|
527
|
+
const handle = await open(path, "wx");
|
|
528
|
+
await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8");
|
|
529
|
+
await handle.close();
|
|
530
|
+
return true;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (error.code === "EEXIST") return false;
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
const acquireRepoLock = async ({ repoRoot, command, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS, staleLockTTLSeconds = DEFAULT_STALE_LOCK_TTL_SECONDS }) => {
|
|
537
|
+
const path = await lockFilePath$1(repoRoot);
|
|
538
|
+
const startAt = Date.now();
|
|
539
|
+
const payload = buildLockPayload(command);
|
|
540
|
+
while (Date.now() - startAt <= timeoutMs) {
|
|
541
|
+
if (await writeNewLockFile(path, payload)) return { release: async () => {
|
|
542
|
+
try {
|
|
543
|
+
await rm(path, { force: true });
|
|
544
|
+
} catch {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
} };
|
|
548
|
+
let lockContent = "";
|
|
549
|
+
try {
|
|
550
|
+
lockContent = await readFile(path, "utf8");
|
|
551
|
+
} catch {
|
|
552
|
+
await sleep(100);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (canRecoverStaleLock({
|
|
556
|
+
lock: safeParseLockFile(lockContent),
|
|
557
|
+
staleLockTTLSeconds
|
|
558
|
+
})) {
|
|
559
|
+
try {
|
|
560
|
+
await rm(path, { force: true });
|
|
561
|
+
} catch {
|
|
562
|
+
throw createCliError("REPO_LOCK_STALE_RECOVERY_FAILED", {
|
|
563
|
+
message: "Failed to recover stale repo lock",
|
|
564
|
+
details: { path }
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
await sleep(100);
|
|
570
|
+
}
|
|
571
|
+
throw createCliError("REPO_LOCK_TIMEOUT", {
|
|
572
|
+
message: "Timed out while acquiring repo lock",
|
|
573
|
+
details: {
|
|
574
|
+
path,
|
|
575
|
+
timeoutMs
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
};
|
|
579
|
+
const withRepoLock = async (options, task) => {
|
|
580
|
+
const handle = await acquireRepoLock(options);
|
|
581
|
+
try {
|
|
582
|
+
return await task();
|
|
583
|
+
} finally {
|
|
584
|
+
await handle.release();
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
const readNumberFromEnvOrDefault = ({ rawValue, defaultValue }) => {
|
|
588
|
+
if (typeof rawValue !== "number" || Number.isFinite(rawValue) !== true) return defaultValue;
|
|
589
|
+
return rawValue;
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/core/worktree-lock.ts
|
|
594
|
+
const parseLock = (content) => {
|
|
595
|
+
try {
|
|
596
|
+
const parsed = JSON.parse(content);
|
|
597
|
+
if (parsed.schemaVersion !== 1 || typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.reason !== "string" || typeof parsed.owner !== "string" || typeof parsed.host !== "string" || typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string" || typeof parsed.updatedAt !== "string") return {
|
|
598
|
+
valid: false,
|
|
599
|
+
record: null
|
|
600
|
+
};
|
|
601
|
+
return {
|
|
602
|
+
valid: true,
|
|
603
|
+
record: parsed
|
|
604
|
+
};
|
|
605
|
+
} catch {
|
|
606
|
+
return {
|
|
607
|
+
valid: false,
|
|
608
|
+
record: null
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
const writeJsonAtomically = async ({ filePath, payload }) => {
|
|
613
|
+
const tmpPath = `${filePath}.tmp-${String(process.pid)}-${String(Date.now())}`;
|
|
614
|
+
await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
|
|
615
|
+
await rename(tmpPath, filePath);
|
|
616
|
+
};
|
|
617
|
+
const lockFilePath = (repoRoot, branch) => {
|
|
618
|
+
return join(getLocksDirectoryPath(repoRoot), `${branchToWorktreeId(branch)}.json`);
|
|
619
|
+
};
|
|
620
|
+
const readWorktreeLock = async ({ repoRoot, branch }) => {
|
|
621
|
+
const path = lockFilePath(repoRoot, branch);
|
|
622
|
+
try {
|
|
623
|
+
await access(path, constants.F_OK);
|
|
624
|
+
} catch {
|
|
625
|
+
return {
|
|
626
|
+
path,
|
|
627
|
+
exists: false,
|
|
628
|
+
valid: true,
|
|
629
|
+
record: null
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
return {
|
|
634
|
+
path,
|
|
635
|
+
exists: true,
|
|
636
|
+
...parseLock(await readFile(path, "utf8"))
|
|
637
|
+
};
|
|
638
|
+
} catch {
|
|
639
|
+
return {
|
|
640
|
+
path,
|
|
641
|
+
exists: true,
|
|
642
|
+
valid: false,
|
|
643
|
+
record: null
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
const upsertWorktreeLock = async ({ repoRoot, branch, reason, owner }) => {
|
|
648
|
+
const { path, record } = await readWorktreeLock({
|
|
649
|
+
repoRoot,
|
|
650
|
+
branch
|
|
651
|
+
});
|
|
652
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
653
|
+
const next = {
|
|
654
|
+
schemaVersion: 1,
|
|
655
|
+
branch,
|
|
656
|
+
worktreeId: branchToWorktreeId(branch),
|
|
657
|
+
reason,
|
|
658
|
+
owner,
|
|
659
|
+
host: hostname(),
|
|
660
|
+
pid: process.pid,
|
|
661
|
+
createdAt: record?.createdAt ?? now,
|
|
662
|
+
updatedAt: now
|
|
663
|
+
};
|
|
664
|
+
await writeJsonAtomically({
|
|
665
|
+
filePath: path,
|
|
666
|
+
payload: next
|
|
667
|
+
});
|
|
668
|
+
return next;
|
|
669
|
+
};
|
|
670
|
+
const deleteWorktreeLock = async ({ repoRoot, branch }) => {
|
|
671
|
+
await rm(lockFilePath(repoRoot, branch), { force: true });
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
//#endregion
|
|
675
|
+
//#region src/integrations/gh.ts
|
|
676
|
+
const defaultRunGh = async ({ cwd, args }) => {
|
|
677
|
+
const result = await execa("gh", [...args], {
|
|
678
|
+
cwd,
|
|
679
|
+
reject: false
|
|
680
|
+
});
|
|
681
|
+
return {
|
|
682
|
+
exitCode: result.exitCode ?? 0,
|
|
683
|
+
stdout: result.stdout,
|
|
684
|
+
stderr: result.stderr
|
|
685
|
+
};
|
|
686
|
+
};
|
|
687
|
+
const parseMergedResult = (raw) => {
|
|
688
|
+
try {
|
|
689
|
+
const parsed = JSON.parse(raw);
|
|
690
|
+
if (Array.isArray(parsed) !== true) return null;
|
|
691
|
+
const records = parsed;
|
|
692
|
+
if (records.length === 0) return false;
|
|
693
|
+
return records.some((record) => typeof record?.mergedAt === "string" && record.mergedAt.length > 0);
|
|
694
|
+
} catch {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
const resolveMergedByPr = async ({ repoRoot, branch, enabled = true, runGh = defaultRunGh }) => {
|
|
699
|
+
if (enabled !== true) return null;
|
|
700
|
+
try {
|
|
701
|
+
const result = await runGh({
|
|
702
|
+
cwd: repoRoot,
|
|
703
|
+
args: [
|
|
704
|
+
"pr",
|
|
705
|
+
"list",
|
|
706
|
+
"--state",
|
|
707
|
+
"merged",
|
|
708
|
+
"--head",
|
|
709
|
+
branch,
|
|
710
|
+
"--limit",
|
|
711
|
+
"1",
|
|
712
|
+
"--json",
|
|
713
|
+
"mergedAt"
|
|
714
|
+
]
|
|
715
|
+
});
|
|
716
|
+
if (result.exitCode !== 0) return null;
|
|
717
|
+
return parseMergedResult(result.stdout);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
if (error.code === "ENOENT") return null;
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/git/worktree.ts
|
|
726
|
+
const BRANCH_PREFIX = "refs/heads/";
|
|
727
|
+
const parseBranchName = (rawRef) => {
|
|
728
|
+
if (rawRef.startsWith(BRANCH_PREFIX)) return rawRef.slice(11);
|
|
729
|
+
return rawRef.length > 0 ? rawRef : null;
|
|
730
|
+
};
|
|
731
|
+
const parseWorktreePorcelain = (raw) => {
|
|
732
|
+
const tokens = raw.split("\0");
|
|
733
|
+
const worktrees = [];
|
|
734
|
+
let currentPath = "";
|
|
735
|
+
let currentHead = "";
|
|
736
|
+
let currentBranch = null;
|
|
737
|
+
const flush = () => {
|
|
738
|
+
if (currentPath.length === 0) return;
|
|
739
|
+
worktrees.push({
|
|
740
|
+
path: currentPath,
|
|
741
|
+
head: currentHead,
|
|
742
|
+
branch: currentBranch
|
|
743
|
+
});
|
|
744
|
+
currentPath = "";
|
|
745
|
+
currentHead = "";
|
|
746
|
+
currentBranch = null;
|
|
747
|
+
};
|
|
748
|
+
for (const token of tokens) {
|
|
749
|
+
if (token.length === 0) {
|
|
750
|
+
flush();
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (token.startsWith("worktree ")) {
|
|
754
|
+
flush();
|
|
755
|
+
currentPath = token.slice(9);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (token.startsWith("HEAD ")) {
|
|
759
|
+
currentHead = token.slice(5);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (token.startsWith("branch ")) {
|
|
763
|
+
currentBranch = parseBranchName(token.slice(7));
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (token === "detached") currentBranch = null;
|
|
767
|
+
}
|
|
768
|
+
flush();
|
|
769
|
+
return worktrees;
|
|
770
|
+
};
|
|
771
|
+
const listGitWorktrees = async (repoRoot) => {
|
|
772
|
+
return parseWorktreePorcelain((await runGitCommand({
|
|
773
|
+
cwd: repoRoot,
|
|
774
|
+
args: [
|
|
775
|
+
"worktree",
|
|
776
|
+
"list",
|
|
777
|
+
"--porcelain",
|
|
778
|
+
"-z"
|
|
779
|
+
]
|
|
780
|
+
})).stdout);
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
//#endregion
|
|
784
|
+
//#region src/core/worktree-state.ts
|
|
785
|
+
const resolveBaseBranch$1 = async (repoRoot) => {
|
|
786
|
+
const explicit = await runGitCommand({
|
|
787
|
+
cwd: repoRoot,
|
|
788
|
+
args: [
|
|
789
|
+
"config",
|
|
790
|
+
"--get",
|
|
791
|
+
"vde-worktree.baseBranch"
|
|
792
|
+
],
|
|
793
|
+
reject: false
|
|
794
|
+
});
|
|
795
|
+
if (explicit.exitCode === 0 && explicit.stdout.trim().length > 0) return explicit.stdout.trim();
|
|
796
|
+
for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
|
|
797
|
+
return null;
|
|
798
|
+
};
|
|
799
|
+
const resolveEnableGh = async (repoRoot) => {
|
|
800
|
+
const result = await runGitCommand({
|
|
801
|
+
cwd: repoRoot,
|
|
802
|
+
args: [
|
|
803
|
+
"config",
|
|
804
|
+
"--bool",
|
|
805
|
+
"--get",
|
|
806
|
+
"vde-worktree.enableGh"
|
|
807
|
+
],
|
|
808
|
+
reject: false
|
|
809
|
+
});
|
|
810
|
+
if (result.exitCode !== 0) return true;
|
|
811
|
+
const value = result.stdout.trim().toLowerCase();
|
|
812
|
+
if (value === "false" || value === "no" || value === "off" || value === "0") return false;
|
|
813
|
+
return true;
|
|
814
|
+
};
|
|
815
|
+
const resolveDirty = async (worktreePath) => {
|
|
816
|
+
return (await runGitCommand({
|
|
817
|
+
cwd: worktreePath,
|
|
818
|
+
args: ["status", "--porcelain"],
|
|
819
|
+
reject: false
|
|
820
|
+
})).stdout.trim().length > 0;
|
|
821
|
+
};
|
|
822
|
+
const parseLockPayload = (content) => {
|
|
823
|
+
try {
|
|
824
|
+
const parsed = JSON.parse(content);
|
|
825
|
+
if (parsed.schemaVersion !== 1) return null;
|
|
826
|
+
if (typeof parsed.branch !== "string" || typeof parsed.worktreeId !== "string" || typeof parsed.reason !== "string" || parsed.reason.length === 0) return null;
|
|
827
|
+
return parsed;
|
|
828
|
+
} catch {
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
const resolveLockState = async ({ repoRoot, branch }) => {
|
|
833
|
+
if (branch === null) return {
|
|
834
|
+
value: false,
|
|
835
|
+
reason: null,
|
|
836
|
+
owner: null
|
|
837
|
+
};
|
|
838
|
+
const id = branchToWorktreeId(branch);
|
|
839
|
+
const lockPath = join(getLocksDirectoryPath(repoRoot), `${id}.json`);
|
|
840
|
+
try {
|
|
841
|
+
await access(lockPath, constants.F_OK);
|
|
842
|
+
} catch {
|
|
843
|
+
return {
|
|
844
|
+
value: false,
|
|
845
|
+
reason: null,
|
|
846
|
+
owner: null
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
const lock = parseLockPayload(await readFile(lockPath, "utf8"));
|
|
851
|
+
if (lock === null) return {
|
|
852
|
+
value: true,
|
|
853
|
+
reason: "invalid lock metadata",
|
|
854
|
+
owner: null
|
|
855
|
+
};
|
|
856
|
+
return {
|
|
857
|
+
value: true,
|
|
858
|
+
reason: lock.reason,
|
|
859
|
+
owner: typeof lock.owner === "string" && lock.owner.length > 0 ? lock.owner : null
|
|
860
|
+
};
|
|
861
|
+
} catch {
|
|
862
|
+
return {
|
|
863
|
+
value: true,
|
|
864
|
+
reason: "invalid lock metadata",
|
|
865
|
+
owner: null
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
const resolveMergedState = async ({ repoRoot, branch, baseBranch, enableGh }) => {
|
|
870
|
+
if (branch === null) return {
|
|
871
|
+
byAncestry: null,
|
|
872
|
+
byPR: null,
|
|
873
|
+
overall: null
|
|
874
|
+
};
|
|
875
|
+
let byAncestry = null;
|
|
876
|
+
if (baseBranch !== null) {
|
|
877
|
+
const result = await runGitCommand({
|
|
878
|
+
cwd: repoRoot,
|
|
879
|
+
args: [
|
|
880
|
+
"merge-base",
|
|
881
|
+
"--is-ancestor",
|
|
882
|
+
branch,
|
|
883
|
+
baseBranch
|
|
884
|
+
],
|
|
885
|
+
reject: false
|
|
886
|
+
});
|
|
887
|
+
if (result.exitCode === 0) byAncestry = true;
|
|
888
|
+
else if (result.exitCode === 1) byAncestry = false;
|
|
889
|
+
}
|
|
890
|
+
const byPR = await resolveMergedByPr({
|
|
891
|
+
repoRoot,
|
|
892
|
+
branch,
|
|
893
|
+
enabled: enableGh
|
|
894
|
+
});
|
|
895
|
+
return {
|
|
896
|
+
byAncestry,
|
|
897
|
+
byPR,
|
|
898
|
+
overall: resolveMergedOverall({
|
|
899
|
+
byAncestry,
|
|
900
|
+
byPR
|
|
901
|
+
})
|
|
902
|
+
};
|
|
903
|
+
};
|
|
904
|
+
const resolveMergedOverall = ({ byAncestry, byPR }) => {
|
|
905
|
+
if (byPR === true) return true;
|
|
906
|
+
if (byPR === false) return false;
|
|
907
|
+
return byAncestry;
|
|
908
|
+
};
|
|
909
|
+
const resolveUpstreamState = async (worktreePath) => {
|
|
910
|
+
const upstreamRef = await runGitCommand({
|
|
911
|
+
cwd: worktreePath,
|
|
912
|
+
args: [
|
|
913
|
+
"rev-parse",
|
|
914
|
+
"--abbrev-ref",
|
|
915
|
+
"--symbolic-full-name",
|
|
916
|
+
"@{upstream}"
|
|
917
|
+
],
|
|
918
|
+
reject: false
|
|
919
|
+
});
|
|
920
|
+
if (upstreamRef.exitCode !== 0) return {
|
|
921
|
+
ahead: null,
|
|
922
|
+
behind: null,
|
|
923
|
+
remote: null
|
|
924
|
+
};
|
|
925
|
+
const distance = await runGitCommand({
|
|
926
|
+
cwd: worktreePath,
|
|
927
|
+
args: [
|
|
928
|
+
"rev-list",
|
|
929
|
+
"--left-right",
|
|
930
|
+
"--count",
|
|
931
|
+
"@{upstream}...HEAD"
|
|
932
|
+
],
|
|
933
|
+
reject: false
|
|
934
|
+
});
|
|
935
|
+
if (distance.exitCode !== 0) return {
|
|
936
|
+
ahead: null,
|
|
937
|
+
behind: null,
|
|
938
|
+
remote: upstreamRef.stdout.trim()
|
|
939
|
+
};
|
|
940
|
+
const [behindRaw, aheadRaw] = distance.stdout.trim().split(/\s+/);
|
|
941
|
+
const behind = Number.parseInt(behindRaw ?? "", 10);
|
|
942
|
+
const ahead = Number.parseInt(aheadRaw ?? "", 10);
|
|
943
|
+
return {
|
|
944
|
+
ahead: Number.isNaN(ahead) ? null : ahead,
|
|
945
|
+
behind: Number.isNaN(behind) ? null : behind,
|
|
946
|
+
remote: upstreamRef.stdout.trim()
|
|
947
|
+
};
|
|
948
|
+
};
|
|
949
|
+
const enrichWorktree = async ({ repoRoot, worktree, baseBranch, enableGh }) => {
|
|
950
|
+
const [dirty, locked, merged, upstream] = await Promise.all([
|
|
951
|
+
resolveDirty(worktree.path),
|
|
952
|
+
resolveLockState({
|
|
953
|
+
repoRoot,
|
|
954
|
+
branch: worktree.branch
|
|
955
|
+
}),
|
|
956
|
+
resolveMergedState({
|
|
957
|
+
repoRoot,
|
|
958
|
+
branch: worktree.branch,
|
|
959
|
+
baseBranch,
|
|
960
|
+
enableGh
|
|
961
|
+
}),
|
|
962
|
+
resolveUpstreamState(worktree.path)
|
|
963
|
+
]);
|
|
964
|
+
return {
|
|
965
|
+
branch: worktree.branch,
|
|
966
|
+
path: worktree.path,
|
|
967
|
+
head: worktree.head,
|
|
968
|
+
dirty,
|
|
969
|
+
locked,
|
|
970
|
+
merged,
|
|
971
|
+
upstream
|
|
972
|
+
};
|
|
973
|
+
};
|
|
974
|
+
const collectWorktreeSnapshot = async (repoRoot) => {
|
|
975
|
+
const [baseBranch, worktrees, enableGh] = await Promise.all([
|
|
976
|
+
resolveBaseBranch$1(repoRoot),
|
|
977
|
+
listGitWorktrees(repoRoot),
|
|
978
|
+
resolveEnableGh(repoRoot)
|
|
979
|
+
]);
|
|
980
|
+
return {
|
|
981
|
+
repoRoot,
|
|
982
|
+
baseBranch,
|
|
983
|
+
worktrees: await Promise.all(worktrees.map(async (worktree) => {
|
|
984
|
+
return enrichWorktree({
|
|
985
|
+
repoRoot,
|
|
986
|
+
worktree,
|
|
987
|
+
baseBranch,
|
|
988
|
+
enableGh
|
|
989
|
+
});
|
|
990
|
+
}))
|
|
991
|
+
};
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/integrations/fzf.ts
|
|
996
|
+
const FZF_BINARY = "fzf";
|
|
997
|
+
const FZF_CHECK_TIMEOUT_MS = 5e3;
|
|
998
|
+
const RESERVED_FZF_ARGS = new Set([
|
|
999
|
+
"prompt",
|
|
1000
|
+
"layout",
|
|
1001
|
+
"height",
|
|
1002
|
+
"border"
|
|
1003
|
+
]);
|
|
1004
|
+
const sanitizeCandidate = (value) => value.replace(/[\t\r\n]+/g, " ").trim();
|
|
1005
|
+
const buildFzfInput = (candidates) => {
|
|
1006
|
+
return candidates.map((candidate) => sanitizeCandidate(candidate)).filter((candidate) => candidate.length > 0).join("\n");
|
|
1007
|
+
};
|
|
1008
|
+
const validateExtraFzfArgs = (fzfExtraArgs) => {
|
|
1009
|
+
for (const arg of fzfExtraArgs) {
|
|
1010
|
+
if (typeof arg !== "string" || arg.length === 0) throw new Error("Empty value is not allowed for --fzf-arg");
|
|
1011
|
+
if (!arg.startsWith("--")) continue;
|
|
1012
|
+
const withoutPrefix = arg.slice(2);
|
|
1013
|
+
if (withoutPrefix.length === 0) continue;
|
|
1014
|
+
const optionName = withoutPrefix.split("=")[0];
|
|
1015
|
+
if (optionName !== void 0 && RESERVED_FZF_ARGS.has(optionName)) throw new Error(`--fzf-arg cannot override reserved fzf option: --${optionName}`);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
const buildFzfArgs = ({ prompt, fzfExtraArgs }) => {
|
|
1019
|
+
validateExtraFzfArgs(fzfExtraArgs);
|
|
1020
|
+
return [
|
|
1021
|
+
`--prompt=${prompt}`,
|
|
1022
|
+
"--layout=reverse",
|
|
1023
|
+
"--height=80%",
|
|
1024
|
+
"--border",
|
|
1025
|
+
...fzfExtraArgs
|
|
1026
|
+
];
|
|
1027
|
+
};
|
|
1028
|
+
const defaultCheckFzfAvailability = async () => {
|
|
1029
|
+
try {
|
|
1030
|
+
await execa(FZF_BINARY, ["--version"], { timeout: FZF_CHECK_TIMEOUT_MS });
|
|
1031
|
+
return true;
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
const execaError = error;
|
|
1034
|
+
if (execaError.code === "ENOENT" || execaError.code === "ETIMEDOUT" || execaError.code === "ERR_EXECA_TIMEOUT" || execaError.timedOut === true) return false;
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
const defaultRunFzf = async ({ args, input, cwd, env }) => {
|
|
1039
|
+
return { stdout: (await execa(FZF_BINARY, args, {
|
|
1040
|
+
input,
|
|
1041
|
+
cwd,
|
|
1042
|
+
env,
|
|
1043
|
+
stderr: "inherit"
|
|
1044
|
+
})).stdout };
|
|
1045
|
+
};
|
|
1046
|
+
const ensureFzfAvailable = async (checkFzfAvailability) => {
|
|
1047
|
+
if (await checkFzfAvailability()) return;
|
|
1048
|
+
throw new Error("fzf is required for interactive selection");
|
|
1049
|
+
};
|
|
1050
|
+
const selectPathWithFzf = async ({ candidates, prompt = "worktree> ", fzfExtraArgs = [], cwd = process.cwd(), env = process.env, isInteractive = () => process.stdout.isTTY === true && process.stderr.isTTY === true, checkFzfAvailability = defaultCheckFzfAvailability, runFzf = defaultRunFzf }) => {
|
|
1051
|
+
if (candidates.length === 0) throw new Error("No candidates provided for fzf selection");
|
|
1052
|
+
if (isInteractive() !== true) throw new Error("fzf selection requires an interactive terminal");
|
|
1053
|
+
await ensureFzfAvailable(checkFzfAvailability);
|
|
1054
|
+
const args = buildFzfArgs({
|
|
1055
|
+
prompt,
|
|
1056
|
+
fzfExtraArgs
|
|
1057
|
+
});
|
|
1058
|
+
const input = buildFzfInput(candidates);
|
|
1059
|
+
if (input.length === 0) throw new Error("All candidates are empty after sanitization");
|
|
1060
|
+
const candidateSet = new Set(input.split("\n"));
|
|
1061
|
+
try {
|
|
1062
|
+
const selectedPath = (await runFzf({
|
|
1063
|
+
args,
|
|
1064
|
+
input,
|
|
1065
|
+
cwd,
|
|
1066
|
+
env
|
|
1067
|
+
})).stdout.trim();
|
|
1068
|
+
if (selectedPath.length === 0) return { status: "cancelled" };
|
|
1069
|
+
if (!candidateSet.has(selectedPath)) throw new Error("fzf returned a value that is not in the candidate list");
|
|
1070
|
+
return {
|
|
1071
|
+
status: "selected",
|
|
1072
|
+
path: selectedPath
|
|
1073
|
+
};
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
if (error.exitCode === 130) return { status: "cancelled" };
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
//#endregion
|
|
1081
|
+
//#region src/utils/logger.ts
|
|
1082
|
+
let LogLevel = /* @__PURE__ */ function(LogLevel) {
|
|
1083
|
+
LogLevel[LogLevel["ERROR"] = 0] = "ERROR";
|
|
1084
|
+
LogLevel[LogLevel["WARN"] = 1] = "WARN";
|
|
1085
|
+
LogLevel[LogLevel["INFO"] = 2] = "INFO";
|
|
1086
|
+
LogLevel[LogLevel["DEBUG"] = 3] = "DEBUG";
|
|
1087
|
+
return LogLevel;
|
|
1088
|
+
}({});
|
|
1089
|
+
const resolveDefaultLogLevel = () => {
|
|
1090
|
+
if (process.env.VDE_WORKTREE_DEBUG === "true" || process.env.VDE_DEBUG === "true") return LogLevel.DEBUG;
|
|
1091
|
+
if (process.env.VDE_WORKTREE_VERBOSE === "true" || process.env.VDE_VERBOSE === "true") return LogLevel.INFO;
|
|
1092
|
+
return LogLevel.WARN;
|
|
1093
|
+
};
|
|
1094
|
+
const formatMessage = (prefix, message) => {
|
|
1095
|
+
return prefix ? `${prefix} ${message}` : message;
|
|
1096
|
+
};
|
|
1097
|
+
const createLogger = (options = {}) => {
|
|
1098
|
+
const level = options.level ?? resolveDefaultLogLevel();
|
|
1099
|
+
const prefix = options.prefix ?? "";
|
|
1100
|
+
const build = (nextPrefix, nextLevel) => {
|
|
1101
|
+
const resolvedPrefix = nextPrefix;
|
|
1102
|
+
return {
|
|
1103
|
+
level: nextLevel,
|
|
1104
|
+
prefix: resolvedPrefix,
|
|
1105
|
+
error(message, error) {
|
|
1106
|
+
if (nextLevel >= LogLevel.ERROR) {
|
|
1107
|
+
console.error(chalk.red(formatMessage(resolvedPrefix, `Error: ${message}`)));
|
|
1108
|
+
if (error && (process.env.VDE_WORKTREE_DEBUG === "true" || process.env.VDE_DEBUG === "true")) console.error(chalk.gray(error.stack));
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
warn(message) {
|
|
1112
|
+
if (nextLevel >= LogLevel.WARN) console.warn(chalk.yellow(formatMessage(resolvedPrefix, message)));
|
|
1113
|
+
},
|
|
1114
|
+
info(message) {
|
|
1115
|
+
if (nextLevel >= LogLevel.INFO) console.log(formatMessage(resolvedPrefix, message));
|
|
1116
|
+
},
|
|
1117
|
+
debug(message) {
|
|
1118
|
+
if (nextLevel >= LogLevel.DEBUG) console.log(chalk.gray(formatMessage(resolvedPrefix, `[DEBUG] ${message}`)));
|
|
1119
|
+
},
|
|
1120
|
+
success(message) {
|
|
1121
|
+
console.log(chalk.green(formatMessage(resolvedPrefix, message)));
|
|
1122
|
+
},
|
|
1123
|
+
createChild(suffix) {
|
|
1124
|
+
return build(resolvedPrefix ? `${resolvedPrefix} ${suffix}` : suffix, nextLevel);
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
};
|
|
1128
|
+
return build(prefix, level);
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
//#endregion
|
|
1132
|
+
//#region src/cli/package-version.ts
|
|
1133
|
+
const CANDIDATE_PATHS = ["../package.json", "../../package.json"];
|
|
1134
|
+
const isModuleNotFoundError = (error) => {
|
|
1135
|
+
return error instanceof Error && error.code === "MODULE_NOT_FOUND";
|
|
1136
|
+
};
|
|
1137
|
+
const loadPackageVersion = (requireFn) => {
|
|
1138
|
+
let lastNotFound;
|
|
1139
|
+
for (const candidatePath of CANDIDATE_PATHS) try {
|
|
1140
|
+
return requireFn(candidatePath).version;
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (isModuleNotFoundError(error)) {
|
|
1143
|
+
lastNotFound = error;
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
throw lastNotFound ?? /* @__PURE__ */ new Error(`Unable to resolve package version from candidates: ${CANDIDATE_PATHS.join(", ")}`);
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
//#endregion
|
|
1152
|
+
//#region src/cli/index.ts
|
|
1153
|
+
const EXIT_CODE_CANCELLED = 130;
|
|
1154
|
+
const optionNamesAllowOptionLikeValue = new Set(["fzfArg", "fzf-arg"]);
|
|
1155
|
+
const COMPLETION_SHELLS = ["zsh", "fish"];
|
|
1156
|
+
const COMPLETION_FILE_BY_SHELL = {
|
|
1157
|
+
zsh: "zsh/_vw",
|
|
1158
|
+
fish: "fish/vw.fish"
|
|
1159
|
+
};
|
|
1160
|
+
const commandHelpEntries = [
|
|
1161
|
+
{
|
|
1162
|
+
name: "init",
|
|
1163
|
+
usage: "vw init",
|
|
1164
|
+
summary: "Initialize directories, hooks, and managed exclude entries.",
|
|
1165
|
+
details: ["Creates .worktree and .vde/worktree directories.", "Appends managed entries to .git/info/exclude (idempotent)."]
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
name: "list",
|
|
1169
|
+
usage: "vw list [--json]",
|
|
1170
|
+
summary: "List worktrees with status metadata.",
|
|
1171
|
+
details: ["Includes branch, path, dirty, lock, merged, and upstream fields."]
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
name: "status",
|
|
1175
|
+
usage: "vw status [branch] [--json]",
|
|
1176
|
+
summary: "Show a single worktree status.",
|
|
1177
|
+
details: ["Without branch, resolves from current working directory."]
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
name: "path",
|
|
1181
|
+
usage: "vw path <branch> [--json]",
|
|
1182
|
+
summary: "Print absolute worktree path for the branch.",
|
|
1183
|
+
details: []
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
name: "new",
|
|
1187
|
+
usage: "vw new [branch]",
|
|
1188
|
+
summary: "Create branch + worktree under .worktree.",
|
|
1189
|
+
details: ["Without branch, generates wip-xxxxxx."]
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
name: "switch",
|
|
1193
|
+
usage: "vw switch <branch>",
|
|
1194
|
+
summary: "Idempotent branch entrypoint.",
|
|
1195
|
+
details: ["Reuses existing worktree when present, otherwise creates one."]
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
name: "mv",
|
|
1199
|
+
usage: "vw mv <new-branch>",
|
|
1200
|
+
summary: "Rename current non-primary worktree branch and move its directory.",
|
|
1201
|
+
details: ["Requires branch checkout (detached HEAD is rejected)."]
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
name: "del",
|
|
1205
|
+
usage: "vw del [branch] [flags]",
|
|
1206
|
+
summary: "Delete worktree + branch with safety checks.",
|
|
1207
|
+
details: ["Default rejects dirty, locked, unmerged/unknown, or unpushed/unknown states.", "For non-TTY force usage, --allow-unsafe is required."],
|
|
1208
|
+
options: [
|
|
1209
|
+
"--force-dirty",
|
|
1210
|
+
"--allow-unpushed",
|
|
1211
|
+
"--force-unmerged",
|
|
1212
|
+
"--force-locked",
|
|
1213
|
+
"--force",
|
|
1214
|
+
"--allow-unsafe"
|
|
1215
|
+
]
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
name: "gone",
|
|
1219
|
+
usage: "vw gone [--json] [--apply|--dry-run]",
|
|
1220
|
+
summary: "Bulk cleanup by safety-filtered candidate selection.",
|
|
1221
|
+
details: ["Default mode is dry-run. Use --apply to delete candidates."]
|
|
1222
|
+
},
|
|
1223
|
+
{
|
|
1224
|
+
name: "get",
|
|
1225
|
+
usage: "vw get <remote/branch>",
|
|
1226
|
+
summary: "Fetch remote branch, create tracking local branch if needed, then attach worktree.",
|
|
1227
|
+
details: ["Example target format: origin/feature/foo."]
|
|
1228
|
+
},
|
|
1229
|
+
{
|
|
1230
|
+
name: "extract",
|
|
1231
|
+
usage: "vw extract --current [--stash]",
|
|
1232
|
+
summary: "Extract current primary branch into .worktree and switch primary back to base.",
|
|
1233
|
+
details: ["Current implementation targets primary worktree extraction flow."],
|
|
1234
|
+
options: [
|
|
1235
|
+
"--current",
|
|
1236
|
+
"--stash",
|
|
1237
|
+
"--from <path>"
|
|
1238
|
+
]
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
name: "use",
|
|
1242
|
+
usage: "vw use <branch> [--allow-agent --allow-unsafe]",
|
|
1243
|
+
summary: "Checkout target branch in primary worktree.",
|
|
1244
|
+
details: ["Non-TTY execution requires --allow-agent and --allow-unsafe."]
|
|
1245
|
+
},
|
|
1246
|
+
{
|
|
1247
|
+
name: "exec",
|
|
1248
|
+
usage: "vw exec <branch> -- <cmd...>",
|
|
1249
|
+
summary: "Run command in target branch worktree.",
|
|
1250
|
+
details: ["Returns exit code 21 when child process exits non-zero."]
|
|
1251
|
+
},
|
|
1252
|
+
{
|
|
1253
|
+
name: "invoke",
|
|
1254
|
+
usage: "vw invoke <pre-*/post-*> [-- <args...>]",
|
|
1255
|
+
summary: "Manually run hook script for debugging/operations.",
|
|
1256
|
+
details: []
|
|
1257
|
+
},
|
|
1258
|
+
{
|
|
1259
|
+
name: "copy",
|
|
1260
|
+
usage: "vw copy <repo-relative-path...>",
|
|
1261
|
+
summary: "Copy repo-root files/dirs to target worktree (typically WT_WORKTREE_PATH).",
|
|
1262
|
+
details: []
|
|
1263
|
+
},
|
|
1264
|
+
{
|
|
1265
|
+
name: "link",
|
|
1266
|
+
usage: "vw link <repo-relative-path...> [--no-fallback]",
|
|
1267
|
+
summary: "Create symlink from target worktree to repo-root file.",
|
|
1268
|
+
details: ["On Windows, fallback copy is used unless --no-fallback is set."]
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
name: "lock",
|
|
1272
|
+
usage: "vw lock <branch> [--owner <name>] [--reason <text>]",
|
|
1273
|
+
summary: "Create/update lock metadata to protect worktree from cleanup/deletion.",
|
|
1274
|
+
details: []
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
name: "unlock",
|
|
1278
|
+
usage: "vw unlock <branch> [--owner <name>] [--force]",
|
|
1279
|
+
summary: "Remove lock metadata with owner/force checks.",
|
|
1280
|
+
details: []
|
|
1281
|
+
},
|
|
1282
|
+
{
|
|
1283
|
+
name: "cd",
|
|
1284
|
+
usage: "vw cd",
|
|
1285
|
+
summary: "Interactive fzf picker that prints selected worktree absolute path.",
|
|
1286
|
+
details: ["Use with shell: cd \"$(vw cd)\""],
|
|
1287
|
+
options: ["--prompt <text>", "--fzf-arg <arg>"]
|
|
1288
|
+
},
|
|
1289
|
+
{
|
|
1290
|
+
name: "completion",
|
|
1291
|
+
usage: "vw completion <zsh|fish> [--install] [--path <file>]",
|
|
1292
|
+
summary: "Print or install shell completion scripts.",
|
|
1293
|
+
details: ["Without --install, prints completion script to stdout.", "With --install, writes completion file to default shell path or --path."],
|
|
1294
|
+
options: ["--install", "--path <file>"]
|
|
1295
|
+
}
|
|
1296
|
+
];
|
|
1297
|
+
const splitRawArgsByDoubleDash = (args) => {
|
|
1298
|
+
const separatorIndex = args.indexOf("--");
|
|
1299
|
+
if (separatorIndex < 0) return {
|
|
1300
|
+
beforeDoubleDash: [...args],
|
|
1301
|
+
afterDoubleDash: []
|
|
1302
|
+
};
|
|
1303
|
+
return {
|
|
1304
|
+
beforeDoubleDash: args.slice(0, separatorIndex),
|
|
1305
|
+
afterDoubleDash: args.slice(separatorIndex + 1)
|
|
1306
|
+
};
|
|
1307
|
+
};
|
|
1308
|
+
const toKebabCase = (value) => {
|
|
1309
|
+
return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
1310
|
+
};
|
|
1311
|
+
const toOptionSpec = (kind, optionName) => {
|
|
1312
|
+
return {
|
|
1313
|
+
kind,
|
|
1314
|
+
allowOptionLikeValue: optionNamesAllowOptionLikeValue.has(optionName)
|
|
1315
|
+
};
|
|
1316
|
+
};
|
|
1317
|
+
const buildOptionSpecs = (argsDef) => {
|
|
1318
|
+
const longOptions = /* @__PURE__ */ new Map();
|
|
1319
|
+
const shortOptions = /* @__PURE__ */ new Map();
|
|
1320
|
+
for (const [argName, arg] of Object.entries(argsDef)) {
|
|
1321
|
+
if (arg.type === "positional") continue;
|
|
1322
|
+
const valueKind = arg.type === "boolean" ? "boolean" : "value";
|
|
1323
|
+
const kebabName = toKebabCase(argName);
|
|
1324
|
+
longOptions.set(argName, toOptionSpec(valueKind, argName));
|
|
1325
|
+
longOptions.set(kebabName, toOptionSpec(valueKind, kebabName));
|
|
1326
|
+
const aliases = "alias" in arg ? Array.isArray(arg.alias) ? arg.alias : typeof arg.alias === "string" ? [arg.alias] : [] : [];
|
|
1327
|
+
for (const alias of aliases) {
|
|
1328
|
+
if (alias.length === 1) {
|
|
1329
|
+
shortOptions.set(alias, toOptionSpec(valueKind, alias));
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
longOptions.set(alias, toOptionSpec(valueKind, alias));
|
|
1333
|
+
const kebabAlias = toKebabCase(alias);
|
|
1334
|
+
longOptions.set(kebabAlias, toOptionSpec(valueKind, kebabAlias));
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return {
|
|
1338
|
+
longOptions,
|
|
1339
|
+
shortOptions
|
|
1340
|
+
};
|
|
1341
|
+
};
|
|
1342
|
+
const validateRawOptions = (args, optionSpecs) => {
|
|
1343
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1344
|
+
const token = args[index];
|
|
1345
|
+
if (typeof token !== "string") continue;
|
|
1346
|
+
if (token === "--") break;
|
|
1347
|
+
if (!token.startsWith("-") || token === "-") continue;
|
|
1348
|
+
if (token.startsWith("--")) {
|
|
1349
|
+
const value = token.slice(2);
|
|
1350
|
+
if (value.length === 0) continue;
|
|
1351
|
+
const separatorIndex = value.indexOf("=");
|
|
1352
|
+
const rawOptionName = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
|
|
1353
|
+
const directOptionSpec = optionSpecs.longOptions.get(rawOptionName);
|
|
1354
|
+
const optionNameForNegation = rawOptionName.startsWith("no-") ? rawOptionName.slice(3) : rawOptionName;
|
|
1355
|
+
const optionSpec = directOptionSpec ?? optionSpecs.longOptions.get(optionNameForNegation);
|
|
1356
|
+
if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: --${rawOptionName}` });
|
|
1357
|
+
if (optionSpec.kind === "value") if (separatorIndex >= 0) {
|
|
1358
|
+
if (value.slice(separatorIndex + 1).length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
|
|
1359
|
+
} else {
|
|
1360
|
+
const nextToken = args[index + 1];
|
|
1361
|
+
if (typeof nextToken !== "string" || nextToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
|
|
1362
|
+
if (nextToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: --${rawOptionName}` });
|
|
1363
|
+
index += 1;
|
|
1364
|
+
}
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const shortFlags = token.slice(1);
|
|
1368
|
+
for (let flagIndex = 0; flagIndex < shortFlags.length; flagIndex += 1) {
|
|
1369
|
+
const option = shortFlags[flagIndex];
|
|
1370
|
+
if (typeof option !== "string" || option.length === 0) continue;
|
|
1371
|
+
const optionSpec = optionSpecs.shortOptions.get(option);
|
|
1372
|
+
if (optionSpec === void 0) throw createCliError("INVALID_ARGUMENT", { message: `Unknown option: -${option}` });
|
|
1373
|
+
if (optionSpec.kind === "value") {
|
|
1374
|
+
if (flagIndex < shortFlags.length - 1) break;
|
|
1375
|
+
const nextToken = args[index + 1];
|
|
1376
|
+
if (typeof nextToken !== "string" || nextToken.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
|
|
1377
|
+
if (nextToken.startsWith("-") && optionSpec.allowOptionLikeValue !== true) throw createCliError("INVALID_ARGUMENT", { message: `Missing value for option: -${option}` });
|
|
1378
|
+
index += 1;
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
const getPositionals = (args) => {
|
|
1385
|
+
return args._.filter((value) => typeof value === "string");
|
|
1386
|
+
};
|
|
1387
|
+
const collectOptionValues = ({ args, optionNames }) => {
|
|
1388
|
+
const values = [];
|
|
1389
|
+
const optionNameSet = new Set(optionNames);
|
|
1390
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1391
|
+
const token = args[index];
|
|
1392
|
+
if (typeof token !== "string") continue;
|
|
1393
|
+
if (token === "--") break;
|
|
1394
|
+
if (!token.startsWith("--")) continue;
|
|
1395
|
+
const eqIndex = token.indexOf("=");
|
|
1396
|
+
const rawName = eqIndex >= 0 ? token.slice(2, eqIndex) : token.slice(2);
|
|
1397
|
+
if (optionNameSet.has(rawName) !== true) continue;
|
|
1398
|
+
if (eqIndex >= 0) {
|
|
1399
|
+
values.push(token.slice(eqIndex + 1));
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
const nextToken = args[index + 1];
|
|
1403
|
+
if (typeof nextToken === "string") {
|
|
1404
|
+
values.push(nextToken);
|
|
1405
|
+
index += 1;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return values;
|
|
1409
|
+
};
|
|
1410
|
+
const toNumberOption = ({ value, optionName }) => {
|
|
1411
|
+
if (value === void 0) return;
|
|
1412
|
+
if (typeof value !== "string") throw createCliError("INVALID_ARGUMENT", { message: `${optionName} must be a number` });
|
|
1413
|
+
const parsed = Number.parseInt(value, 10);
|
|
1414
|
+
if (Number.isFinite(parsed) !== true || parsed <= 0) throw createCliError("INVALID_ARGUMENT", { message: `${optionName} must be a positive integer` });
|
|
1415
|
+
return parsed;
|
|
1416
|
+
};
|
|
1417
|
+
const ensureArgumentCount = ({ command, args, min, max }) => {
|
|
1418
|
+
if (args.length < min || args.length > max) throw createCliError("INVALID_ARGUMENT", {
|
|
1419
|
+
message: `${command} expects ${String(min)}-${String(max)} positional argument(s), received ${String(args.length)}`,
|
|
1420
|
+
details: {
|
|
1421
|
+
command,
|
|
1422
|
+
args
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
};
|
|
1426
|
+
const ensureHasCommandAfterDoubleDash = ({ command, argsAfterDoubleDash }) => {
|
|
1427
|
+
if (argsAfterDoubleDash.length === 0) throw createCliError("INVALID_ARGUMENT", { message: `${command} requires arguments after --` });
|
|
1428
|
+
};
|
|
1429
|
+
const readGitConfigInt = async (repoRoot, key) => {
|
|
1430
|
+
const result = await runGitCommand({
|
|
1431
|
+
cwd: repoRoot,
|
|
1432
|
+
args: [
|
|
1433
|
+
"config",
|
|
1434
|
+
"--get",
|
|
1435
|
+
key
|
|
1436
|
+
],
|
|
1437
|
+
reject: false
|
|
1438
|
+
});
|
|
1439
|
+
if (result.exitCode !== 0) return;
|
|
1440
|
+
const parsed = Number.parseInt(result.stdout.trim(), 10);
|
|
1441
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
1442
|
+
};
|
|
1443
|
+
const readGitConfigBoolean = async (repoRoot, key) => {
|
|
1444
|
+
const result = await runGitCommand({
|
|
1445
|
+
cwd: repoRoot,
|
|
1446
|
+
args: [
|
|
1447
|
+
"config",
|
|
1448
|
+
"--bool",
|
|
1449
|
+
"--get",
|
|
1450
|
+
key
|
|
1451
|
+
],
|
|
1452
|
+
reject: false
|
|
1453
|
+
});
|
|
1454
|
+
if (result.exitCode !== 0) return;
|
|
1455
|
+
const value = result.stdout.trim().toLowerCase();
|
|
1456
|
+
if (value === "true" || value === "yes" || value === "on" || value === "1") return true;
|
|
1457
|
+
if (value === "false" || value === "no" || value === "off" || value === "0") return false;
|
|
1458
|
+
};
|
|
1459
|
+
const resolveConfiguredBaseRemote = async (repoRoot) => {
|
|
1460
|
+
const configured = await runGitCommand({
|
|
1461
|
+
cwd: repoRoot,
|
|
1462
|
+
args: [
|
|
1463
|
+
"config",
|
|
1464
|
+
"--get",
|
|
1465
|
+
"vde-worktree.baseRemote"
|
|
1466
|
+
],
|
|
1467
|
+
reject: false
|
|
1468
|
+
});
|
|
1469
|
+
if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
|
|
1470
|
+
return "origin";
|
|
1471
|
+
};
|
|
1472
|
+
const resolveBaseBranch = async (repoRoot) => {
|
|
1473
|
+
const configured = await runGitCommand({
|
|
1474
|
+
cwd: repoRoot,
|
|
1475
|
+
args: [
|
|
1476
|
+
"config",
|
|
1477
|
+
"--get",
|
|
1478
|
+
"vde-worktree.baseBranch"
|
|
1479
|
+
],
|
|
1480
|
+
reject: false
|
|
1481
|
+
});
|
|
1482
|
+
if (configured.exitCode === 0 && configured.stdout.trim().length > 0) return configured.stdout.trim();
|
|
1483
|
+
const remotesToProbe = [
|
|
1484
|
+
await resolveConfiguredBaseRemote(repoRoot),
|
|
1485
|
+
"origin",
|
|
1486
|
+
"upstream"
|
|
1487
|
+
].filter((value, index, arr) => {
|
|
1488
|
+
return arr.indexOf(value) === index;
|
|
1489
|
+
});
|
|
1490
|
+
for (const remote of remotesToProbe) {
|
|
1491
|
+
const resolved = await runGitCommand({
|
|
1492
|
+
cwd: repoRoot,
|
|
1493
|
+
args: [
|
|
1494
|
+
"symbolic-ref",
|
|
1495
|
+
"--quiet",
|
|
1496
|
+
"--short",
|
|
1497
|
+
`refs/remotes/${remote}/HEAD`
|
|
1498
|
+
],
|
|
1499
|
+
reject: false
|
|
1500
|
+
});
|
|
1501
|
+
if (resolved.exitCode !== 0) continue;
|
|
1502
|
+
const raw = resolved.stdout.trim();
|
|
1503
|
+
const prefix = `${remote}/`;
|
|
1504
|
+
if (raw.startsWith(prefix)) return raw.slice(prefix.length);
|
|
1505
|
+
}
|
|
1506
|
+
for (const candidate of ["main", "master"]) if (await doesGitRefExist(repoRoot, `refs/heads/${candidate}`)) return candidate;
|
|
1507
|
+
throw createCliError("INVALID_ARGUMENT", { message: "Unable to resolve base branch. Configure vde-worktree.baseBranch." });
|
|
1508
|
+
};
|
|
1509
|
+
const ensureTargetPathWritable = async (targetPath) => {
|
|
1510
|
+
try {
|
|
1511
|
+
await access(targetPath, constants.F_OK);
|
|
1512
|
+
} catch {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if ((await readdir(targetPath)).length > 0) throw createCliError("TARGET_PATH_NOT_EMPTY", {
|
|
1516
|
+
message: `Target path is not empty: ${targetPath}`,
|
|
1517
|
+
details: { path: targetPath }
|
|
1518
|
+
});
|
|
1519
|
+
};
|
|
1520
|
+
const buildJsonSuccess = ({ command, status, repoRoot, details }) => {
|
|
1521
|
+
return {
|
|
1522
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1523
|
+
command,
|
|
1524
|
+
status,
|
|
1525
|
+
repoRoot,
|
|
1526
|
+
...details ?? {}
|
|
1527
|
+
};
|
|
1528
|
+
};
|
|
1529
|
+
const buildJsonError = ({ command, repoRoot, error }) => {
|
|
1530
|
+
return {
|
|
1531
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1532
|
+
command,
|
|
1533
|
+
status: "error",
|
|
1534
|
+
repoRoot,
|
|
1535
|
+
code: error.code,
|
|
1536
|
+
message: error.message,
|
|
1537
|
+
details: error.details
|
|
1538
|
+
};
|
|
1539
|
+
};
|
|
1540
|
+
const resolveTargetWorktreeByBranch = ({ branch, worktrees }) => {
|
|
1541
|
+
const found = worktrees.find((worktree) => worktree.branch === branch);
|
|
1542
|
+
if (found !== void 0) return found;
|
|
1543
|
+
throw createCliError("WORKTREE_NOT_FOUND", {
|
|
1544
|
+
message: `Worktree not found for branch: ${branch}`,
|
|
1545
|
+
details: { branch }
|
|
1546
|
+
});
|
|
1547
|
+
};
|
|
1548
|
+
const resolveCurrentWorktree = ({ snapshot, currentWorktreeRoot }) => {
|
|
1549
|
+
const directMatch = snapshot.worktrees.find((worktree) => worktree.path === currentWorktreeRoot);
|
|
1550
|
+
if (directMatch !== void 0) return directMatch;
|
|
1551
|
+
const containing = snapshot.worktrees.find((worktree) => {
|
|
1552
|
+
return currentWorktreeRoot.startsWith(`${worktree.path}${sep}`);
|
|
1553
|
+
});
|
|
1554
|
+
if (containing !== void 0) return containing;
|
|
1555
|
+
throw createCliError("WORKTREE_NOT_FOUND", {
|
|
1556
|
+
message: "No worktree found for current location",
|
|
1557
|
+
details: { currentWorktreeRoot }
|
|
1558
|
+
});
|
|
1559
|
+
};
|
|
1560
|
+
const validateInitializedForWrite = async (repoRoot) => {
|
|
1561
|
+
if (await isInitialized(repoRoot)) return;
|
|
1562
|
+
throw createCliError("NOT_INITIALIZED", {
|
|
1563
|
+
message: "Repository is not initialized. Run `vde-worktree init` first.",
|
|
1564
|
+
details: { repoRoot }
|
|
1565
|
+
});
|
|
1566
|
+
};
|
|
1567
|
+
const randomWipBranchName = () => {
|
|
1568
|
+
const random = Math.floor(Math.random() * 1e6);
|
|
1569
|
+
return `wip-${String(random).padStart(6, "0")}`;
|
|
1570
|
+
};
|
|
1571
|
+
const parseForceFlags = (parsedArgs) => {
|
|
1572
|
+
const globalForce = parsedArgs.force === true;
|
|
1573
|
+
return {
|
|
1574
|
+
forceDirty: globalForce || parsedArgs.forceDirty === true,
|
|
1575
|
+
allowUnpushed: globalForce || parsedArgs.allowUnpushed === true,
|
|
1576
|
+
forceUnmerged: globalForce || parsedArgs.forceUnmerged === true,
|
|
1577
|
+
forceLocked: globalForce || parsedArgs.forceLocked === true
|
|
1578
|
+
};
|
|
1579
|
+
};
|
|
1580
|
+
const hasAnyForceFlag = (flags) => {
|
|
1581
|
+
return flags.forceDirty || flags.allowUnpushed || flags.forceUnmerged || flags.forceLocked;
|
|
1582
|
+
};
|
|
1583
|
+
const ensureUnsafeForNonTty = ({ runtime, reason }) => {
|
|
1584
|
+
if (runtime.isInteractive || runtime.allowUnsafe) return;
|
|
1585
|
+
throw createCliError("UNSAFE_FLAG_REQUIRED", { message: `UNSAFE_FLAG_REQUIRED: ${reason}` });
|
|
1586
|
+
};
|
|
1587
|
+
const resolveRemoteAndBranch = (remoteBranch) => {
|
|
1588
|
+
const separatorIndex = remoteBranch.indexOf("/");
|
|
1589
|
+
if (separatorIndex <= 0 || separatorIndex >= remoteBranch.length - 1) throw createCliError("INVALID_REMOTE_BRANCH_FORMAT", {
|
|
1590
|
+
message: `Invalid remote branch format: ${remoteBranch}`,
|
|
1591
|
+
details: { value: remoteBranch }
|
|
1592
|
+
});
|
|
1593
|
+
return {
|
|
1594
|
+
remote: remoteBranch.slice(0, separatorIndex),
|
|
1595
|
+
branch: remoteBranch.slice(separatorIndex + 1)
|
|
1596
|
+
};
|
|
1597
|
+
};
|
|
1598
|
+
const resolveCompletionShell = (value) => {
|
|
1599
|
+
if (COMPLETION_SHELLS.includes(value)) return value;
|
|
1600
|
+
throw createCliError("INVALID_ARGUMENT", {
|
|
1601
|
+
message: `Unsupported shell for completion: ${value}`,
|
|
1602
|
+
details: {
|
|
1603
|
+
value,
|
|
1604
|
+
supported: COMPLETION_SHELLS
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
};
|
|
1608
|
+
const resolveCompletionSourceCandidates = (shell) => {
|
|
1609
|
+
const relativeCompletionFile = COMPLETION_FILE_BY_SHELL[shell];
|
|
1610
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
1611
|
+
return [
|
|
1612
|
+
resolve(moduleDirectory, "..", "..", "completions", relativeCompletionFile),
|
|
1613
|
+
resolve(moduleDirectory, "..", "completions", relativeCompletionFile),
|
|
1614
|
+
resolve(process.cwd(), "completions", relativeCompletionFile)
|
|
1615
|
+
];
|
|
1616
|
+
};
|
|
1617
|
+
const loadCompletionScript = async (shell) => {
|
|
1618
|
+
const candidates = resolveCompletionSourceCandidates(shell);
|
|
1619
|
+
for (const candidate of candidates) try {
|
|
1620
|
+
return await readFile(candidate, "utf8");
|
|
1621
|
+
} catch {
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
throw createCliError("INTERNAL_ERROR", {
|
|
1625
|
+
message: `Completion template not found for shell: ${shell}`,
|
|
1626
|
+
details: {
|
|
1627
|
+
shell,
|
|
1628
|
+
candidates
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
};
|
|
1632
|
+
const resolveDefaultCompletionInstallPath = (shell) => {
|
|
1633
|
+
const homeDirectory = homedir();
|
|
1634
|
+
if (homeDirectory.length === 0) throw createCliError("INTERNAL_ERROR", {
|
|
1635
|
+
message: "Unable to resolve home directory for completion installation",
|
|
1636
|
+
details: { shell }
|
|
1637
|
+
});
|
|
1638
|
+
if (shell === "zsh") return join(homeDirectory, ".zsh", "completions", "_vw");
|
|
1639
|
+
return join(homeDirectory, ".config", "fish", "completions", "vw.fish");
|
|
1640
|
+
};
|
|
1641
|
+
const resolveCompletionInstallPath = ({ shell, requestedPath }) => {
|
|
1642
|
+
if (typeof requestedPath === "string" && requestedPath.trim().length > 0) return resolve(requestedPath);
|
|
1643
|
+
return resolveDefaultCompletionInstallPath(shell);
|
|
1644
|
+
};
|
|
1645
|
+
const installCompletionScript = async ({ content, destinationPath }) => {
|
|
1646
|
+
await mkdir(dirname(destinationPath), { recursive: true });
|
|
1647
|
+
await writeFile(destinationPath, content, "utf8");
|
|
1648
|
+
};
|
|
1649
|
+
const normalizeHookName = (value) => {
|
|
1650
|
+
if (/^(pre|post)-[a-z0-9][a-z0-9-]*$/.test(value) !== true) throw createCliError("INVALID_ARGUMENT", {
|
|
1651
|
+
message: "hookName must be pre-* or post-*",
|
|
1652
|
+
details: { hookName: value }
|
|
1653
|
+
});
|
|
1654
|
+
return value;
|
|
1655
|
+
};
|
|
1656
|
+
const defaultOwner = () => {
|
|
1657
|
+
return process.env.USER ?? process.env.USERNAME ?? "unknown";
|
|
1658
|
+
};
|
|
1659
|
+
const resolveBranchDeleteMode = (forceFlags) => {
|
|
1660
|
+
if (forceFlags.forceDirty || forceFlags.forceUnmerged || forceFlags.allowUnpushed || forceFlags.forceLocked) return "-D";
|
|
1661
|
+
return "-d";
|
|
1662
|
+
};
|
|
1663
|
+
const validateDeleteSafety = ({ target, forceFlags }) => {
|
|
1664
|
+
if (target.dirty && forceFlags.forceDirty !== true) throw createCliError("DIRTY_WORKTREE", {
|
|
1665
|
+
message: "Worktree has uncommitted changes",
|
|
1666
|
+
details: {
|
|
1667
|
+
branch: target.branch,
|
|
1668
|
+
path: target.path
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
if (target.locked.value && forceFlags.forceLocked !== true) throw createCliError("LOCKED_WORKTREE", {
|
|
1672
|
+
message: "Worktree is locked",
|
|
1673
|
+
details: {
|
|
1674
|
+
branch: target.branch,
|
|
1675
|
+
path: target.path,
|
|
1676
|
+
reason: target.locked.reason
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
if (target.merged.overall !== true && forceFlags.forceUnmerged !== true) throw createCliError("UNMERGED_WORKTREE", {
|
|
1680
|
+
message: "Worktree is not merged (or merge state is unknown)",
|
|
1681
|
+
details: {
|
|
1682
|
+
branch: target.branch,
|
|
1683
|
+
path: target.path,
|
|
1684
|
+
merged: target.merged
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
if ((target.upstream.ahead === null || target.upstream.ahead > 0) && forceFlags.allowUnpushed !== true) throw createCliError("UNPUSHED_WORKTREE", {
|
|
1688
|
+
message: "Worktree has unpushed commits (or push state is unknown)",
|
|
1689
|
+
details: {
|
|
1690
|
+
branch: target.branch,
|
|
1691
|
+
path: target.path,
|
|
1692
|
+
upstream: target.upstream
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
};
|
|
1696
|
+
const resolveLinkTargetPath = ({ sourcePath, destinationPath }) => {
|
|
1697
|
+
return relative(dirname(destinationPath), sourcePath);
|
|
1698
|
+
};
|
|
1699
|
+
const resolveFileCopyTargets = ({ repoRoot, worktreePath, relativePath }) => {
|
|
1700
|
+
const sourcePath = resolveRepoRelativePath({
|
|
1701
|
+
repoRoot,
|
|
1702
|
+
relativePath
|
|
1703
|
+
});
|
|
1704
|
+
const relativeFromRoot = relative(repoRoot, sourcePath);
|
|
1705
|
+
return {
|
|
1706
|
+
sourcePath,
|
|
1707
|
+
destinationPath: ensurePathInsideRepo({
|
|
1708
|
+
repoRoot,
|
|
1709
|
+
path: resolve(worktreePath, relativeFromRoot)
|
|
1710
|
+
}),
|
|
1711
|
+
relativeFromRoot
|
|
1712
|
+
};
|
|
1713
|
+
};
|
|
1714
|
+
const ensureBranchIsNotPrimary = ({ branch, baseBranch }) => {
|
|
1715
|
+
if (branch !== baseBranch) return;
|
|
1716
|
+
throw createCliError("INVALID_ARGUMENT", {
|
|
1717
|
+
message: "extract cannot target the base branch",
|
|
1718
|
+
details: {
|
|
1719
|
+
branch,
|
|
1720
|
+
baseBranch
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
};
|
|
1724
|
+
const formatDisplayPath = (absolutePath) => {
|
|
1725
|
+
const homeDirectory = homedir();
|
|
1726
|
+
if (homeDirectory.length === 0) return absolutePath;
|
|
1727
|
+
if (absolutePath === homeDirectory) return "~";
|
|
1728
|
+
if (absolutePath.startsWith(`${homeDirectory}${sep}`)) return `~${absolutePath.slice(homeDirectory.length)}`;
|
|
1729
|
+
return absolutePath;
|
|
1730
|
+
};
|
|
1731
|
+
const padEndByWidth = (value, targetWidth) => {
|
|
1732
|
+
const width = stringWidth(value);
|
|
1733
|
+
if (width >= targetWidth) return value;
|
|
1734
|
+
return `${value}${" ".repeat(targetWidth - width)}`;
|
|
1735
|
+
};
|
|
1736
|
+
const containsBranch = ({ branch, worktrees }) => {
|
|
1737
|
+
return worktrees.some((worktree) => worktree.branch === branch);
|
|
1738
|
+
};
|
|
1739
|
+
const readStringOption = (parsedArgsRecord, key) => {
|
|
1740
|
+
const value = parsedArgsRecord[key];
|
|
1741
|
+
if (typeof value === "string") return value;
|
|
1742
|
+
};
|
|
1743
|
+
const findCommandHelp = (commandName) => {
|
|
1744
|
+
return commandHelpEntries.find((entry) => entry.name === commandName);
|
|
1745
|
+
};
|
|
1746
|
+
const renderGeneralHelpText = ({ version }) => {
|
|
1747
|
+
const commandList = commandHelpEntries.map((entry) => ` ${entry.name.padEnd(8)} ${entry.summary}`).join("\n");
|
|
1748
|
+
return [
|
|
1749
|
+
"vde-worktree",
|
|
1750
|
+
"",
|
|
1751
|
+
"Usage:",
|
|
1752
|
+
" vw <command> [options]",
|
|
1753
|
+
" vde-worktree <command> [options]",
|
|
1754
|
+
"",
|
|
1755
|
+
`Version: ${version}`,
|
|
1756
|
+
"",
|
|
1757
|
+
"Commands:",
|
|
1758
|
+
commandList,
|
|
1759
|
+
"",
|
|
1760
|
+
"Global options:",
|
|
1761
|
+
" --json Output machine-readable JSON.",
|
|
1762
|
+
" --verbose Enable verbose logs.",
|
|
1763
|
+
" --no-hooks Disable hooks for this run (requires --allow-unsafe).",
|
|
1764
|
+
" --allow-unsafe Explicitly allow unsafe behavior in non-TTY mode.",
|
|
1765
|
+
" --hook-timeout-ms <ms> Override hook timeout.",
|
|
1766
|
+
" --lock-timeout-ms <ms> Override repository lock timeout.",
|
|
1767
|
+
" -h, --help Show help.",
|
|
1768
|
+
" -v, --version Show version.",
|
|
1769
|
+
"",
|
|
1770
|
+
"Help commands:",
|
|
1771
|
+
" vw help",
|
|
1772
|
+
" vw help <command>",
|
|
1773
|
+
" vw <command> --help",
|
|
1774
|
+
"",
|
|
1775
|
+
"Examples:",
|
|
1776
|
+
" vw switch feature/foo",
|
|
1777
|
+
" cd \"$(vw cd)\"",
|
|
1778
|
+
" vw completion zsh --install",
|
|
1779
|
+
" vw del feature/foo --force-unmerged --allow-unpushed --allow-unsafe"
|
|
1780
|
+
].join("\n");
|
|
1781
|
+
};
|
|
1782
|
+
const renderCommandHelpText = ({ entry }) => {
|
|
1783
|
+
const lines = [
|
|
1784
|
+
`Command: ${entry.name}`,
|
|
1785
|
+
"",
|
|
1786
|
+
"Usage:",
|
|
1787
|
+
` ${entry.usage}`,
|
|
1788
|
+
"",
|
|
1789
|
+
"Summary:",
|
|
1790
|
+
` ${entry.summary}`
|
|
1791
|
+
];
|
|
1792
|
+
if (entry.details.length > 0) {
|
|
1793
|
+
lines.push("", "Details:");
|
|
1794
|
+
for (const detail of entry.details) lines.push(` - ${detail}`);
|
|
1795
|
+
}
|
|
1796
|
+
if (entry.options !== void 0 && entry.options.length > 0) {
|
|
1797
|
+
lines.push("", "Options:");
|
|
1798
|
+
for (const option of entry.options) lines.push(` - ${option}`);
|
|
1799
|
+
}
|
|
1800
|
+
if (entry.examples !== void 0 && entry.examples.length > 0) {
|
|
1801
|
+
lines.push("", "Examples:");
|
|
1802
|
+
for (const example of entry.examples) lines.push(` ${example}`);
|
|
1803
|
+
}
|
|
1804
|
+
lines.push("", "Show all commands: vw help");
|
|
1805
|
+
return lines.join("\n");
|
|
1806
|
+
};
|
|
1807
|
+
const createHookContext = ({ runtime, repoRoot, action, branch, worktreePath, stderr, extraEnv }) => {
|
|
1808
|
+
return {
|
|
1809
|
+
repoRoot,
|
|
1810
|
+
action,
|
|
1811
|
+
branch,
|
|
1812
|
+
worktreePath,
|
|
1813
|
+
timeoutMs: runtime.hookTimeoutMs,
|
|
1814
|
+
enabled: runtime.hooksEnabled,
|
|
1815
|
+
strictPostHooks: runtime.strictPostHooks,
|
|
1816
|
+
stderr,
|
|
1817
|
+
extraEnv
|
|
1818
|
+
};
|
|
1819
|
+
};
|
|
1820
|
+
const createCli = (options = {}) => {
|
|
1821
|
+
const require = createRequire(import.meta.url);
|
|
1822
|
+
const version = options.version ?? (() => {
|
|
1823
|
+
try {
|
|
1824
|
+
return loadPackageVersion(require);
|
|
1825
|
+
} catch {
|
|
1826
|
+
return "0.0.0";
|
|
1827
|
+
}
|
|
1828
|
+
})();
|
|
1829
|
+
const runtimeCwd = options.cwd ?? process.cwd();
|
|
1830
|
+
const stdout = options.stdout ?? ((line) => console.log(line));
|
|
1831
|
+
const stderr = options.stderr ?? ((line) => console.error(line));
|
|
1832
|
+
const selectPathWithFzf$1 = options.selectPathWithFzf ?? selectPathWithFzf;
|
|
1833
|
+
const isInteractiveFn = options.isInteractive ?? (() => process.stdout.isTTY === true && process.stderr.isTTY === true);
|
|
1834
|
+
let logger = createLogger();
|
|
1835
|
+
const rootArgsDef = {
|
|
1836
|
+
command: {
|
|
1837
|
+
type: "positional",
|
|
1838
|
+
description: "Command name",
|
|
1839
|
+
required: false
|
|
1840
|
+
},
|
|
1841
|
+
prompt: {
|
|
1842
|
+
type: "string",
|
|
1843
|
+
valueHint: "text",
|
|
1844
|
+
description: "Custom fzf prompt for cd command"
|
|
1845
|
+
},
|
|
1846
|
+
fzfArg: {
|
|
1847
|
+
type: "string",
|
|
1848
|
+
valueHint: "arg",
|
|
1849
|
+
description: "Additional argument passed to fzf (repeatable)"
|
|
1850
|
+
},
|
|
1851
|
+
json: {
|
|
1852
|
+
type: "boolean",
|
|
1853
|
+
description: "Output JSON on stdout"
|
|
1854
|
+
},
|
|
1855
|
+
verbose: {
|
|
1856
|
+
type: "boolean",
|
|
1857
|
+
description: "Show detailed logs"
|
|
1858
|
+
},
|
|
1859
|
+
hooks: {
|
|
1860
|
+
type: "boolean",
|
|
1861
|
+
description: "Enable hooks (disable with --no-hooks)",
|
|
1862
|
+
default: true
|
|
1863
|
+
},
|
|
1864
|
+
allowUnsafe: {
|
|
1865
|
+
type: "boolean",
|
|
1866
|
+
description: "Allow unsafe operations"
|
|
1867
|
+
},
|
|
1868
|
+
strictPostHooks: {
|
|
1869
|
+
type: "boolean",
|
|
1870
|
+
description: "Fail when post hooks fail"
|
|
1871
|
+
},
|
|
1872
|
+
hookTimeoutMs: {
|
|
1873
|
+
type: "string",
|
|
1874
|
+
valueHint: "ms",
|
|
1875
|
+
description: "Override hook timeout (ms)"
|
|
1876
|
+
},
|
|
1877
|
+
lockTimeoutMs: {
|
|
1878
|
+
type: "string",
|
|
1879
|
+
valueHint: "ms",
|
|
1880
|
+
description: "Override lock timeout (ms)"
|
|
1881
|
+
},
|
|
1882
|
+
allowAgent: {
|
|
1883
|
+
type: "boolean",
|
|
1884
|
+
description: "Allow non-TTY execution for use command"
|
|
1885
|
+
},
|
|
1886
|
+
reason: {
|
|
1887
|
+
type: "string",
|
|
1888
|
+
valueHint: "text",
|
|
1889
|
+
description: "Reason text for lock command"
|
|
1890
|
+
},
|
|
1891
|
+
owner: {
|
|
1892
|
+
type: "string",
|
|
1893
|
+
valueHint: "owner",
|
|
1894
|
+
description: "Owner for lock/unlock commands"
|
|
1895
|
+
},
|
|
1896
|
+
force: {
|
|
1897
|
+
type: "boolean",
|
|
1898
|
+
description: "Force operation"
|
|
1899
|
+
},
|
|
1900
|
+
forceDirty: {
|
|
1901
|
+
type: "boolean",
|
|
1902
|
+
description: "Allow dirty worktree for del"
|
|
1903
|
+
},
|
|
1904
|
+
allowUnpushed: {
|
|
1905
|
+
type: "boolean",
|
|
1906
|
+
description: "Allow unpushed commits for del"
|
|
1907
|
+
},
|
|
1908
|
+
forceUnmerged: {
|
|
1909
|
+
type: "boolean",
|
|
1910
|
+
description: "Allow unmerged worktree for del"
|
|
1911
|
+
},
|
|
1912
|
+
forceLocked: {
|
|
1913
|
+
type: "boolean",
|
|
1914
|
+
description: "Allow deleting locked worktree"
|
|
1915
|
+
},
|
|
1916
|
+
apply: {
|
|
1917
|
+
type: "boolean",
|
|
1918
|
+
description: "Apply changes"
|
|
1919
|
+
},
|
|
1920
|
+
dryRun: {
|
|
1921
|
+
type: "boolean",
|
|
1922
|
+
description: "Dry-run mode"
|
|
1923
|
+
},
|
|
1924
|
+
current: {
|
|
1925
|
+
type: "boolean",
|
|
1926
|
+
description: "Use current worktree for extract"
|
|
1927
|
+
},
|
|
1928
|
+
from: {
|
|
1929
|
+
type: "string",
|
|
1930
|
+
valueHint: "path",
|
|
1931
|
+
description: "Path used by extract --from"
|
|
1932
|
+
},
|
|
1933
|
+
stash: {
|
|
1934
|
+
type: "boolean",
|
|
1935
|
+
description: "Allow stash for extract"
|
|
1936
|
+
},
|
|
1937
|
+
fallback: {
|
|
1938
|
+
type: "boolean",
|
|
1939
|
+
description: "Enable fallback behavior (disable with --no-fallback)",
|
|
1940
|
+
default: true
|
|
1941
|
+
},
|
|
1942
|
+
install: {
|
|
1943
|
+
type: "boolean",
|
|
1944
|
+
description: "Install generated artifacts to default location (used by completion command)"
|
|
1945
|
+
},
|
|
1946
|
+
path: {
|
|
1947
|
+
type: "string",
|
|
1948
|
+
valueHint: "path",
|
|
1949
|
+
description: "Custom file path (used by completion command install)"
|
|
1950
|
+
},
|
|
1951
|
+
help: {
|
|
1952
|
+
type: "boolean",
|
|
1953
|
+
alias: "h",
|
|
1954
|
+
description: "Show help"
|
|
1955
|
+
},
|
|
1956
|
+
version: {
|
|
1957
|
+
type: "boolean",
|
|
1958
|
+
alias: "v",
|
|
1959
|
+
description: "Show version"
|
|
1960
|
+
}
|
|
1961
|
+
};
|
|
1962
|
+
const optionSpecs = buildOptionSpecs(rootArgsDef);
|
|
1963
|
+
const run = async (rawArgs = process.argv.slice(2)) => {
|
|
1964
|
+
logger = createLogger();
|
|
1965
|
+
let command = "unknown";
|
|
1966
|
+
let jsonEnabled = false;
|
|
1967
|
+
let repoRootForJson = null;
|
|
1968
|
+
try {
|
|
1969
|
+
const { beforeDoubleDash, afterDoubleDash } = splitRawArgsByDoubleDash(rawArgs);
|
|
1970
|
+
validateRawOptions(beforeDoubleDash, optionSpecs);
|
|
1971
|
+
const parsedArgs = parseArgs(beforeDoubleDash, rootArgsDef);
|
|
1972
|
+
const parsedArgsRecord = parsedArgs;
|
|
1973
|
+
const positionals = getPositionals(parsedArgs);
|
|
1974
|
+
command = positionals[0] ?? "unknown";
|
|
1975
|
+
jsonEnabled = parsedArgs.json === true;
|
|
1976
|
+
if (parsedArgs.help === true) {
|
|
1977
|
+
const commandHelpTarget = typeof command === "string" && command !== "unknown" && command !== "help" ? command : null;
|
|
1978
|
+
if (commandHelpTarget !== null) {
|
|
1979
|
+
const entry = findCommandHelp(commandHelpTarget);
|
|
1980
|
+
if (entry !== void 0) {
|
|
1981
|
+
stdout(`${renderCommandHelpText({ entry })}\n`);
|
|
1982
|
+
return EXIT_CODE.OK;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
stdout(`${renderGeneralHelpText({ version })}\n`);
|
|
1986
|
+
return EXIT_CODE.OK;
|
|
1987
|
+
}
|
|
1988
|
+
if (parsedArgs.version === true) {
|
|
1989
|
+
stdout(version);
|
|
1990
|
+
return EXIT_CODE.OK;
|
|
1991
|
+
}
|
|
1992
|
+
logger = parsedArgs.verbose === true ? createLogger({ level: LogLevel.INFO }) : createLogger();
|
|
1993
|
+
if (positionals.length === 0) {
|
|
1994
|
+
stdout(`${renderGeneralHelpText({ version })}\n`);
|
|
1995
|
+
return EXIT_CODE.OK;
|
|
1996
|
+
}
|
|
1997
|
+
if (command === "help") {
|
|
1998
|
+
const helpTarget = positionals[1];
|
|
1999
|
+
if (typeof helpTarget !== "string" || helpTarget.length === 0) {
|
|
2000
|
+
stdout(`${renderGeneralHelpText({ version })}\n`);
|
|
2001
|
+
return EXIT_CODE.OK;
|
|
2002
|
+
}
|
|
2003
|
+
const entry = findCommandHelp(helpTarget);
|
|
2004
|
+
if (entry === void 0) throw createCliError("INVALID_ARGUMENT", {
|
|
2005
|
+
message: `Unknown command for help: ${helpTarget}`,
|
|
2006
|
+
details: {
|
|
2007
|
+
requested: helpTarget,
|
|
2008
|
+
availableCommands: commandHelpEntries.map((item) => item.name)
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
stdout(`${renderCommandHelpText({ entry })}\n`);
|
|
2012
|
+
return EXIT_CODE.OK;
|
|
2013
|
+
}
|
|
2014
|
+
const commandArgs = positionals.slice(1);
|
|
2015
|
+
if (command === "completion") {
|
|
2016
|
+
ensureArgumentCount({
|
|
2017
|
+
command,
|
|
2018
|
+
args: commandArgs,
|
|
2019
|
+
min: 1,
|
|
2020
|
+
max: 1
|
|
2021
|
+
});
|
|
2022
|
+
const shell = resolveCompletionShell(commandArgs[0]);
|
|
2023
|
+
const script = await loadCompletionScript(shell);
|
|
2024
|
+
if (parsedArgs.install === true) {
|
|
2025
|
+
const destinationPath = resolveCompletionInstallPath({
|
|
2026
|
+
shell,
|
|
2027
|
+
requestedPath: readStringOption(parsedArgsRecord, "path")
|
|
2028
|
+
});
|
|
2029
|
+
await installCompletionScript({
|
|
2030
|
+
content: script,
|
|
2031
|
+
destinationPath
|
|
2032
|
+
});
|
|
2033
|
+
if (jsonEnabled) {
|
|
2034
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2035
|
+
command,
|
|
2036
|
+
status: "ok",
|
|
2037
|
+
repoRoot: null,
|
|
2038
|
+
details: {
|
|
2039
|
+
shell,
|
|
2040
|
+
installed: true,
|
|
2041
|
+
path: destinationPath
|
|
2042
|
+
}
|
|
2043
|
+
})));
|
|
2044
|
+
return EXIT_CODE.OK;
|
|
2045
|
+
}
|
|
2046
|
+
stdout(`installed completion: ${destinationPath}`);
|
|
2047
|
+
if (shell === "zsh") stdout("zsh note: ensure completion path is in fpath, then run: autoload -Uz compinit && compinit");
|
|
2048
|
+
return EXIT_CODE.OK;
|
|
2049
|
+
}
|
|
2050
|
+
if (jsonEnabled) {
|
|
2051
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2052
|
+
command,
|
|
2053
|
+
status: "ok",
|
|
2054
|
+
repoRoot: null,
|
|
2055
|
+
details: {
|
|
2056
|
+
shell,
|
|
2057
|
+
installed: false,
|
|
2058
|
+
script
|
|
2059
|
+
}
|
|
2060
|
+
})));
|
|
2061
|
+
return EXIT_CODE.OK;
|
|
2062
|
+
}
|
|
2063
|
+
stdout(script);
|
|
2064
|
+
return EXIT_CODE.OK;
|
|
2065
|
+
}
|
|
2066
|
+
const allowUnsafe = parsedArgs.allowUnsafe === true;
|
|
2067
|
+
if (parsedArgs.hooks === false && allowUnsafe !== true) throw createCliError("UNSAFE_FLAG_REQUIRED", { message: "UNSAFE_FLAG_REQUIRED: --no-hooks requires --allow-unsafe" });
|
|
2068
|
+
const repoContext = await resolveRepoContext(runtimeCwd);
|
|
2069
|
+
const repoRoot = repoContext.repoRoot;
|
|
2070
|
+
repoRootForJson = repoRoot;
|
|
2071
|
+
const configuredHookTimeoutMs = await readGitConfigInt(repoRoot, "vde-worktree.hookTimeoutMs");
|
|
2072
|
+
const configuredLockTimeoutMs = await readGitConfigInt(repoRoot, "vde-worktree.lockTimeoutMs");
|
|
2073
|
+
const configuredStaleTTL = await readGitConfigInt(repoRoot, "vde-worktree.staleLockTTLSeconds");
|
|
2074
|
+
const configuredHooksEnabled = await readGitConfigBoolean(repoRoot, "vde-worktree.hooksEnabled");
|
|
2075
|
+
const runtime = {
|
|
2076
|
+
command,
|
|
2077
|
+
json: jsonEnabled,
|
|
2078
|
+
hooksEnabled: parsedArgs.hooks !== false && configuredHooksEnabled !== false,
|
|
2079
|
+
strictPostHooks: parsedArgs.strictPostHooks === true,
|
|
2080
|
+
hookTimeoutMs: readNumberFromEnvOrDefault({
|
|
2081
|
+
rawValue: toNumberOption({
|
|
2082
|
+
value: parsedArgs.hookTimeoutMs,
|
|
2083
|
+
optionName: "--hook-timeout-ms"
|
|
2084
|
+
}) ?? configuredHookTimeoutMs,
|
|
2085
|
+
defaultValue: DEFAULT_HOOK_TIMEOUT_MS
|
|
2086
|
+
}),
|
|
2087
|
+
lockTimeoutMs: readNumberFromEnvOrDefault({
|
|
2088
|
+
rawValue: toNumberOption({
|
|
2089
|
+
value: parsedArgs.lockTimeoutMs,
|
|
2090
|
+
optionName: "--lock-timeout-ms"
|
|
2091
|
+
}) ?? configuredLockTimeoutMs,
|
|
2092
|
+
defaultValue: DEFAULT_LOCK_TIMEOUT_MS
|
|
2093
|
+
}),
|
|
2094
|
+
allowUnsafe,
|
|
2095
|
+
isInteractive: isInteractiveFn()
|
|
2096
|
+
};
|
|
2097
|
+
const staleLockTTLSeconds = readNumberFromEnvOrDefault({
|
|
2098
|
+
rawValue: configuredStaleTTL,
|
|
2099
|
+
defaultValue: DEFAULT_STALE_LOCK_TTL_SECONDS
|
|
2100
|
+
});
|
|
2101
|
+
const runWriteOperation = async (task) => {
|
|
2102
|
+
if (WRITE_COMMANDS.has(command) !== true) return task();
|
|
2103
|
+
if (command !== "init") await validateInitializedForWrite(repoRoot);
|
|
2104
|
+
return withRepoLock({
|
|
2105
|
+
repoRoot,
|
|
2106
|
+
command,
|
|
2107
|
+
timeoutMs: runtime.lockTimeoutMs,
|
|
2108
|
+
staleLockTTLSeconds
|
|
2109
|
+
}, task);
|
|
2110
|
+
};
|
|
2111
|
+
if (command === "init") {
|
|
2112
|
+
ensureArgumentCount({
|
|
2113
|
+
command,
|
|
2114
|
+
args: commandArgs,
|
|
2115
|
+
min: 0,
|
|
2116
|
+
max: 0
|
|
2117
|
+
});
|
|
2118
|
+
const result = await runWriteOperation(async () => {
|
|
2119
|
+
const hookContext = createHookContext({
|
|
2120
|
+
runtime,
|
|
2121
|
+
repoRoot,
|
|
2122
|
+
action: "init",
|
|
2123
|
+
branch: null,
|
|
2124
|
+
worktreePath: repoRoot,
|
|
2125
|
+
stderr
|
|
2126
|
+
});
|
|
2127
|
+
await runPreHook({
|
|
2128
|
+
name: "init",
|
|
2129
|
+
context: hookContext
|
|
2130
|
+
});
|
|
2131
|
+
const initialized = await initializeRepository(repoRoot);
|
|
2132
|
+
await runPostHook({
|
|
2133
|
+
name: "init",
|
|
2134
|
+
context: hookContext
|
|
2135
|
+
});
|
|
2136
|
+
return initialized;
|
|
2137
|
+
});
|
|
2138
|
+
if (runtime.json) {
|
|
2139
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2140
|
+
command,
|
|
2141
|
+
status: "ok",
|
|
2142
|
+
repoRoot,
|
|
2143
|
+
details: {
|
|
2144
|
+
initialized: true,
|
|
2145
|
+
alreadyInitialized: result.alreadyInitialized
|
|
2146
|
+
}
|
|
2147
|
+
})));
|
|
2148
|
+
return EXIT_CODE.OK;
|
|
2149
|
+
}
|
|
2150
|
+
return EXIT_CODE.OK;
|
|
2151
|
+
}
|
|
2152
|
+
if (command === "list") {
|
|
2153
|
+
ensureArgumentCount({
|
|
2154
|
+
command,
|
|
2155
|
+
args: commandArgs,
|
|
2156
|
+
min: 0,
|
|
2157
|
+
max: 0
|
|
2158
|
+
});
|
|
2159
|
+
const snapshot = await collectWorktreeSnapshot(repoRoot);
|
|
2160
|
+
if (runtime.json) {
|
|
2161
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2162
|
+
command,
|
|
2163
|
+
status: "ok",
|
|
2164
|
+
repoRoot,
|
|
2165
|
+
details: {
|
|
2166
|
+
baseBranch: snapshot.baseBranch,
|
|
2167
|
+
worktrees: snapshot.worktrees
|
|
2168
|
+
}
|
|
2169
|
+
})));
|
|
2170
|
+
return EXIT_CODE.OK;
|
|
2171
|
+
}
|
|
2172
|
+
const rows = snapshot.worktrees.map((worktree) => {
|
|
2173
|
+
return {
|
|
2174
|
+
branch: worktree.branch ?? "(detached)",
|
|
2175
|
+
path: formatDisplayPath(worktree.path)
|
|
2176
|
+
};
|
|
2177
|
+
});
|
|
2178
|
+
const branchWidth = rows.reduce((max, row) => {
|
|
2179
|
+
return Math.max(max, stringWidth(row.branch));
|
|
2180
|
+
}, 0);
|
|
2181
|
+
for (const row of rows) stdout(`${padEndByWidth(row.branch, branchWidth)} ${row.path}`);
|
|
2182
|
+
return EXIT_CODE.OK;
|
|
2183
|
+
}
|
|
2184
|
+
if (command === "status") {
|
|
2185
|
+
ensureArgumentCount({
|
|
2186
|
+
command,
|
|
2187
|
+
args: commandArgs,
|
|
2188
|
+
min: 0,
|
|
2189
|
+
max: 1
|
|
2190
|
+
});
|
|
2191
|
+
const snapshot = await collectWorktreeSnapshot(repoRoot);
|
|
2192
|
+
const targetBranch = commandArgs[0];
|
|
2193
|
+
const targetWorktree = typeof targetBranch === "string" && targetBranch.length > 0 ? resolveTargetWorktreeByBranch({
|
|
2194
|
+
branch: targetBranch,
|
|
2195
|
+
worktrees: snapshot.worktrees
|
|
2196
|
+
}) : resolveCurrentWorktree({
|
|
2197
|
+
snapshot,
|
|
2198
|
+
currentWorktreeRoot: repoContext.currentWorktreeRoot
|
|
2199
|
+
});
|
|
2200
|
+
if (runtime.json) {
|
|
2201
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2202
|
+
command,
|
|
2203
|
+
status: "ok",
|
|
2204
|
+
repoRoot,
|
|
2205
|
+
details: { worktree: targetWorktree }
|
|
2206
|
+
})));
|
|
2207
|
+
return EXIT_CODE.OK;
|
|
2208
|
+
}
|
|
2209
|
+
stdout(`branch: ${targetWorktree.branch ?? "(detached)"}`);
|
|
2210
|
+
stdout(`path: ${formatDisplayPath(targetWorktree.path)}`);
|
|
2211
|
+
stdout(`dirty: ${targetWorktree.dirty ? "true" : "false"}`);
|
|
2212
|
+
stdout(`locked: ${targetWorktree.locked.value ? "true" : "false"}`);
|
|
2213
|
+
return EXIT_CODE.OK;
|
|
2214
|
+
}
|
|
2215
|
+
if (command === "path") {
|
|
2216
|
+
ensureArgumentCount({
|
|
2217
|
+
command,
|
|
2218
|
+
args: commandArgs,
|
|
2219
|
+
min: 1,
|
|
2220
|
+
max: 1
|
|
2221
|
+
});
|
|
2222
|
+
const branch = commandArgs[0];
|
|
2223
|
+
const target = resolveTargetWorktreeByBranch({
|
|
2224
|
+
branch,
|
|
2225
|
+
worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
|
|
2226
|
+
});
|
|
2227
|
+
if (runtime.json) {
|
|
2228
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2229
|
+
command,
|
|
2230
|
+
status: "ok",
|
|
2231
|
+
repoRoot,
|
|
2232
|
+
details: {
|
|
2233
|
+
branch,
|
|
2234
|
+
path: target.path
|
|
2235
|
+
}
|
|
2236
|
+
})));
|
|
2237
|
+
return EXIT_CODE.OK;
|
|
2238
|
+
}
|
|
2239
|
+
stdout(target.path);
|
|
2240
|
+
return EXIT_CODE.OK;
|
|
2241
|
+
}
|
|
2242
|
+
if (command === "new") {
|
|
2243
|
+
ensureArgumentCount({
|
|
2244
|
+
command,
|
|
2245
|
+
args: commandArgs,
|
|
2246
|
+
min: 0,
|
|
2247
|
+
max: 1
|
|
2248
|
+
});
|
|
2249
|
+
const branch = commandArgs[0] ?? randomWipBranchName();
|
|
2250
|
+
const result = await runWriteOperation(async () => {
|
|
2251
|
+
if (containsBranch({
|
|
2252
|
+
branch,
|
|
2253
|
+
worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
|
|
2254
|
+
})) throw createCliError("BRANCH_ALREADY_ATTACHED", {
|
|
2255
|
+
message: `Branch is already attached to a worktree: ${branch}`,
|
|
2256
|
+
details: { branch }
|
|
2257
|
+
});
|
|
2258
|
+
if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
|
|
2259
|
+
message: `Branch already exists locally: ${branch}`,
|
|
2260
|
+
details: { branch }
|
|
2261
|
+
});
|
|
2262
|
+
const targetPath = branchToWorktreePath(repoRoot, branch);
|
|
2263
|
+
await ensureTargetPathWritable(targetPath);
|
|
2264
|
+
const baseBranch = await resolveBaseBranch(repoRoot);
|
|
2265
|
+
const hookContext = createHookContext({
|
|
2266
|
+
runtime,
|
|
2267
|
+
repoRoot,
|
|
2268
|
+
action: "new",
|
|
2269
|
+
branch,
|
|
2270
|
+
worktreePath: targetPath,
|
|
2271
|
+
stderr
|
|
2272
|
+
});
|
|
2273
|
+
await runPreHook({
|
|
2274
|
+
name: "new",
|
|
2275
|
+
context: hookContext
|
|
2276
|
+
});
|
|
2277
|
+
await runGitCommand({
|
|
2278
|
+
cwd: repoRoot,
|
|
2279
|
+
args: [
|
|
2280
|
+
"worktree",
|
|
2281
|
+
"add",
|
|
2282
|
+
"-b",
|
|
2283
|
+
branch,
|
|
2284
|
+
targetPath,
|
|
2285
|
+
baseBranch
|
|
2286
|
+
]
|
|
2287
|
+
});
|
|
2288
|
+
await runPostHook({
|
|
2289
|
+
name: "new",
|
|
2290
|
+
context: hookContext
|
|
2291
|
+
});
|
|
2292
|
+
return {
|
|
2293
|
+
branch,
|
|
2294
|
+
path: targetPath
|
|
2295
|
+
};
|
|
2296
|
+
});
|
|
2297
|
+
if (runtime.json) {
|
|
2298
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2299
|
+
command,
|
|
2300
|
+
status: "created",
|
|
2301
|
+
repoRoot,
|
|
2302
|
+
details: result
|
|
2303
|
+
})));
|
|
2304
|
+
return EXIT_CODE.OK;
|
|
2305
|
+
}
|
|
2306
|
+
stdout(result.path);
|
|
2307
|
+
return EXIT_CODE.OK;
|
|
2308
|
+
}
|
|
2309
|
+
if (command === "switch") {
|
|
2310
|
+
ensureArgumentCount({
|
|
2311
|
+
command,
|
|
2312
|
+
args: commandArgs,
|
|
2313
|
+
min: 1,
|
|
2314
|
+
max: 1
|
|
2315
|
+
});
|
|
2316
|
+
const branch = commandArgs[0];
|
|
2317
|
+
const result = await runWriteOperation(async () => {
|
|
2318
|
+
const existing = (await collectWorktreeSnapshot(repoRoot)).worktrees.find((worktree) => worktree.branch === branch);
|
|
2319
|
+
if (existing !== void 0) return {
|
|
2320
|
+
status: "existing",
|
|
2321
|
+
branch,
|
|
2322
|
+
path: existing.path
|
|
2323
|
+
};
|
|
2324
|
+
const targetPath = branchToWorktreePath(repoRoot, branch);
|
|
2325
|
+
await ensureTargetPathWritable(targetPath);
|
|
2326
|
+
const hookContext = createHookContext({
|
|
2327
|
+
runtime,
|
|
2328
|
+
repoRoot,
|
|
2329
|
+
action: "switch",
|
|
2330
|
+
branch,
|
|
2331
|
+
worktreePath: targetPath,
|
|
2332
|
+
stderr
|
|
2333
|
+
});
|
|
2334
|
+
await runPreHook({
|
|
2335
|
+
name: "switch",
|
|
2336
|
+
context: hookContext
|
|
2337
|
+
});
|
|
2338
|
+
if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`)) await runGitCommand({
|
|
2339
|
+
cwd: repoRoot,
|
|
2340
|
+
args: [
|
|
2341
|
+
"worktree",
|
|
2342
|
+
"add",
|
|
2343
|
+
targetPath,
|
|
2344
|
+
branch
|
|
2345
|
+
]
|
|
2346
|
+
});
|
|
2347
|
+
else await runGitCommand({
|
|
2348
|
+
cwd: repoRoot,
|
|
2349
|
+
args: [
|
|
2350
|
+
"worktree",
|
|
2351
|
+
"add",
|
|
2352
|
+
"-b",
|
|
2353
|
+
branch,
|
|
2354
|
+
targetPath,
|
|
2355
|
+
await resolveBaseBranch(repoRoot)
|
|
2356
|
+
]
|
|
2357
|
+
});
|
|
2358
|
+
await runPostHook({
|
|
2359
|
+
name: "switch",
|
|
2360
|
+
context: hookContext
|
|
2361
|
+
});
|
|
2362
|
+
return {
|
|
2363
|
+
status: "created",
|
|
2364
|
+
branch,
|
|
2365
|
+
path: targetPath
|
|
2366
|
+
};
|
|
2367
|
+
});
|
|
2368
|
+
if (runtime.json) {
|
|
2369
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2370
|
+
command,
|
|
2371
|
+
status: result.status,
|
|
2372
|
+
repoRoot,
|
|
2373
|
+
details: {
|
|
2374
|
+
branch: result.branch,
|
|
2375
|
+
path: result.path
|
|
2376
|
+
}
|
|
2377
|
+
})));
|
|
2378
|
+
return EXIT_CODE.OK;
|
|
2379
|
+
}
|
|
2380
|
+
stdout(result.path);
|
|
2381
|
+
return EXIT_CODE.OK;
|
|
2382
|
+
}
|
|
2383
|
+
if (command === "mv") {
|
|
2384
|
+
ensureArgumentCount({
|
|
2385
|
+
command,
|
|
2386
|
+
args: commandArgs,
|
|
2387
|
+
min: 1,
|
|
2388
|
+
max: 1
|
|
2389
|
+
});
|
|
2390
|
+
const newBranch = commandArgs[0];
|
|
2391
|
+
const result = await runWriteOperation(async () => {
|
|
2392
|
+
const snapshot = await collectWorktreeSnapshot(repoRoot);
|
|
2393
|
+
const current = resolveCurrentWorktree({
|
|
2394
|
+
snapshot,
|
|
2395
|
+
currentWorktreeRoot: repoContext.currentWorktreeRoot
|
|
2396
|
+
});
|
|
2397
|
+
const oldBranch = current.branch;
|
|
2398
|
+
if (oldBranch === null) throw createCliError("DETACHED_HEAD", {
|
|
2399
|
+
message: "mv requires a branch checkout (detached HEAD is not supported)",
|
|
2400
|
+
details: { path: current.path }
|
|
2401
|
+
});
|
|
2402
|
+
if (current.path === repoRoot) throw createCliError("INVALID_ARGUMENT", {
|
|
2403
|
+
message: "mv cannot move the primary worktree",
|
|
2404
|
+
details: { path: current.path }
|
|
2405
|
+
});
|
|
2406
|
+
if (oldBranch === newBranch) return {
|
|
2407
|
+
branch: newBranch,
|
|
2408
|
+
path: current.path
|
|
2409
|
+
};
|
|
2410
|
+
if (containsBranch({
|
|
2411
|
+
branch: newBranch,
|
|
2412
|
+
worktrees: snapshot.worktrees
|
|
2413
|
+
})) throw createCliError("BRANCH_ALREADY_ATTACHED", {
|
|
2414
|
+
message: `Branch is already attached to another worktree: ${newBranch}`,
|
|
2415
|
+
details: { branch: newBranch }
|
|
2416
|
+
});
|
|
2417
|
+
if (await doesGitRefExist(repoRoot, `refs/heads/${newBranch}`)) throw createCliError("BRANCH_ALREADY_EXISTS", {
|
|
2418
|
+
message: `Branch already exists locally: ${newBranch}`,
|
|
2419
|
+
details: { branch: newBranch }
|
|
2420
|
+
});
|
|
2421
|
+
const newPath = branchToWorktreePath(repoRoot, newBranch);
|
|
2422
|
+
await ensureTargetPathWritable(newPath);
|
|
2423
|
+
const hookContext = createHookContext({
|
|
2424
|
+
runtime,
|
|
2425
|
+
repoRoot,
|
|
2426
|
+
action: "mv",
|
|
2427
|
+
branch: newBranch,
|
|
2428
|
+
worktreePath: newPath,
|
|
2429
|
+
stderr,
|
|
2430
|
+
extraEnv: {
|
|
2431
|
+
WT_OLD_BRANCH: oldBranch,
|
|
2432
|
+
WT_NEW_BRANCH: newBranch
|
|
2433
|
+
}
|
|
2434
|
+
});
|
|
2435
|
+
await runPreHook({
|
|
2436
|
+
name: "mv",
|
|
2437
|
+
context: hookContext
|
|
2438
|
+
});
|
|
2439
|
+
await runGitCommand({
|
|
2440
|
+
cwd: current.path,
|
|
2441
|
+
args: [
|
|
2442
|
+
"branch",
|
|
2443
|
+
"-m",
|
|
2444
|
+
oldBranch,
|
|
2445
|
+
newBranch
|
|
2446
|
+
]
|
|
2447
|
+
});
|
|
2448
|
+
await runGitCommand({
|
|
2449
|
+
cwd: repoRoot,
|
|
2450
|
+
args: [
|
|
2451
|
+
"worktree",
|
|
2452
|
+
"move",
|
|
2453
|
+
current.path,
|
|
2454
|
+
newPath
|
|
2455
|
+
]
|
|
2456
|
+
});
|
|
2457
|
+
await runPostHook({
|
|
2458
|
+
name: "mv",
|
|
2459
|
+
context: hookContext
|
|
2460
|
+
});
|
|
2461
|
+
return {
|
|
2462
|
+
branch: newBranch,
|
|
2463
|
+
path: newPath
|
|
2464
|
+
};
|
|
2465
|
+
});
|
|
2466
|
+
if (runtime.json) {
|
|
2467
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2468
|
+
command,
|
|
2469
|
+
status: "ok",
|
|
2470
|
+
repoRoot,
|
|
2471
|
+
details: result
|
|
2472
|
+
})));
|
|
2473
|
+
return EXIT_CODE.OK;
|
|
2474
|
+
}
|
|
2475
|
+
stdout(result.path);
|
|
2476
|
+
return EXIT_CODE.OK;
|
|
2477
|
+
}
|
|
2478
|
+
if (command === "del") {
|
|
2479
|
+
ensureArgumentCount({
|
|
2480
|
+
command,
|
|
2481
|
+
args: commandArgs,
|
|
2482
|
+
min: 0,
|
|
2483
|
+
max: 1
|
|
2484
|
+
});
|
|
2485
|
+
const forceFlags = parseForceFlags(parsedArgsRecord);
|
|
2486
|
+
if (hasAnyForceFlag(forceFlags)) ensureUnsafeForNonTty({
|
|
2487
|
+
runtime,
|
|
2488
|
+
reason: "force flags in non-TTY mode require --allow-unsafe"
|
|
2489
|
+
});
|
|
2490
|
+
const branchArg = commandArgs[0];
|
|
2491
|
+
const result = await runWriteOperation(async () => {
|
|
2492
|
+
const snapshot = await collectWorktreeSnapshot(repoRoot);
|
|
2493
|
+
const target = typeof branchArg === "string" && branchArg.length > 0 ? resolveTargetWorktreeByBranch({
|
|
2494
|
+
branch: branchArg,
|
|
2495
|
+
worktrees: snapshot.worktrees
|
|
2496
|
+
}) : resolveCurrentWorktree({
|
|
2497
|
+
snapshot,
|
|
2498
|
+
currentWorktreeRoot: repoContext.currentWorktreeRoot
|
|
2499
|
+
});
|
|
2500
|
+
if (target.branch === null) throw createCliError("DETACHED_HEAD", {
|
|
2501
|
+
message: "Cannot delete detached worktree without branch",
|
|
2502
|
+
details: { path: target.path }
|
|
2503
|
+
});
|
|
2504
|
+
if (target.path === repoRoot) throw createCliError("INVALID_ARGUMENT", {
|
|
2505
|
+
message: "Cannot delete the primary worktree",
|
|
2506
|
+
details: { path: target.path }
|
|
2507
|
+
});
|
|
2508
|
+
validateDeleteSafety({
|
|
2509
|
+
target,
|
|
2510
|
+
forceFlags
|
|
2511
|
+
});
|
|
2512
|
+
const hookContext = createHookContext({
|
|
2513
|
+
runtime,
|
|
2514
|
+
repoRoot,
|
|
2515
|
+
action: "del",
|
|
2516
|
+
branch: target.branch,
|
|
2517
|
+
worktreePath: target.path,
|
|
2518
|
+
stderr
|
|
2519
|
+
});
|
|
2520
|
+
await runPreHook({
|
|
2521
|
+
name: "del",
|
|
2522
|
+
context: hookContext
|
|
2523
|
+
});
|
|
2524
|
+
const removeArgs = [
|
|
2525
|
+
"worktree",
|
|
2526
|
+
"remove",
|
|
2527
|
+
target.path
|
|
2528
|
+
];
|
|
2529
|
+
if (forceFlags.forceDirty) removeArgs.push("--force");
|
|
2530
|
+
await runGitCommand({
|
|
2531
|
+
cwd: repoRoot,
|
|
2532
|
+
args: removeArgs
|
|
2533
|
+
});
|
|
2534
|
+
await runGitCommand({
|
|
2535
|
+
cwd: repoRoot,
|
|
2536
|
+
args: [
|
|
2537
|
+
"branch",
|
|
2538
|
+
resolveBranchDeleteMode(forceFlags),
|
|
2539
|
+
target.branch
|
|
2540
|
+
]
|
|
2541
|
+
});
|
|
2542
|
+
await deleteWorktreeLock({
|
|
2543
|
+
repoRoot,
|
|
2544
|
+
branch: target.branch
|
|
2545
|
+
});
|
|
2546
|
+
await runPostHook({
|
|
2547
|
+
name: "del",
|
|
2548
|
+
context: hookContext
|
|
2549
|
+
});
|
|
2550
|
+
return {
|
|
2551
|
+
branch: target.branch,
|
|
2552
|
+
path: target.path
|
|
2553
|
+
};
|
|
2554
|
+
});
|
|
2555
|
+
if (runtime.json) {
|
|
2556
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2557
|
+
command,
|
|
2558
|
+
status: "deleted",
|
|
2559
|
+
repoRoot,
|
|
2560
|
+
details: result
|
|
2561
|
+
})));
|
|
2562
|
+
return EXIT_CODE.OK;
|
|
2563
|
+
}
|
|
2564
|
+
stdout(result.path);
|
|
2565
|
+
return EXIT_CODE.OK;
|
|
2566
|
+
}
|
|
2567
|
+
if (command === "gone") {
|
|
2568
|
+
ensureArgumentCount({
|
|
2569
|
+
command,
|
|
2570
|
+
args: commandArgs,
|
|
2571
|
+
min: 0,
|
|
2572
|
+
max: 0
|
|
2573
|
+
});
|
|
2574
|
+
if (parsedArgs.apply === true && parsedArgs.dryRun === true) throw createCliError("INVALID_ARGUMENT", { message: "Cannot use --apply and --dry-run together" });
|
|
2575
|
+
const dryRun = parsedArgs.apply !== true;
|
|
2576
|
+
const execute = async () => {
|
|
2577
|
+
const candidates = (await collectWorktreeSnapshot(repoRoot)).worktrees.filter((worktree) => worktree.branch !== null).filter((worktree) => worktree.path !== repoRoot).filter((worktree) => worktree.dirty === false).filter((worktree) => worktree.locked.value === false).filter((worktree) => worktree.merged.overall === true).filter((worktree) => worktree.upstream.ahead === 0).map((worktree) => worktree.branch);
|
|
2578
|
+
if (dryRun) return {
|
|
2579
|
+
deleted: [],
|
|
2580
|
+
candidates,
|
|
2581
|
+
dryRun: true
|
|
2582
|
+
};
|
|
2583
|
+
const hookContext = createHookContext({
|
|
2584
|
+
runtime,
|
|
2585
|
+
repoRoot,
|
|
2586
|
+
action: "gone",
|
|
2587
|
+
branch: null,
|
|
2588
|
+
worktreePath: repoRoot,
|
|
2589
|
+
stderr
|
|
2590
|
+
});
|
|
2591
|
+
await runPreHook({
|
|
2592
|
+
name: "gone",
|
|
2593
|
+
context: hookContext
|
|
2594
|
+
});
|
|
2595
|
+
const deleted = [];
|
|
2596
|
+
for (const branch of candidates) {
|
|
2597
|
+
await runGitCommand({
|
|
2598
|
+
cwd: repoRoot,
|
|
2599
|
+
args: [
|
|
2600
|
+
"worktree",
|
|
2601
|
+
"remove",
|
|
2602
|
+
resolveTargetWorktreeByBranch({
|
|
2603
|
+
branch,
|
|
2604
|
+
worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
|
|
2605
|
+
}).path
|
|
2606
|
+
]
|
|
2607
|
+
});
|
|
2608
|
+
await runGitCommand({
|
|
2609
|
+
cwd: repoRoot,
|
|
2610
|
+
args: [
|
|
2611
|
+
"branch",
|
|
2612
|
+
"-d",
|
|
2613
|
+
branch
|
|
2614
|
+
]
|
|
2615
|
+
});
|
|
2616
|
+
await deleteWorktreeLock({
|
|
2617
|
+
repoRoot,
|
|
2618
|
+
branch
|
|
2619
|
+
});
|
|
2620
|
+
deleted.push(branch);
|
|
2621
|
+
}
|
|
2622
|
+
await runPostHook({
|
|
2623
|
+
name: "gone",
|
|
2624
|
+
context: hookContext
|
|
2625
|
+
});
|
|
2626
|
+
return {
|
|
2627
|
+
deleted,
|
|
2628
|
+
candidates,
|
|
2629
|
+
dryRun: false
|
|
2630
|
+
};
|
|
2631
|
+
};
|
|
2632
|
+
const result = await runWriteOperation(execute);
|
|
2633
|
+
if (runtime.json) {
|
|
2634
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2635
|
+
command,
|
|
2636
|
+
status: "ok",
|
|
2637
|
+
repoRoot,
|
|
2638
|
+
details: {
|
|
2639
|
+
dryRun: result.dryRun,
|
|
2640
|
+
candidates: result.candidates,
|
|
2641
|
+
deleted: result.deleted
|
|
2642
|
+
}
|
|
2643
|
+
})));
|
|
2644
|
+
return EXIT_CODE.OK;
|
|
2645
|
+
}
|
|
2646
|
+
const label = result.dryRun ? "candidates" : "deleted";
|
|
2647
|
+
const branches = result.dryRun ? result.candidates : result.deleted;
|
|
2648
|
+
for (const branch of branches) stdout(`${label}: ${branch}`);
|
|
2649
|
+
return EXIT_CODE.OK;
|
|
2650
|
+
}
|
|
2651
|
+
if (command === "get") {
|
|
2652
|
+
ensureArgumentCount({
|
|
2653
|
+
command,
|
|
2654
|
+
args: commandArgs,
|
|
2655
|
+
min: 1,
|
|
2656
|
+
max: 1
|
|
2657
|
+
});
|
|
2658
|
+
const remoteBranchArg = commandArgs[0];
|
|
2659
|
+
const { remote, branch } = resolveRemoteAndBranch(remoteBranchArg);
|
|
2660
|
+
const result = await runWriteOperation(async () => {
|
|
2661
|
+
if ((await runGitCommand({
|
|
2662
|
+
cwd: repoRoot,
|
|
2663
|
+
args: [
|
|
2664
|
+
"remote",
|
|
2665
|
+
"get-url",
|
|
2666
|
+
remote
|
|
2667
|
+
],
|
|
2668
|
+
reject: false
|
|
2669
|
+
})).exitCode !== 0) throw createCliError("REMOTE_NOT_FOUND", {
|
|
2670
|
+
message: `Remote not found: ${remote}`,
|
|
2671
|
+
details: { remote }
|
|
2672
|
+
});
|
|
2673
|
+
const hookContext = createHookContext({
|
|
2674
|
+
runtime,
|
|
2675
|
+
repoRoot,
|
|
2676
|
+
action: "get",
|
|
2677
|
+
branch,
|
|
2678
|
+
worktreePath: branchToWorktreePath(repoRoot, branch),
|
|
2679
|
+
stderr
|
|
2680
|
+
});
|
|
2681
|
+
await runPreHook({
|
|
2682
|
+
name: "get",
|
|
2683
|
+
context: hookContext
|
|
2684
|
+
});
|
|
2685
|
+
if ((await runGitCommand({
|
|
2686
|
+
cwd: repoRoot,
|
|
2687
|
+
args: [
|
|
2688
|
+
"fetch",
|
|
2689
|
+
remote,
|
|
2690
|
+
branch
|
|
2691
|
+
],
|
|
2692
|
+
reject: false
|
|
2693
|
+
})).exitCode !== 0) throw createCliError("REMOTE_BRANCH_NOT_FOUND", {
|
|
2694
|
+
message: `Remote branch not found: ${remote}/${branch}`,
|
|
2695
|
+
details: {
|
|
2696
|
+
remote,
|
|
2697
|
+
branch
|
|
2698
|
+
}
|
|
2699
|
+
});
|
|
2700
|
+
if (await doesGitRefExist(repoRoot, `refs/heads/${branch}`) !== true) await runGitCommand({
|
|
2701
|
+
cwd: repoRoot,
|
|
2702
|
+
args: [
|
|
2703
|
+
"branch",
|
|
2704
|
+
"--track",
|
|
2705
|
+
branch,
|
|
2706
|
+
`${remote}/${branch}`
|
|
2707
|
+
]
|
|
2708
|
+
});
|
|
2709
|
+
const existing = (await collectWorktreeSnapshot(repoRoot)).worktrees.find((worktree) => worktree.branch === branch);
|
|
2710
|
+
if (existing !== void 0) {
|
|
2711
|
+
await runPostHook({
|
|
2712
|
+
name: "get",
|
|
2713
|
+
context: hookContext
|
|
2714
|
+
});
|
|
2715
|
+
return {
|
|
2716
|
+
status: "existing",
|
|
2717
|
+
branch,
|
|
2718
|
+
path: existing.path
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
const targetPath = branchToWorktreePath(repoRoot, branch);
|
|
2722
|
+
await ensureTargetPathWritable(targetPath);
|
|
2723
|
+
await runGitCommand({
|
|
2724
|
+
cwd: repoRoot,
|
|
2725
|
+
args: [
|
|
2726
|
+
"worktree",
|
|
2727
|
+
"add",
|
|
2728
|
+
targetPath,
|
|
2729
|
+
branch
|
|
2730
|
+
]
|
|
2731
|
+
});
|
|
2732
|
+
await runPostHook({
|
|
2733
|
+
name: "get",
|
|
2734
|
+
context: hookContext
|
|
2735
|
+
});
|
|
2736
|
+
return {
|
|
2737
|
+
status: "created",
|
|
2738
|
+
branch,
|
|
2739
|
+
path: targetPath
|
|
2740
|
+
};
|
|
2741
|
+
});
|
|
2742
|
+
if (runtime.json) {
|
|
2743
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2744
|
+
command,
|
|
2745
|
+
status: result.status,
|
|
2746
|
+
repoRoot,
|
|
2747
|
+
details: {
|
|
2748
|
+
branch: result.branch,
|
|
2749
|
+
path: result.path
|
|
2750
|
+
}
|
|
2751
|
+
})));
|
|
2752
|
+
return EXIT_CODE.OK;
|
|
2753
|
+
}
|
|
2754
|
+
stdout(result.path);
|
|
2755
|
+
return EXIT_CODE.OK;
|
|
2756
|
+
}
|
|
2757
|
+
if (command === "extract") {
|
|
2758
|
+
ensureArgumentCount({
|
|
2759
|
+
command,
|
|
2760
|
+
args: commandArgs,
|
|
2761
|
+
min: 0,
|
|
2762
|
+
max: 0
|
|
2763
|
+
});
|
|
2764
|
+
const fromPath = typeof parsedArgs.from === "string" ? parsedArgs.from : void 0;
|
|
2765
|
+
if (fromPath !== void 0 && parsedArgs.current === true) throw createCliError("INVALID_ARGUMENT", { message: "extract cannot use --current and --from together" });
|
|
2766
|
+
const result = await runWriteOperation(async () => {
|
|
2767
|
+
const snapshot = await collectWorktreeSnapshot(repoRoot);
|
|
2768
|
+
const resolvedSourcePath = ensurePathInsideRepo({
|
|
2769
|
+
repoRoot,
|
|
2770
|
+
path: fromPath !== void 0 ? resolvePathFromCwd({
|
|
2771
|
+
cwd: runtimeCwd,
|
|
2772
|
+
path: fromPath
|
|
2773
|
+
}) : repoContext.currentWorktreeRoot
|
|
2774
|
+
});
|
|
2775
|
+
const sourceWorktree = snapshot.worktrees.find((worktree) => worktree.path === resolvedSourcePath) ?? snapshot.worktrees.find((worktree) => resolvedSourcePath.startsWith(`${worktree.path}${sep}`));
|
|
2776
|
+
if (sourceWorktree === void 0) throw createCliError("WORKTREE_NOT_FOUND", {
|
|
2777
|
+
message: "extract source worktree not found",
|
|
2778
|
+
details: { path: resolvedSourcePath }
|
|
2779
|
+
});
|
|
2780
|
+
if (sourceWorktree.path !== repoRoot) throw createCliError("INVALID_ARGUMENT", {
|
|
2781
|
+
message: "extract currently supports only the primary worktree",
|
|
2782
|
+
details: { sourcePath: sourceWorktree.path }
|
|
2783
|
+
});
|
|
2784
|
+
if (sourceWorktree.branch === null) throw createCliError("DETACHED_HEAD", {
|
|
2785
|
+
message: "extract requires current branch checkout",
|
|
2786
|
+
details: { path: sourceWorktree.path }
|
|
2787
|
+
});
|
|
2788
|
+
const branch = sourceWorktree.branch;
|
|
2789
|
+
const baseBranch = await resolveBaseBranch(repoRoot);
|
|
2790
|
+
ensureBranchIsNotPrimary({
|
|
2791
|
+
branch,
|
|
2792
|
+
baseBranch
|
|
2793
|
+
});
|
|
2794
|
+
const targetPath = branchToWorktreePath(repoRoot, branch);
|
|
2795
|
+
await ensureTargetPathWritable(targetPath);
|
|
2796
|
+
const dirty = (await runGitCommand({
|
|
2797
|
+
cwd: repoRoot,
|
|
2798
|
+
args: ["status", "--porcelain"],
|
|
2799
|
+
reject: false
|
|
2800
|
+
})).stdout.trim().length > 0;
|
|
2801
|
+
if (dirty && parsedArgs.stash !== true) throw createCliError("DIRTY_WORKTREE", { message: "extract requires clean worktree unless --stash is specified" });
|
|
2802
|
+
let stashRef = null;
|
|
2803
|
+
if (dirty && parsedArgs.stash === true) {
|
|
2804
|
+
await runGitCommand({
|
|
2805
|
+
cwd: repoRoot,
|
|
2806
|
+
args: [
|
|
2807
|
+
"stash",
|
|
2808
|
+
"push",
|
|
2809
|
+
"-u",
|
|
2810
|
+
"-m",
|
|
2811
|
+
`vde-worktree extract ${branch}`
|
|
2812
|
+
]
|
|
2813
|
+
});
|
|
2814
|
+
const stashTop = await runGitCommand({
|
|
2815
|
+
cwd: repoRoot,
|
|
2816
|
+
args: [
|
|
2817
|
+
"stash",
|
|
2818
|
+
"list",
|
|
2819
|
+
"--max-count=1",
|
|
2820
|
+
"--format=%gd"
|
|
2821
|
+
]
|
|
2822
|
+
});
|
|
2823
|
+
stashRef = stashTop.stdout.trim().length > 0 ? stashTop.stdout.trim() : null;
|
|
2824
|
+
}
|
|
2825
|
+
const hookContext = createHookContext({
|
|
2826
|
+
runtime,
|
|
2827
|
+
repoRoot,
|
|
2828
|
+
action: "extract",
|
|
2829
|
+
branch,
|
|
2830
|
+
worktreePath: targetPath,
|
|
2831
|
+
stderr
|
|
2832
|
+
});
|
|
2833
|
+
await runPreHook({
|
|
2834
|
+
name: "extract",
|
|
2835
|
+
context: hookContext
|
|
2836
|
+
});
|
|
2837
|
+
await runGitCommand({
|
|
2838
|
+
cwd: repoRoot,
|
|
2839
|
+
args: ["checkout", baseBranch]
|
|
2840
|
+
});
|
|
2841
|
+
await runGitCommand({
|
|
2842
|
+
cwd: repoRoot,
|
|
2843
|
+
args: [
|
|
2844
|
+
"worktree",
|
|
2845
|
+
"add",
|
|
2846
|
+
targetPath,
|
|
2847
|
+
branch
|
|
2848
|
+
]
|
|
2849
|
+
});
|
|
2850
|
+
if (stashRef !== null) {
|
|
2851
|
+
if ((await runGitCommand({
|
|
2852
|
+
cwd: targetPath,
|
|
2853
|
+
args: [
|
|
2854
|
+
"stash",
|
|
2855
|
+
"apply",
|
|
2856
|
+
stashRef
|
|
2857
|
+
],
|
|
2858
|
+
reject: false
|
|
2859
|
+
})).exitCode !== 0) throw createCliError("STASH_APPLY_FAILED", {
|
|
2860
|
+
message: "Failed to apply stash to extracted worktree",
|
|
2861
|
+
details: {
|
|
2862
|
+
stashRef,
|
|
2863
|
+
branch,
|
|
2864
|
+
path: targetPath
|
|
2865
|
+
}
|
|
2866
|
+
});
|
|
2867
|
+
await runGitCommand({
|
|
2868
|
+
cwd: repoRoot,
|
|
2869
|
+
args: [
|
|
2870
|
+
"stash",
|
|
2871
|
+
"drop",
|
|
2872
|
+
stashRef
|
|
2873
|
+
]
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
await runPostHook({
|
|
2877
|
+
name: "extract",
|
|
2878
|
+
context: hookContext
|
|
2879
|
+
});
|
|
2880
|
+
return {
|
|
2881
|
+
branch,
|
|
2882
|
+
path: targetPath
|
|
2883
|
+
};
|
|
2884
|
+
});
|
|
2885
|
+
if (runtime.json) {
|
|
2886
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2887
|
+
command,
|
|
2888
|
+
status: "created",
|
|
2889
|
+
repoRoot,
|
|
2890
|
+
details: result
|
|
2891
|
+
})));
|
|
2892
|
+
return EXIT_CODE.OK;
|
|
2893
|
+
}
|
|
2894
|
+
stdout(result.path);
|
|
2895
|
+
return EXIT_CODE.OK;
|
|
2896
|
+
}
|
|
2897
|
+
if (command === "use") {
|
|
2898
|
+
ensureArgumentCount({
|
|
2899
|
+
command,
|
|
2900
|
+
args: commandArgs,
|
|
2901
|
+
min: 1,
|
|
2902
|
+
max: 1
|
|
2903
|
+
});
|
|
2904
|
+
const branch = commandArgs[0];
|
|
2905
|
+
if (runtime.isInteractive !== true) {
|
|
2906
|
+
if (parsedArgs.allowAgent !== true) throw createCliError("UNSAFE_FLAG_REQUIRED", { message: "UNSAFE_FLAG_REQUIRED: use in non-TTY requires --allow-agent" });
|
|
2907
|
+
ensureUnsafeForNonTty({
|
|
2908
|
+
runtime,
|
|
2909
|
+
reason: "use in non-TTY mode requires --allow-unsafe"
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
const result = await runWriteOperation(async () => {
|
|
2913
|
+
if ((await runGitCommand({
|
|
2914
|
+
cwd: repoRoot,
|
|
2915
|
+
args: ["status", "--porcelain"],
|
|
2916
|
+
reject: false
|
|
2917
|
+
})).stdout.trim().length > 0) throw createCliError("DIRTY_WORKTREE", {
|
|
2918
|
+
message: "use requires clean primary worktree",
|
|
2919
|
+
details: { repoRoot }
|
|
2920
|
+
});
|
|
2921
|
+
const hookContext = createHookContext({
|
|
2922
|
+
runtime,
|
|
2923
|
+
repoRoot,
|
|
2924
|
+
action: "use",
|
|
2925
|
+
branch,
|
|
2926
|
+
worktreePath: repoRoot,
|
|
2927
|
+
stderr
|
|
2928
|
+
});
|
|
2929
|
+
await runPreHook({
|
|
2930
|
+
name: "use",
|
|
2931
|
+
context: hookContext
|
|
2932
|
+
});
|
|
2933
|
+
await runGitCommand({
|
|
2934
|
+
cwd: repoRoot,
|
|
2935
|
+
args: ["checkout", branch]
|
|
2936
|
+
});
|
|
2937
|
+
await runPostHook({
|
|
2938
|
+
name: "use",
|
|
2939
|
+
context: hookContext
|
|
2940
|
+
});
|
|
2941
|
+
return {
|
|
2942
|
+
branch,
|
|
2943
|
+
path: repoRoot
|
|
2944
|
+
};
|
|
2945
|
+
});
|
|
2946
|
+
if (runtime.json) {
|
|
2947
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2948
|
+
command,
|
|
2949
|
+
status: "ok",
|
|
2950
|
+
repoRoot,
|
|
2951
|
+
details: result
|
|
2952
|
+
})));
|
|
2953
|
+
return EXIT_CODE.OK;
|
|
2954
|
+
}
|
|
2955
|
+
stdout(result.path);
|
|
2956
|
+
return EXIT_CODE.OK;
|
|
2957
|
+
}
|
|
2958
|
+
if (command === "exec") {
|
|
2959
|
+
ensureArgumentCount({
|
|
2960
|
+
command,
|
|
2961
|
+
args: commandArgs,
|
|
2962
|
+
min: 1,
|
|
2963
|
+
max: 1
|
|
2964
|
+
});
|
|
2965
|
+
ensureHasCommandAfterDoubleDash({
|
|
2966
|
+
command,
|
|
2967
|
+
argsAfterDoubleDash: afterDoubleDash
|
|
2968
|
+
});
|
|
2969
|
+
const branch = commandArgs[0];
|
|
2970
|
+
const target = resolveTargetWorktreeByBranch({
|
|
2971
|
+
branch,
|
|
2972
|
+
worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
|
|
2973
|
+
});
|
|
2974
|
+
const executable = afterDoubleDash[0];
|
|
2975
|
+
if (typeof executable !== "string" || executable.length === 0) throw createCliError("INVALID_ARGUMENT", { message: "exec requires executable after --" });
|
|
2976
|
+
const childExitCode = (await execa(executable, afterDoubleDash.slice(1), {
|
|
2977
|
+
cwd: target.path,
|
|
2978
|
+
stdin: "inherit",
|
|
2979
|
+
stdout: "inherit",
|
|
2980
|
+
stderr: "inherit",
|
|
2981
|
+
reject: false
|
|
2982
|
+
})).exitCode ?? 0;
|
|
2983
|
+
if (runtime.json) {
|
|
2984
|
+
if (childExitCode === 0) {
|
|
2985
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
2986
|
+
command,
|
|
2987
|
+
status: "ok",
|
|
2988
|
+
repoRoot,
|
|
2989
|
+
details: {
|
|
2990
|
+
branch,
|
|
2991
|
+
path: target.path,
|
|
2992
|
+
childExitCode
|
|
2993
|
+
}
|
|
2994
|
+
})));
|
|
2995
|
+
return EXIT_CODE.OK;
|
|
2996
|
+
}
|
|
2997
|
+
stdout(JSON.stringify({
|
|
2998
|
+
schemaVersion: SCHEMA_VERSION,
|
|
2999
|
+
command,
|
|
3000
|
+
status: "error",
|
|
3001
|
+
repoRoot,
|
|
3002
|
+
code: "CHILD_PROCESS_FAILED",
|
|
3003
|
+
message: "target command exited with non-zero status",
|
|
3004
|
+
details: {
|
|
3005
|
+
branch,
|
|
3006
|
+
path: target.path,
|
|
3007
|
+
childExitCode
|
|
3008
|
+
}
|
|
3009
|
+
}));
|
|
3010
|
+
return EXIT_CODE.CHILD_PROCESS_FAILED;
|
|
3011
|
+
}
|
|
3012
|
+
return childExitCode === 0 ? EXIT_CODE.OK : EXIT_CODE.CHILD_PROCESS_FAILED;
|
|
3013
|
+
}
|
|
3014
|
+
if (command === "invoke") {
|
|
3015
|
+
ensureArgumentCount({
|
|
3016
|
+
command,
|
|
3017
|
+
args: commandArgs,
|
|
3018
|
+
min: 1,
|
|
3019
|
+
max: 1
|
|
3020
|
+
});
|
|
3021
|
+
const hookName = normalizeHookName(commandArgs[0]);
|
|
3022
|
+
const current = resolveCurrentWorktree({
|
|
3023
|
+
snapshot: await collectWorktreeSnapshot(repoRoot),
|
|
3024
|
+
currentWorktreeRoot: repoContext.currentWorktreeRoot
|
|
3025
|
+
});
|
|
3026
|
+
await invokeHook({
|
|
3027
|
+
hookName,
|
|
3028
|
+
args: afterDoubleDash,
|
|
3029
|
+
context: createHookContext({
|
|
3030
|
+
runtime,
|
|
3031
|
+
repoRoot,
|
|
3032
|
+
action: `invoke:${hookName}`,
|
|
3033
|
+
branch: current.branch,
|
|
3034
|
+
worktreePath: current.path,
|
|
3035
|
+
stderr
|
|
3036
|
+
})
|
|
3037
|
+
});
|
|
3038
|
+
if (runtime.json) {
|
|
3039
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
3040
|
+
command,
|
|
3041
|
+
status: "ok",
|
|
3042
|
+
repoRoot,
|
|
3043
|
+
details: {
|
|
3044
|
+
hook: hookName,
|
|
3045
|
+
exitCode: 0
|
|
3046
|
+
}
|
|
3047
|
+
})));
|
|
3048
|
+
return EXIT_CODE.OK;
|
|
3049
|
+
}
|
|
3050
|
+
return EXIT_CODE.OK;
|
|
3051
|
+
}
|
|
3052
|
+
if (command === "copy") {
|
|
3053
|
+
ensureArgumentCount({
|
|
3054
|
+
command,
|
|
3055
|
+
args: commandArgs,
|
|
3056
|
+
min: 1,
|
|
3057
|
+
max: Number.MAX_SAFE_INTEGER
|
|
3058
|
+
});
|
|
3059
|
+
const worktreePath = ensurePathInsideRepo({
|
|
3060
|
+
repoRoot,
|
|
3061
|
+
path: resolvePathFromCwd({
|
|
3062
|
+
cwd: repoContext.currentWorktreeRoot,
|
|
3063
|
+
path: process.env.WT_WORKTREE_PATH ?? "."
|
|
3064
|
+
})
|
|
3065
|
+
});
|
|
3066
|
+
for (const relativePath of commandArgs) {
|
|
3067
|
+
const { sourcePath, destinationPath } = resolveFileCopyTargets({
|
|
3068
|
+
repoRoot,
|
|
3069
|
+
worktreePath,
|
|
3070
|
+
relativePath
|
|
3071
|
+
});
|
|
3072
|
+
await access(sourcePath, constants.F_OK);
|
|
3073
|
+
await mkdir(dirname(destinationPath), { recursive: true });
|
|
3074
|
+
await cp(sourcePath, destinationPath, {
|
|
3075
|
+
recursive: true,
|
|
3076
|
+
force: true,
|
|
3077
|
+
errorOnExist: false,
|
|
3078
|
+
dereference: false
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
if (runtime.json) {
|
|
3082
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
3083
|
+
command,
|
|
3084
|
+
status: "ok",
|
|
3085
|
+
repoRoot,
|
|
3086
|
+
details: {
|
|
3087
|
+
copied: commandArgs,
|
|
3088
|
+
worktreePath
|
|
3089
|
+
}
|
|
3090
|
+
})));
|
|
3091
|
+
return EXIT_CODE.OK;
|
|
3092
|
+
}
|
|
3093
|
+
return EXIT_CODE.OK;
|
|
3094
|
+
}
|
|
3095
|
+
if (command === "link") {
|
|
3096
|
+
ensureArgumentCount({
|
|
3097
|
+
command,
|
|
3098
|
+
args: commandArgs,
|
|
3099
|
+
min: 1,
|
|
3100
|
+
max: Number.MAX_SAFE_INTEGER
|
|
3101
|
+
});
|
|
3102
|
+
const worktreePath = ensurePathInsideRepo({
|
|
3103
|
+
repoRoot,
|
|
3104
|
+
path: resolvePathFromCwd({
|
|
3105
|
+
cwd: repoContext.currentWorktreeRoot,
|
|
3106
|
+
path: process.env.WT_WORKTREE_PATH ?? "."
|
|
3107
|
+
})
|
|
3108
|
+
});
|
|
3109
|
+
const fallbackEnabled = parsedArgs.fallback !== false;
|
|
3110
|
+
for (const relativePath of commandArgs) {
|
|
3111
|
+
const { sourcePath, destinationPath } = resolveFileCopyTargets({
|
|
3112
|
+
repoRoot,
|
|
3113
|
+
worktreePath,
|
|
3114
|
+
relativePath
|
|
3115
|
+
});
|
|
3116
|
+
await access(sourcePath, constants.F_OK);
|
|
3117
|
+
await rm(destinationPath, {
|
|
3118
|
+
recursive: true,
|
|
3119
|
+
force: true
|
|
3120
|
+
});
|
|
3121
|
+
await mkdir(dirname(destinationPath), { recursive: true });
|
|
3122
|
+
try {
|
|
3123
|
+
await symlink(resolveLinkTargetPath({
|
|
3124
|
+
sourcePath,
|
|
3125
|
+
destinationPath
|
|
3126
|
+
}), destinationPath);
|
|
3127
|
+
} catch (error) {
|
|
3128
|
+
if (process.platform === "win32" && fallbackEnabled) {
|
|
3129
|
+
stderr(`symlink failed for ${relativePath}; fallback to copy`);
|
|
3130
|
+
await cp(sourcePath, destinationPath, {
|
|
3131
|
+
recursive: true,
|
|
3132
|
+
force: true,
|
|
3133
|
+
errorOnExist: false,
|
|
3134
|
+
dereference: false
|
|
3135
|
+
});
|
|
3136
|
+
continue;
|
|
3137
|
+
}
|
|
3138
|
+
throw createCliError("INVALID_ARGUMENT", {
|
|
3139
|
+
message: `Failed to create symlink for ${relativePath}`,
|
|
3140
|
+
cause: error
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
if (runtime.json) {
|
|
3145
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
3146
|
+
command,
|
|
3147
|
+
status: "ok",
|
|
3148
|
+
repoRoot,
|
|
3149
|
+
details: {
|
|
3150
|
+
linked: commandArgs,
|
|
3151
|
+
worktreePath,
|
|
3152
|
+
fallback: fallbackEnabled
|
|
3153
|
+
}
|
|
3154
|
+
})));
|
|
3155
|
+
return EXIT_CODE.OK;
|
|
3156
|
+
}
|
|
3157
|
+
return EXIT_CODE.OK;
|
|
3158
|
+
}
|
|
3159
|
+
if (command === "lock") {
|
|
3160
|
+
ensureArgumentCount({
|
|
3161
|
+
command,
|
|
3162
|
+
args: commandArgs,
|
|
3163
|
+
min: 1,
|
|
3164
|
+
max: 1
|
|
3165
|
+
});
|
|
3166
|
+
const branch = commandArgs[0];
|
|
3167
|
+
const ownerOption = readStringOption(parsedArgsRecord, "owner");
|
|
3168
|
+
const reasonOption = readStringOption(parsedArgsRecord, "reason");
|
|
3169
|
+
const owner = typeof ownerOption === "string" && ownerOption.length > 0 ? ownerOption : defaultOwner();
|
|
3170
|
+
const reason = typeof reasonOption === "string" && reasonOption.length > 0 ? reasonOption : "locked";
|
|
3171
|
+
const result = await runWriteOperation(async () => {
|
|
3172
|
+
resolveTargetWorktreeByBranch({
|
|
3173
|
+
branch,
|
|
3174
|
+
worktrees: (await collectWorktreeSnapshot(repoRoot)).worktrees
|
|
3175
|
+
});
|
|
3176
|
+
const existing = await readWorktreeLock({
|
|
3177
|
+
repoRoot,
|
|
3178
|
+
branch
|
|
3179
|
+
});
|
|
3180
|
+
if (existing.exists && existing.valid !== true) throw createCliError("LOCK_CONFLICT", {
|
|
3181
|
+
message: "Cannot update lock with invalid metadata; fix or remove lock file first",
|
|
3182
|
+
details: {
|
|
3183
|
+
branch,
|
|
3184
|
+
path: existing.path
|
|
3185
|
+
}
|
|
3186
|
+
});
|
|
3187
|
+
if (existing.record !== null && existing.record.owner !== owner) throw createCliError("LOCK_CONFLICT", {
|
|
3188
|
+
message: "Lock is owned by another owner",
|
|
3189
|
+
details: {
|
|
3190
|
+
branch,
|
|
3191
|
+
owner: existing.record.owner
|
|
3192
|
+
}
|
|
3193
|
+
});
|
|
3194
|
+
return await upsertWorktreeLock({
|
|
3195
|
+
repoRoot,
|
|
3196
|
+
branch,
|
|
3197
|
+
reason,
|
|
3198
|
+
owner
|
|
3199
|
+
});
|
|
3200
|
+
});
|
|
3201
|
+
if (runtime.json) {
|
|
3202
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
3203
|
+
command,
|
|
3204
|
+
status: "ok",
|
|
3205
|
+
repoRoot,
|
|
3206
|
+
details: {
|
|
3207
|
+
branch,
|
|
3208
|
+
locked: {
|
|
3209
|
+
value: true,
|
|
3210
|
+
reason: result.reason,
|
|
3211
|
+
owner: result.owner
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
})));
|
|
3215
|
+
return EXIT_CODE.OK;
|
|
3216
|
+
}
|
|
3217
|
+
return EXIT_CODE.OK;
|
|
3218
|
+
}
|
|
3219
|
+
if (command === "unlock") {
|
|
3220
|
+
ensureArgumentCount({
|
|
3221
|
+
command,
|
|
3222
|
+
args: commandArgs,
|
|
3223
|
+
min: 1,
|
|
3224
|
+
max: 1
|
|
3225
|
+
});
|
|
3226
|
+
const branch = commandArgs[0];
|
|
3227
|
+
const ownerOption = readStringOption(parsedArgsRecord, "owner");
|
|
3228
|
+
const owner = typeof ownerOption === "string" && ownerOption.length > 0 ? ownerOption : defaultOwner();
|
|
3229
|
+
const force = parsedArgs.force === true;
|
|
3230
|
+
await runWriteOperation(async () => {
|
|
3231
|
+
const existing = await readWorktreeLock({
|
|
3232
|
+
repoRoot,
|
|
3233
|
+
branch
|
|
3234
|
+
});
|
|
3235
|
+
if (existing.exists !== true) return;
|
|
3236
|
+
if (existing.valid !== true) {
|
|
3237
|
+
if (force) {
|
|
3238
|
+
await deleteWorktreeLock({
|
|
3239
|
+
repoRoot,
|
|
3240
|
+
branch
|
|
3241
|
+
});
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
throw createCliError("LOCK_CONFLICT", {
|
|
3245
|
+
message: "Lock metadata is invalid; use --force to unlock",
|
|
3246
|
+
details: {
|
|
3247
|
+
branch,
|
|
3248
|
+
path: existing.path
|
|
3249
|
+
}
|
|
3250
|
+
});
|
|
3251
|
+
}
|
|
3252
|
+
if (existing.record !== null && existing.record.owner !== owner && force !== true) throw createCliError("LOCK_CONFLICT", {
|
|
3253
|
+
message: "Lock is owned by another owner. Use --force to unlock.",
|
|
3254
|
+
details: {
|
|
3255
|
+
branch,
|
|
3256
|
+
owner: existing.record.owner
|
|
3257
|
+
}
|
|
3258
|
+
});
|
|
3259
|
+
await deleteWorktreeLock({
|
|
3260
|
+
repoRoot,
|
|
3261
|
+
branch
|
|
3262
|
+
});
|
|
3263
|
+
});
|
|
3264
|
+
if (runtime.json) {
|
|
3265
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
3266
|
+
command,
|
|
3267
|
+
status: "ok",
|
|
3268
|
+
repoRoot,
|
|
3269
|
+
details: {
|
|
3270
|
+
branch,
|
|
3271
|
+
locked: {
|
|
3272
|
+
value: false,
|
|
3273
|
+
reason: null
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
})));
|
|
3277
|
+
return EXIT_CODE.OK;
|
|
3278
|
+
}
|
|
3279
|
+
return EXIT_CODE.OK;
|
|
3280
|
+
}
|
|
3281
|
+
if (command === "cd") {
|
|
3282
|
+
ensureArgumentCount({
|
|
3283
|
+
command,
|
|
3284
|
+
args: commandArgs,
|
|
3285
|
+
min: 0,
|
|
3286
|
+
max: 0
|
|
3287
|
+
});
|
|
3288
|
+
const candidates = (await collectWorktreeSnapshot(repoRoot)).worktrees.map((worktree) => worktree.path);
|
|
3289
|
+
if (candidates.length === 0) throw createCliError("WORKTREE_NOT_FOUND", { message: "No worktree candidates found" });
|
|
3290
|
+
const promptValue = readStringOption(parsedArgsRecord, "prompt");
|
|
3291
|
+
const selection = await selectPathWithFzf$1({
|
|
3292
|
+
candidates,
|
|
3293
|
+
prompt: typeof promptValue === "string" && promptValue.length > 0 ? promptValue : "worktree> ",
|
|
3294
|
+
fzfExtraArgs: collectOptionValues({
|
|
3295
|
+
args: beforeDoubleDash,
|
|
3296
|
+
optionNames: ["fzfArg", "fzf-arg"]
|
|
3297
|
+
}),
|
|
3298
|
+
cwd: repoRoot,
|
|
3299
|
+
isInteractive: () => runtime.isInteractive || process.stderr.isTTY === true
|
|
3300
|
+
}).catch((error) => {
|
|
3301
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3302
|
+
if (message.includes("interactive terminal") || message.includes("fzf is required")) throw createCliError("DEPENDENCY_MISSING", { message: `DEPENDENCY_MISSING: ${message}` });
|
|
3303
|
+
throw error;
|
|
3304
|
+
});
|
|
3305
|
+
if (selection.status === "cancelled") return EXIT_CODE_CANCELLED;
|
|
3306
|
+
if (runtime.json) {
|
|
3307
|
+
stdout(JSON.stringify(buildJsonSuccess({
|
|
3308
|
+
command,
|
|
3309
|
+
status: "ok",
|
|
3310
|
+
repoRoot,
|
|
3311
|
+
details: { path: selection.path }
|
|
3312
|
+
})));
|
|
3313
|
+
return EXIT_CODE.OK;
|
|
3314
|
+
}
|
|
3315
|
+
stdout(selection.path);
|
|
3316
|
+
return EXIT_CODE.OK;
|
|
3317
|
+
}
|
|
3318
|
+
throw createCliError("UNKNOWN_COMMAND", { message: `Unknown command: ${command}` });
|
|
3319
|
+
} catch (error) {
|
|
3320
|
+
const cliError = ensureCliError(error);
|
|
3321
|
+
if (jsonEnabled) stdout(JSON.stringify(buildJsonError({
|
|
3322
|
+
command,
|
|
3323
|
+
repoRoot: repoRootForJson,
|
|
3324
|
+
error: cliError
|
|
3325
|
+
})));
|
|
3326
|
+
else {
|
|
3327
|
+
stderr(`[${cliError.code}] ${cliError.message}`);
|
|
3328
|
+
logger.debug(JSON.stringify(cliError.details));
|
|
3329
|
+
}
|
|
3330
|
+
return cliError.exitCode;
|
|
3331
|
+
}
|
|
3332
|
+
};
|
|
3333
|
+
return { run };
|
|
3334
|
+
};
|
|
3335
|
+
|
|
3336
|
+
//#endregion
|
|
3337
|
+
//#region src/index.ts
|
|
3338
|
+
const main = async () => {
|
|
3339
|
+
const cli = createCli();
|
|
3340
|
+
try {
|
|
3341
|
+
const exitCode = await cli.run(process.argv.slice(2));
|
|
3342
|
+
if (typeof exitCode === "number" && exitCode !== 0) process.exit(exitCode);
|
|
3343
|
+
} catch (error) {
|
|
3344
|
+
if (error instanceof Error) {
|
|
3345
|
+
console.error("Error:", error.message);
|
|
3346
|
+
if (process.env.VDE_WORKTREE_DEBUG === "true" || process.env.VDE_DEBUG === "true") console.error(error.stack);
|
|
3347
|
+
} else console.error("An unexpected error occurred:", String(error));
|
|
3348
|
+
process.exit(1);
|
|
3349
|
+
}
|
|
3350
|
+
};
|
|
3351
|
+
main();
|
|
3352
|
+
|
|
3353
|
+
//#endregion
|
|
3354
|
+
export { };
|
|
3355
|
+
//# sourceMappingURL=index.mjs.map
|