@soprog_/cdwt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -0
- package/dist/cli.js +1787 -0
- package/dist/cli.js.map +1 -0
- package/package.json +71 -0
- package/shell/cdwt.zsh +20 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1787 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import pc2 from "picocolors";
|
|
6
|
+
|
|
7
|
+
// src/errors.ts
|
|
8
|
+
var CdwtError = class extends Error {
|
|
9
|
+
constructor(message, code = 1) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "CdwtError";
|
|
13
|
+
}
|
|
14
|
+
code;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/commands/install.ts
|
|
18
|
+
import { mkdir, readFile, stat, writeFile } from "fs/promises";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
import path from "path";
|
|
21
|
+
import pc from "picocolors";
|
|
22
|
+
|
|
23
|
+
// src/core/rc-file.ts
|
|
24
|
+
function appendLineIfMissing(existing, line2) {
|
|
25
|
+
const target = line2.trim();
|
|
26
|
+
if (target === "") return { contents: existing, changed: false };
|
|
27
|
+
for (const rawLine of existing.split("\n")) {
|
|
28
|
+
if (rawLine.trim() === target) {
|
|
29
|
+
return { contents: existing, changed: false };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (existing === "") {
|
|
33
|
+
return { contents: `${target}
|
|
34
|
+
`, changed: true };
|
|
35
|
+
}
|
|
36
|
+
const separator = existing.endsWith("\n") ? "" : "\n";
|
|
37
|
+
return { contents: `${existing}${separator}${target}
|
|
38
|
+
`, changed: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/commands/install.ts
|
|
42
|
+
var SHELL_FILE = "cdwt.zsh";
|
|
43
|
+
var SHELL_FUNCTION = `cdwt() {
|
|
44
|
+
local destination
|
|
45
|
+
case "\${1-}" in
|
|
46
|
+
-h|--help)
|
|
47
|
+
command cdwt "$@"
|
|
48
|
+
return $?
|
|
49
|
+
;;
|
|
50
|
+
esac
|
|
51
|
+
if ! destination="$(command cdwt "$@")"; then
|
|
52
|
+
return $?
|
|
53
|
+
fi
|
|
54
|
+
if [[ -z "$destination" ]]; then
|
|
55
|
+
return 1
|
|
56
|
+
fi
|
|
57
|
+
cd "$destination" || return $?
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
var RC_LINE = 'source "$HOME/.local/share/cdwt/cdwt.zsh"';
|
|
61
|
+
async function runInstall(options) {
|
|
62
|
+
const { console } = options;
|
|
63
|
+
const shareDir = path.join(options.home, ".local", "share", "cdwt");
|
|
64
|
+
const shellFile = path.join(shareDir, SHELL_FILE);
|
|
65
|
+
await mkdir(shareDir, { recursive: true });
|
|
66
|
+
await writeFile(shellFile, SHELL_FUNCTION, { mode: 420 });
|
|
67
|
+
const rcFile = options.rcFile ?? path.join(options.home, ".zshrc");
|
|
68
|
+
const existing = await fileExists(rcFile) ? await readFile(rcFile, "utf8") : "";
|
|
69
|
+
const result = appendLineIfMissing(existing, RC_LINE);
|
|
70
|
+
if (result.changed) {
|
|
71
|
+
await writeFile(rcFile, result.contents);
|
|
72
|
+
}
|
|
73
|
+
console.outln(`${pc.green("\u2713")} installed shell wrapper to ${shellFile}`);
|
|
74
|
+
console.outln(
|
|
75
|
+
`${pc.green("\u2713")} ${result.changed ? "added" : "kept"} \`source\` line in ${rcFile}`
|
|
76
|
+
);
|
|
77
|
+
console.outln("");
|
|
78
|
+
console.outln("Reload your shell with one of:");
|
|
79
|
+
console.outln(` exec zsh -l`);
|
|
80
|
+
console.outln(` source "${rcFile}"`);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
async function fileExists(file) {
|
|
84
|
+
try {
|
|
85
|
+
const s = await stat(file);
|
|
86
|
+
return s.isFile();
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function defaultHome() {
|
|
92
|
+
return process.env["HOME"] ?? homedir();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/commands/select.ts
|
|
96
|
+
import { homedir as homedir2 } from "os";
|
|
97
|
+
|
|
98
|
+
// src/core/paths.ts
|
|
99
|
+
import path2 from "path";
|
|
100
|
+
function displayPath({
|
|
101
|
+
path: target,
|
|
102
|
+
mainWorktree,
|
|
103
|
+
mainParent,
|
|
104
|
+
home
|
|
105
|
+
}) {
|
|
106
|
+
if (target === mainWorktree) return ".";
|
|
107
|
+
if (mainWorktree && target.startsWith(`${mainWorktree}/`)) {
|
|
108
|
+
return `./${target.slice(mainWorktree.length + 1)}`;
|
|
109
|
+
}
|
|
110
|
+
if (mainParent && target.startsWith(`${mainParent}/`)) {
|
|
111
|
+
return `../${target.slice(mainParent.length + 1)}`;
|
|
112
|
+
}
|
|
113
|
+
if (home && target.startsWith(`${home}/`)) {
|
|
114
|
+
return `~/${target.slice(home.length + 1)}`;
|
|
115
|
+
}
|
|
116
|
+
return target;
|
|
117
|
+
}
|
|
118
|
+
function slugifyBranch(branch) {
|
|
119
|
+
let slug = branch.replace(/[/ ]/g, "-").replace(/[^A-Za-z0-9._-]/g, "-");
|
|
120
|
+
slug = slug.replace(/^-+/, "").replace(/-+$/, "");
|
|
121
|
+
if (slug === "") return "worktree";
|
|
122
|
+
return slug;
|
|
123
|
+
}
|
|
124
|
+
function makeBranchPath(branch, mainWorktree, repoName) {
|
|
125
|
+
const parent = path2.dirname(mainWorktree);
|
|
126
|
+
return `${parent}/${repoName}-${slugifyBranch(branch)}`;
|
|
127
|
+
}
|
|
128
|
+
function makePrPath(prNumber, mainWorktree, repoName) {
|
|
129
|
+
const parent = path2.dirname(mainWorktree);
|
|
130
|
+
return `${parent}/${repoName}-pr-${prNumber}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/core/sections.ts
|
|
134
|
+
function deriveBranchesWithWorktree(repo) {
|
|
135
|
+
const set = /* @__PURE__ */ new Set();
|
|
136
|
+
for (const wt of repo.worktrees) {
|
|
137
|
+
if (wt.branch && wt.path !== repo.mainWorktree) {
|
|
138
|
+
set.add(wt.branch);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return set;
|
|
142
|
+
}
|
|
143
|
+
function buildSections({
|
|
144
|
+
repo,
|
|
145
|
+
prs,
|
|
146
|
+
localBranches,
|
|
147
|
+
home
|
|
148
|
+
}) {
|
|
149
|
+
const lines = [];
|
|
150
|
+
const branchesWithWorktree = deriveBranchesWithWorktree(repo);
|
|
151
|
+
const prBranches = /* @__PURE__ */ new Set();
|
|
152
|
+
const rootMarker = repo.mainWorktree === repo.currentPath ? " [current]" : "";
|
|
153
|
+
lines.push(
|
|
154
|
+
line({
|
|
155
|
+
kind: "worktree",
|
|
156
|
+
section: "main",
|
|
157
|
+
name: `${repo.repoName}${rootMarker}`,
|
|
158
|
+
shortPath: ".",
|
|
159
|
+
fullPath: repo.mainWorktree,
|
|
160
|
+
destination: repo.mainWorktree,
|
|
161
|
+
branch: repo.mainWorktreeBranch ?? ""
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
for (const wt of repo.worktrees) {
|
|
165
|
+
if (wt.path === repo.mainWorktree) continue;
|
|
166
|
+
const label = worktreeLabel(wt, repo.currentPath);
|
|
167
|
+
lines.push(
|
|
168
|
+
line({
|
|
169
|
+
kind: "worktree",
|
|
170
|
+
section: "wt",
|
|
171
|
+
name: label,
|
|
172
|
+
shortPath: displayPath({
|
|
173
|
+
path: wt.path,
|
|
174
|
+
mainWorktree: repo.mainWorktree,
|
|
175
|
+
mainParent: repo.mainParent,
|
|
176
|
+
home
|
|
177
|
+
}),
|
|
178
|
+
fullPath: wt.path,
|
|
179
|
+
destination: wt.path,
|
|
180
|
+
branch: wt.branch ?? ""
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
for (const pr of prs) {
|
|
185
|
+
prBranches.add(pr.branch);
|
|
186
|
+
const existingWorktreePath = findNonMainWorktreePathForBranch(
|
|
187
|
+
repo.worktrees,
|
|
188
|
+
pr.branch,
|
|
189
|
+
repo.mainWorktree
|
|
190
|
+
);
|
|
191
|
+
const targetPath = existingWorktreePath ?? makePrPath(pr.number, repo.mainWorktree, repo.repoName);
|
|
192
|
+
lines.push(
|
|
193
|
+
line({
|
|
194
|
+
kind: "pr",
|
|
195
|
+
section: "pr",
|
|
196
|
+
name: `#${pr.number} ${pr.title}`,
|
|
197
|
+
shortPath: displayPath({
|
|
198
|
+
path: targetPath,
|
|
199
|
+
mainWorktree: repo.mainWorktree,
|
|
200
|
+
mainParent: repo.mainParent,
|
|
201
|
+
home
|
|
202
|
+
}),
|
|
203
|
+
fullPath: targetPath,
|
|
204
|
+
destination: targetPath,
|
|
205
|
+
branch: pr.branch,
|
|
206
|
+
prNumber: pr.number
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
for (const branch of localBranches) {
|
|
211
|
+
if (branchesWithWorktree.has(branch)) continue;
|
|
212
|
+
if (prBranches.has(branch)) continue;
|
|
213
|
+
const targetPath = makeBranchPath(branch, repo.mainWorktree, repo.repoName);
|
|
214
|
+
lines.push(
|
|
215
|
+
line({
|
|
216
|
+
kind: "branch",
|
|
217
|
+
section: "br",
|
|
218
|
+
name: branch,
|
|
219
|
+
shortPath: displayPath({
|
|
220
|
+
path: targetPath,
|
|
221
|
+
mainWorktree: repo.mainWorktree,
|
|
222
|
+
mainParent: repo.mainParent,
|
|
223
|
+
home
|
|
224
|
+
}),
|
|
225
|
+
fullPath: targetPath,
|
|
226
|
+
destination: targetPath,
|
|
227
|
+
branch
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
function line(input) {
|
|
234
|
+
return {
|
|
235
|
+
kind: input.kind,
|
|
236
|
+
section: input.section,
|
|
237
|
+
name: input.name,
|
|
238
|
+
shortPath: input.shortPath,
|
|
239
|
+
fullPath: input.fullPath,
|
|
240
|
+
destination: input.destination,
|
|
241
|
+
branch: input.branch,
|
|
242
|
+
prNumber: input.prNumber ?? null
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function worktreeLabel(wt, currentPath) {
|
|
246
|
+
const baseLabel = wt.branch ? wt.branch : wt.head ? `detached@${wt.head.slice(0, 7)}` : "detached";
|
|
247
|
+
const marker = wt.path === currentPath ? " [current]" : "";
|
|
248
|
+
return `${baseLabel}${marker}`;
|
|
249
|
+
}
|
|
250
|
+
function findNonMainWorktreePathForBranch(worktrees, branch, mainWorktree) {
|
|
251
|
+
for (const wt of worktrees) {
|
|
252
|
+
if (wt.path === mainWorktree) continue;
|
|
253
|
+
if (wt.branch === branch) return wt.path;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/io/config-loader.ts
|
|
259
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
260
|
+
import path3 from "path";
|
|
261
|
+
|
|
262
|
+
// src/core/config.ts
|
|
263
|
+
var ConfigError = class extends Error {
|
|
264
|
+
constructor(message, file) {
|
|
265
|
+
super(message);
|
|
266
|
+
this.file = file;
|
|
267
|
+
this.name = "ConfigError";
|
|
268
|
+
}
|
|
269
|
+
file;
|
|
270
|
+
};
|
|
271
|
+
function emptyConfig() {
|
|
272
|
+
return { copyIgnored: { paths: [], patterns: [] } };
|
|
273
|
+
}
|
|
274
|
+
function parseConfig(json, file) {
|
|
275
|
+
if (json === null || typeof json !== "object" || Array.isArray(json)) {
|
|
276
|
+
throw new ConfigError("config root must be an object", file);
|
|
277
|
+
}
|
|
278
|
+
const root = json;
|
|
279
|
+
const copyRaw = root["copyIgnored"];
|
|
280
|
+
if (copyRaw === void 0) {
|
|
281
|
+
return { copyIgnored: {} };
|
|
282
|
+
}
|
|
283
|
+
if (copyRaw === null || typeof copyRaw !== "object" || Array.isArray(copyRaw)) {
|
|
284
|
+
throw new ConfigError("copyIgnored must be an object", file);
|
|
285
|
+
}
|
|
286
|
+
const copy = copyRaw;
|
|
287
|
+
const out = { copyIgnored: {} };
|
|
288
|
+
if ("paths" in copy) {
|
|
289
|
+
out.copyIgnored.paths = readStringArray(copy["paths"], "copyIgnored.paths", file);
|
|
290
|
+
}
|
|
291
|
+
if ("patterns" in copy) {
|
|
292
|
+
out.copyIgnored.patterns = readStringArray(copy["patterns"], "copyIgnored.patterns", file);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
function readStringArray(value, key, file) {
|
|
297
|
+
if (!Array.isArray(value)) {
|
|
298
|
+
throw new ConfigError(`${key} must be an array`, file);
|
|
299
|
+
}
|
|
300
|
+
for (const item of value) {
|
|
301
|
+
if (typeof item !== "string") {
|
|
302
|
+
throw new ConfigError(`${key} must contain only strings`, file);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return [...value];
|
|
306
|
+
}
|
|
307
|
+
function mergeConfigs(configs) {
|
|
308
|
+
const merged = emptyConfig();
|
|
309
|
+
for (const config of configs) {
|
|
310
|
+
if (config.copyIgnored.paths !== void 0) {
|
|
311
|
+
merged.copyIgnored.paths = [...config.copyIgnored.paths];
|
|
312
|
+
}
|
|
313
|
+
if (config.copyIgnored.patterns !== void 0) {
|
|
314
|
+
merged.copyIgnored.patterns = [...config.copyIgnored.patterns];
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return merged;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/io/config-loader.ts
|
|
321
|
+
async function discoverConfigFiles({
|
|
322
|
+
cwd,
|
|
323
|
+
mainWorktree,
|
|
324
|
+
home,
|
|
325
|
+
override,
|
|
326
|
+
debug
|
|
327
|
+
}) {
|
|
328
|
+
if (override !== void 0) {
|
|
329
|
+
const exists = await fileExists2(override);
|
|
330
|
+
debug?.(`config override path=${override} exists=${exists}`);
|
|
331
|
+
if (!exists) {
|
|
332
|
+
throw new CdwtError(`config file not found: ${override}`);
|
|
333
|
+
}
|
|
334
|
+
return [override];
|
|
335
|
+
}
|
|
336
|
+
const seen = /* @__PURE__ */ new Set();
|
|
337
|
+
const files = [];
|
|
338
|
+
const homeFile = path3.join(home, ".cdwt", "settings.json");
|
|
339
|
+
const homeExists = await fileExists2(homeFile);
|
|
340
|
+
debug?.(`config check path=${homeFile} hit=${homeExists}`);
|
|
341
|
+
if (homeExists) {
|
|
342
|
+
seen.add(homeFile);
|
|
343
|
+
files.push(homeFile);
|
|
344
|
+
}
|
|
345
|
+
let scan = cwd;
|
|
346
|
+
if (scan !== mainWorktree && !scan.startsWith(`${mainWorktree}/`)) {
|
|
347
|
+
scan = mainWorktree;
|
|
348
|
+
}
|
|
349
|
+
const dirs = [];
|
|
350
|
+
while (true) {
|
|
351
|
+
dirs.push(scan);
|
|
352
|
+
if (scan === "/" || scan === "") break;
|
|
353
|
+
scan = path3.dirname(scan);
|
|
354
|
+
}
|
|
355
|
+
for (let i = dirs.length - 1; i >= 0; i--) {
|
|
356
|
+
const file = path3.join(dirs[i], ".cdwt", "settings.json");
|
|
357
|
+
if (seen.has(file)) continue;
|
|
358
|
+
const exists = await fileExists2(file);
|
|
359
|
+
debug?.(`config check path=${file} hit=${exists}`);
|
|
360
|
+
if (exists) {
|
|
361
|
+
seen.add(file);
|
|
362
|
+
files.push(file);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
debug?.(`config discovery complete: found ${files.length} file(s): [${files.join(", ")}]`);
|
|
366
|
+
return files;
|
|
367
|
+
}
|
|
368
|
+
async function readMergedConfig(files) {
|
|
369
|
+
const parsed = [];
|
|
370
|
+
for (const file of files) {
|
|
371
|
+
let raw;
|
|
372
|
+
try {
|
|
373
|
+
raw = await readFile2(file, "utf8");
|
|
374
|
+
} catch (err) {
|
|
375
|
+
throw new CdwtError(`failed to read config: ${file} (${err.message})`);
|
|
376
|
+
}
|
|
377
|
+
let json;
|
|
378
|
+
try {
|
|
379
|
+
json = JSON.parse(raw);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
throw new CdwtError(`invalid JSON in ${file}: ${err.message}`);
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
parsed.push(parseConfig(json, file));
|
|
385
|
+
} catch (err) {
|
|
386
|
+
if (err instanceof ConfigError) {
|
|
387
|
+
throw new CdwtError(`invalid config: ${file} (${err.message})`);
|
|
388
|
+
}
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return mergeConfigs(parsed);
|
|
393
|
+
}
|
|
394
|
+
async function fileExists2(file) {
|
|
395
|
+
try {
|
|
396
|
+
const s = await stat2(file);
|
|
397
|
+
return s.isFile();
|
|
398
|
+
} catch {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/io/exec.ts
|
|
404
|
+
import { spawn } from "child_process";
|
|
405
|
+
function run(command, args, options = {}) {
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
const t0 = Date.now();
|
|
408
|
+
const stdio = options.inheritStdio ? ["inherit", process.stderr, "inherit"] : ["pipe", "pipe", "pipe"];
|
|
409
|
+
const child = spawn(command, args, {
|
|
410
|
+
cwd: options.cwd,
|
|
411
|
+
env: options.env,
|
|
412
|
+
stdio
|
|
413
|
+
});
|
|
414
|
+
if (options.inheritStdio) {
|
|
415
|
+
child.on("error", reject);
|
|
416
|
+
child.on("close", (exitCode2) => {
|
|
417
|
+
const code = exitCode2 ?? 0;
|
|
418
|
+
const elapsed = Date.now() - t0;
|
|
419
|
+
options.onDebug?.(
|
|
420
|
+
`exec [inherited] ${command} ${args.join(" ")} cwd=${options.cwd ?? "."} exit=${code} +${elapsed}ms`
|
|
421
|
+
);
|
|
422
|
+
resolve({ stdout: "", stderr: "", exitCode: code });
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let stdout = "";
|
|
427
|
+
let stderr = "";
|
|
428
|
+
child.stdout?.on("data", (chunk) => {
|
|
429
|
+
stdout += chunk.toString("utf8");
|
|
430
|
+
});
|
|
431
|
+
child.stderr?.on("data", (chunk) => {
|
|
432
|
+
stderr += chunk.toString("utf8");
|
|
433
|
+
});
|
|
434
|
+
child.on("error", reject);
|
|
435
|
+
child.on("close", (exitCode2) => {
|
|
436
|
+
const code = exitCode2 ?? 0;
|
|
437
|
+
const elapsed = Date.now() - t0;
|
|
438
|
+
options.onDebug?.(
|
|
439
|
+
`exec ${command} ${args.join(" ")} cwd=${options.cwd ?? "."} exit=${code} stdout=${stdout.length}B stderr=${stderr.length}B +${elapsed}ms`
|
|
440
|
+
);
|
|
441
|
+
resolve({ stdout, stderr, exitCode: code });
|
|
442
|
+
});
|
|
443
|
+
if (options.input !== void 0) {
|
|
444
|
+
child.stdin?.write(options.input);
|
|
445
|
+
child.stdin?.end();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/io/gh.ts
|
|
451
|
+
async function isGhAvailable() {
|
|
452
|
+
const result = await run("which", ["gh"]);
|
|
453
|
+
return result.exitCode === 0;
|
|
454
|
+
}
|
|
455
|
+
async function listPullRequests(cwd, limit = 100) {
|
|
456
|
+
const result = await run(
|
|
457
|
+
"gh",
|
|
458
|
+
["pr", "list", "--limit", String(limit), "--json", "number,title,headRefName"],
|
|
459
|
+
{ cwd }
|
|
460
|
+
);
|
|
461
|
+
if (result.exitCode !== 0) return [];
|
|
462
|
+
let parsed;
|
|
463
|
+
try {
|
|
464
|
+
parsed = JSON.parse(result.stdout);
|
|
465
|
+
} catch {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
if (!Array.isArray(parsed)) return [];
|
|
469
|
+
const out = [];
|
|
470
|
+
for (const item of parsed) {
|
|
471
|
+
if (!isGhPrItem(item)) continue;
|
|
472
|
+
out.push({ number: item.number, branch: item.headRefName, title: item.title });
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
async function checkoutPr(cwd, prNumber) {
|
|
477
|
+
const result = await run("gh", ["pr", "checkout", String(prNumber)], {
|
|
478
|
+
cwd,
|
|
479
|
+
inheritStdio: true
|
|
480
|
+
});
|
|
481
|
+
return result.exitCode === 0;
|
|
482
|
+
}
|
|
483
|
+
function isGhPrItem(value) {
|
|
484
|
+
if (!value || typeof value !== "object") return false;
|
|
485
|
+
const v = value;
|
|
486
|
+
return typeof v["number"] === "number" && typeof v["title"] === "string" && typeof v["headRefName"] === "string";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/io/git.ts
|
|
490
|
+
import path4 from "path";
|
|
491
|
+
import { realpath } from "fs/promises";
|
|
492
|
+
|
|
493
|
+
// src/core/git-parse.ts
|
|
494
|
+
function parseWorktreeList(output) {
|
|
495
|
+
const worktrees = [];
|
|
496
|
+
let current = null;
|
|
497
|
+
const flush = () => {
|
|
498
|
+
if (current && current.path) {
|
|
499
|
+
worktrees.push({ ...current });
|
|
500
|
+
}
|
|
501
|
+
current = null;
|
|
502
|
+
};
|
|
503
|
+
for (const rawLine of output.split("\n")) {
|
|
504
|
+
const line2 = rawLine.replace(/\r$/, "");
|
|
505
|
+
if (line2 === "") {
|
|
506
|
+
flush();
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (line2.startsWith("worktree ")) {
|
|
510
|
+
flush();
|
|
511
|
+
current = { path: line2.slice("worktree ".length), branch: null, head: null };
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (!current) continue;
|
|
515
|
+
if (line2.startsWith("branch ")) {
|
|
516
|
+
const ref = line2.slice("branch ".length);
|
|
517
|
+
current.branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
518
|
+
} else if (line2.startsWith("HEAD ")) {
|
|
519
|
+
current.head = line2.slice("HEAD ".length);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
flush();
|
|
523
|
+
return worktrees;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/io/git.ts
|
|
527
|
+
var _gitDebug;
|
|
528
|
+
function setGitDebug(fn) {
|
|
529
|
+
_gitDebug = fn;
|
|
530
|
+
}
|
|
531
|
+
async function loadGitContext(cwd) {
|
|
532
|
+
let realCwd;
|
|
533
|
+
try {
|
|
534
|
+
realCwd = await realpath(cwd);
|
|
535
|
+
} catch {
|
|
536
|
+
throw new CdwtError(`invalid working directory: ${cwd}`);
|
|
537
|
+
}
|
|
538
|
+
const root = await runGit(["rev-parse", "--show-toplevel"], { cwd: realCwd });
|
|
539
|
+
if (root.exitCode !== 0) {
|
|
540
|
+
throw new CdwtError("not inside a git worktree");
|
|
541
|
+
}
|
|
542
|
+
const commonDirResult = await runGit(["rev-parse", "--git-common-dir"], { cwd: realCwd });
|
|
543
|
+
if (commonDirResult.exitCode !== 0) {
|
|
544
|
+
throw new CdwtError("failed to resolve git common dir");
|
|
545
|
+
}
|
|
546
|
+
let commonDir = commonDirResult.stdout.trim();
|
|
547
|
+
if (!path4.isAbsolute(commonDir)) {
|
|
548
|
+
commonDir = await realpath(path4.resolve(realCwd, commonDir));
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
cwd: realCwd,
|
|
552
|
+
gitRoot: root.stdout.trim(),
|
|
553
|
+
gitCommonDir: commonDir,
|
|
554
|
+
expectedMainWorktree: path4.dirname(commonDir)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
async function listWorktrees(cwd) {
|
|
558
|
+
const result = await runGit(["worktree", "list", "--porcelain"], { cwd });
|
|
559
|
+
if (result.exitCode !== 0) {
|
|
560
|
+
throw new CdwtError("failed to list git worktrees");
|
|
561
|
+
}
|
|
562
|
+
return parseWorktreeList(result.stdout);
|
|
563
|
+
}
|
|
564
|
+
async function symbolicRef(cwd, ref) {
|
|
565
|
+
const result = await runGit(["symbolic-ref", "--quiet", "--short", ref], { cwd });
|
|
566
|
+
return result.exitCode === 0 ? result.stdout.trim() : null;
|
|
567
|
+
}
|
|
568
|
+
async function listLocalBranches(cwd) {
|
|
569
|
+
const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], {
|
|
570
|
+
cwd
|
|
571
|
+
});
|
|
572
|
+
if (result.exitCode !== 0) return [];
|
|
573
|
+
return result.stdout.split("\n").filter((line2) => line2.length > 0);
|
|
574
|
+
}
|
|
575
|
+
async function listRemoteBranches(cwd) {
|
|
576
|
+
const result = await runGit(
|
|
577
|
+
["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"],
|
|
578
|
+
{ cwd }
|
|
579
|
+
);
|
|
580
|
+
if (result.exitCode !== 0) return [];
|
|
581
|
+
return result.stdout.split("\n").filter((line2) => line2.length > 0 && line2 !== "origin/HEAD").map((line2) => line2.replace(/^origin\//, ""));
|
|
582
|
+
}
|
|
583
|
+
async function branchExists(cwd, branch) {
|
|
584
|
+
const result = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
585
|
+
cwd
|
|
586
|
+
});
|
|
587
|
+
return result.exitCode === 0;
|
|
588
|
+
}
|
|
589
|
+
async function checkRefFormat(cwd, branch) {
|
|
590
|
+
const result = await runGit(["check-ref-format", "--branch", branch], { cwd });
|
|
591
|
+
return result.exitCode === 0;
|
|
592
|
+
}
|
|
593
|
+
async function isGitIgnored(cwd, relativePath) {
|
|
594
|
+
const result = await runGit(["check-ignore", "-q", "--", relativePath], { cwd });
|
|
595
|
+
return result.exitCode === 0;
|
|
596
|
+
}
|
|
597
|
+
async function listIgnoredFilesMatching(cwd, pathspecs) {
|
|
598
|
+
if (pathspecs.length === 0) return [];
|
|
599
|
+
const result = await runGit(
|
|
600
|
+
["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", ...pathspecs],
|
|
601
|
+
{ cwd }
|
|
602
|
+
);
|
|
603
|
+
if (result.exitCode !== 0) return [];
|
|
604
|
+
return result.stdout.split("\0").filter((p) => p.length > 0);
|
|
605
|
+
}
|
|
606
|
+
async function runGitInherited(args, cwd) {
|
|
607
|
+
const result = await run("git", args, { cwd, inheritStdio: true, ..._gitDebug && { onDebug: _gitDebug } });
|
|
608
|
+
return result.exitCode;
|
|
609
|
+
}
|
|
610
|
+
async function addWorktreeForBranch(cwd, destination, branch) {
|
|
611
|
+
const code = await runGitInherited(["worktree", "add", destination, branch], cwd);
|
|
612
|
+
if (code !== 0) {
|
|
613
|
+
throw new CdwtError(`git worktree add failed for ${branch} (exit ${code})`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function addWorktreeNewBranch(cwd, destination, branch, baseRef) {
|
|
617
|
+
const code = await runGitInherited(["worktree", "add", "-b", branch, destination, baseRef], cwd);
|
|
618
|
+
if (code !== 0) {
|
|
619
|
+
throw new CdwtError(`git worktree add -b ${branch} from ${baseRef} failed (exit ${code})`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async function addWorktreeDetached(cwd, destination) {
|
|
623
|
+
const code = await runGitInherited(["worktree", "add", "--detach", destination], cwd);
|
|
624
|
+
if (code !== 0) {
|
|
625
|
+
throw new CdwtError(`git worktree add --detach failed at ${destination} (exit ${code})`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async function removeWorktree(cwd, target) {
|
|
629
|
+
const result = await run("git", ["worktree", "remove", target], { cwd, ..._gitDebug && { onDebug: _gitDebug } });
|
|
630
|
+
return { exitCode: result.exitCode, stderr: result.stderr };
|
|
631
|
+
}
|
|
632
|
+
async function removeWorktreeForceRaw(cwd, target) {
|
|
633
|
+
const result = await run("git", ["worktree", "remove", "--force", target], {
|
|
634
|
+
cwd,
|
|
635
|
+
..._gitDebug && { onDebug: _gitDebug }
|
|
636
|
+
});
|
|
637
|
+
return { exitCode: result.exitCode, stderr: result.stderr };
|
|
638
|
+
}
|
|
639
|
+
async function removeWorktreeForce(cwd, target, console) {
|
|
640
|
+
const result = await removeWorktreeForceRaw(cwd, target);
|
|
641
|
+
if (result.exitCode === 0) return;
|
|
642
|
+
if (result.stderr) console.err(result.stderr);
|
|
643
|
+
console.errln(
|
|
644
|
+
`cdwt: warning: failed to remove orphan worktree at ${target}; clean up with: git worktree remove --force "${target}"`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
async function runGit(args, options) {
|
|
648
|
+
return run("git", args, { cwd: options.cwd, ..._gitDebug && { onDebug: _gitDebug } });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// src/io/repo-context.ts
|
|
652
|
+
import path5 from "path";
|
|
653
|
+
|
|
654
|
+
// src/core/default-branch.ts
|
|
655
|
+
var FALLBACK_CANDIDATES = ["main", "master"];
|
|
656
|
+
function resolveDefaultBranch(input) {
|
|
657
|
+
let branch = null;
|
|
658
|
+
if (input.remoteHead) {
|
|
659
|
+
branch = input.remoteHead.startsWith("origin/") ? input.remoteHead.slice("origin/".length) : input.remoteHead;
|
|
660
|
+
}
|
|
661
|
+
if (!branch && input.mainWorktreeBranch) {
|
|
662
|
+
branch = input.mainWorktreeBranch;
|
|
663
|
+
}
|
|
664
|
+
if (!branch) {
|
|
665
|
+
for (const candidate of FALLBACK_CANDIDATES) {
|
|
666
|
+
if (input.localBranches.has(candidate) || input.remoteBranches.has(candidate)) {
|
|
667
|
+
branch = candidate;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (!branch) {
|
|
673
|
+
return { branch: null, ref: null };
|
|
674
|
+
}
|
|
675
|
+
if (input.localBranches.has(branch)) {
|
|
676
|
+
return { branch, ref: branch };
|
|
677
|
+
}
|
|
678
|
+
if (input.remoteBranches.has(branch)) {
|
|
679
|
+
return { branch, ref: `origin/${branch}` };
|
|
680
|
+
}
|
|
681
|
+
if (input.worktreeBranches.has(branch)) {
|
|
682
|
+
return { branch, ref: branch };
|
|
683
|
+
}
|
|
684
|
+
return { branch, ref: null };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/io/repo-context.ts
|
|
688
|
+
async function loadRepoContext(cwd) {
|
|
689
|
+
const git = await loadGitContext(cwd);
|
|
690
|
+
const worktrees = await listWorktrees(git.cwd);
|
|
691
|
+
const main2 = worktrees.find((wt) => wt.path === git.expectedMainWorktree);
|
|
692
|
+
if (!main2) {
|
|
693
|
+
throw new CdwtError("failed to detect the main worktree");
|
|
694
|
+
}
|
|
695
|
+
const mainParent = path5.dirname(main2.path);
|
|
696
|
+
const repoName = path5.basename(main2.path);
|
|
697
|
+
const [remoteHead, localBranches, remoteBranches] = await Promise.all([
|
|
698
|
+
symbolicRef(git.cwd, "refs/remotes/origin/HEAD"),
|
|
699
|
+
listLocalBranches(git.cwd),
|
|
700
|
+
listRemoteBranches(git.cwd)
|
|
701
|
+
]);
|
|
702
|
+
const worktreeBranches = /* @__PURE__ */ new Set();
|
|
703
|
+
for (const wt of worktrees) {
|
|
704
|
+
if (wt.branch) worktreeBranches.add(wt.branch);
|
|
705
|
+
}
|
|
706
|
+
const { branch, ref } = resolveDefaultBranch({
|
|
707
|
+
remoteHead,
|
|
708
|
+
mainWorktreeBranch: main2.branch,
|
|
709
|
+
localBranches: new Set(localBranches),
|
|
710
|
+
remoteBranches: new Set(remoteBranches),
|
|
711
|
+
worktreeBranches
|
|
712
|
+
});
|
|
713
|
+
return {
|
|
714
|
+
mainWorktree: main2.path,
|
|
715
|
+
mainParent,
|
|
716
|
+
repoName,
|
|
717
|
+
defaultBranch: branch,
|
|
718
|
+
defaultBranchRef: ref,
|
|
719
|
+
mainWorktreeBranch: main2.branch,
|
|
720
|
+
worktrees,
|
|
721
|
+
cwd: git.cwd,
|
|
722
|
+
currentPath: git.gitRoot
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/ui/fzf.ts
|
|
727
|
+
import { spawn as spawn2 } from "child_process";
|
|
728
|
+
async function isFzfAvailable() {
|
|
729
|
+
const result = await run("which", ["fzf"]);
|
|
730
|
+
return result.exitCode === 0;
|
|
731
|
+
}
|
|
732
|
+
function runFzf({ args, input }) {
|
|
733
|
+
return new Promise((resolve, reject) => {
|
|
734
|
+
const child = spawn2("fzf", args, {
|
|
735
|
+
stdio: ["pipe", "pipe", "inherit"]
|
|
736
|
+
});
|
|
737
|
+
let stdout = "";
|
|
738
|
+
child.stdout.setEncoding("utf8");
|
|
739
|
+
child.stdout.on("data", (chunk) => {
|
|
740
|
+
stdout += chunk;
|
|
741
|
+
});
|
|
742
|
+
child.on("error", reject);
|
|
743
|
+
child.on("close", (code) => resolve({ exitCode: code ?? 0, stdout }));
|
|
744
|
+
child.stdin.write(input);
|
|
745
|
+
child.stdin.end();
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/ui/format.ts
|
|
750
|
+
var FIELD_SEP = "";
|
|
751
|
+
var TAG_WIDTH = 11;
|
|
752
|
+
var NAME_WIDTH = 44;
|
|
753
|
+
var ESC = "\x1B[";
|
|
754
|
+
var RESET = `${ESC}0m`;
|
|
755
|
+
var BOLD = `${ESC}1m`;
|
|
756
|
+
var FG_GREEN = `${ESC}32m`;
|
|
757
|
+
var FG_YELLOW = `${ESC}33m`;
|
|
758
|
+
var FG_CYAN = `${ESC}36m`;
|
|
759
|
+
var FG_MAGENTA = `${ESC}35m`;
|
|
760
|
+
var FG_GRAY = `${ESC}90m`;
|
|
761
|
+
var STYLES = {
|
|
762
|
+
main: { label: "main", glyph: "\u2605", color: `${BOLD}${FG_GREEN}` },
|
|
763
|
+
wt: { label: "worktree", glyph: "\u25CF", color: FG_CYAN },
|
|
764
|
+
br: { label: "branch", glyph: "\u25CB", color: FG_YELLOW },
|
|
765
|
+
pr: { label: "PR", glyph: "\u25C6", color: FG_MAGENTA }
|
|
766
|
+
};
|
|
767
|
+
function renderLine(line2) {
|
|
768
|
+
const style = STYLES[line2.section];
|
|
769
|
+
const tag = pad(`[${style.label}]`, TAG_WIDTH);
|
|
770
|
+
const paddedName = pad(line2.name, NAME_WIDTH);
|
|
771
|
+
const head = `${style.color}${style.glyph} ${tag} ${paddedName}${RESET}`;
|
|
772
|
+
const pathHint = `${FG_GRAY}${line2.shortPath}${RESET}`;
|
|
773
|
+
const visible = `${head} ${pathHint}`;
|
|
774
|
+
return `${visible}${FIELD_SEP}${line2.shortPath}${FIELD_SEP}${line2.fullPath}`;
|
|
775
|
+
}
|
|
776
|
+
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
777
|
+
function stripAnsi(s) {
|
|
778
|
+
return s.replace(ANSI_RE, "");
|
|
779
|
+
}
|
|
780
|
+
function sectionLabel(section) {
|
|
781
|
+
return STYLES[section].label;
|
|
782
|
+
}
|
|
783
|
+
function pad(value, width) {
|
|
784
|
+
return value.length >= width ? value : value + " ".repeat(width - value.length);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/ui/selector.ts
|
|
788
|
+
var FILTER_ORDER = ["all", "wt", "br", "pr"];
|
|
789
|
+
function filterLabel(filter) {
|
|
790
|
+
return filter === "all" ? "all" : sectionLabel(filter);
|
|
791
|
+
}
|
|
792
|
+
var FILTER_CYCLE_LABEL = `${FILTER_ORDER.map(filterLabel).join(" / ")}`;
|
|
793
|
+
var COMMAND_SENTINEL = "__CDWT_CMD__";
|
|
794
|
+
var FOOTER = "\u21B5 go esc cancel tab filter ctrl-d delete / commands ? help";
|
|
795
|
+
var HELP_BODY = [
|
|
796
|
+
"shortcuts",
|
|
797
|
+
" enter go to highlighted entry",
|
|
798
|
+
" esc cancel",
|
|
799
|
+
` tab / shift-tab cycle filter (${FILTER_CYCLE_LABEL})`,
|
|
800
|
+
" ctrl-d delete the highlighted worktree",
|
|
801
|
+
" / open the slash command palette",
|
|
802
|
+
" ? this help",
|
|
803
|
+
"",
|
|
804
|
+
"row legend",
|
|
805
|
+
" \u2605 [main] default-branch worktree (enter = jump)",
|
|
806
|
+
" \u25CF [worktree] existing linked worktree (enter = jump)",
|
|
807
|
+
" \u25CB [branch] local branch without a worktree (enter = create then jump)",
|
|
808
|
+
" \u25C6 [PR] GitHub PR (enter = checkout into a worktree then jump)"
|
|
809
|
+
];
|
|
810
|
+
async function selectInteractive(allLines, options) {
|
|
811
|
+
if (allLines.length === 0) return { kind: "cancelled" };
|
|
812
|
+
const useFzf = options.useFzf ?? await isFzfAvailable();
|
|
813
|
+
options.console.debug(
|
|
814
|
+
`selector mode=${useFzf ? "fzf" : "prompt"} totalLines=${allLines.length} initialFilter=${options.initialFilter ?? "all"}`
|
|
815
|
+
);
|
|
816
|
+
if (useFzf) {
|
|
817
|
+
return selectWithFzf(allLines, options);
|
|
818
|
+
}
|
|
819
|
+
return selectWithPrompt(allLines, options);
|
|
820
|
+
}
|
|
821
|
+
async function selectWithFzf(allLines, options) {
|
|
822
|
+
const runFzf2 = options.fzfRunner ?? runFzf;
|
|
823
|
+
let filter = options.initialFilter ?? "all";
|
|
824
|
+
const prompt = options.prompt ?? "> ";
|
|
825
|
+
const footer = options.footer ?? FOOTER;
|
|
826
|
+
while (true) {
|
|
827
|
+
const visible = applyFilter(allLines, filter);
|
|
828
|
+
if (visible.length === 0) {
|
|
829
|
+
filter = "all";
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const inputLines = visible.map((line2) => renderLine(line2));
|
|
833
|
+
const lookup = new Map(visible.map((line2) => [stripAnsi(renderLine(line2)), line2]));
|
|
834
|
+
const header = filter === "all" ? "" : `filter: ${filterLabel(filter)}`;
|
|
835
|
+
const result = await runFzf2({
|
|
836
|
+
args: [
|
|
837
|
+
`--prompt=${prompt}`,
|
|
838
|
+
"--print-query",
|
|
839
|
+
`--delimiter=${FIELD_SEP}`,
|
|
840
|
+
"--nth=1",
|
|
841
|
+
// Field 1 carries the colored "glyph + [tag] + name + dim path" row,
|
|
842
|
+
// fields 2/3 are data-only (preview / cd target). --with-nth=1 keeps
|
|
843
|
+
// the visible row tidy; --ansi makes fzf interpret the color codes
|
|
844
|
+
// and strip them when matching/printing.
|
|
845
|
+
"--with-nth=1",
|
|
846
|
+
"--ansi",
|
|
847
|
+
"--expect=tab,btab,?,ctrl-d",
|
|
848
|
+
"--height=70%",
|
|
849
|
+
"--reverse",
|
|
850
|
+
"--layout=reverse",
|
|
851
|
+
"--info=inline-right",
|
|
852
|
+
"--header-first",
|
|
853
|
+
`--header=${header}`,
|
|
854
|
+
`--footer=${footer}`,
|
|
855
|
+
"--preview",
|
|
856
|
+
'if [ -n "{3}" ]; then printf "%s\\n" {3}; fi',
|
|
857
|
+
"--preview-window=down:3:wrap",
|
|
858
|
+
// `/` leaves the picker. `become` replaces fzf with a printf that
|
|
859
|
+
// writes the sentinel; we dispatch into command-mode from the caller.
|
|
860
|
+
`--bind=/:become(printf '%s\\n' '${COMMAND_SENTINEL}')`
|
|
861
|
+
],
|
|
862
|
+
input: inputLines.join("\n")
|
|
863
|
+
});
|
|
864
|
+
options.console.debug(
|
|
865
|
+
`fzf returned exit=${result.exitCode} stdoutBytes=${result.stdout.length}`
|
|
866
|
+
);
|
|
867
|
+
if (containsCommandSentinel(result.stdout)) {
|
|
868
|
+
return { kind: "command-mode" };
|
|
869
|
+
}
|
|
870
|
+
const outcome = parsePickerOutput(result.stdout, result.exitCode);
|
|
871
|
+
options.console.debug(
|
|
872
|
+
`fzf parsed key=${JSON.stringify(outcome.key)} query=${JSON.stringify(outcome.query)} selected=${outcome.selected ? "yes" : "no"}`
|
|
873
|
+
);
|
|
874
|
+
if (outcome.cancelled) return { kind: "cancelled" };
|
|
875
|
+
if (outcome.key === "tab") {
|
|
876
|
+
filter = nextFilter(filter, 1);
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (outcome.key === "btab") {
|
|
880
|
+
filter = nextFilter(filter, -1);
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (outcome.key === "?") {
|
|
884
|
+
await runHelpOverlay(runFzf2);
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (outcome.key === "ctrl-d") {
|
|
888
|
+
if (outcome.selected) {
|
|
889
|
+
const line2 = lookup.get(stripAnsi(outcome.selected));
|
|
890
|
+
if (line2) return { kind: "delete-target", line: line2 };
|
|
891
|
+
}
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (outcome.selected) {
|
|
895
|
+
const line2 = lookup.get(stripAnsi(outcome.selected));
|
|
896
|
+
if (line2) return { kind: "selected", line: line2 };
|
|
897
|
+
}
|
|
898
|
+
return { kind: "cancelled" };
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function containsCommandSentinel(stdout) {
|
|
902
|
+
return stdout.split("\n").some((line2) => line2 === COMMAND_SENTINEL);
|
|
903
|
+
}
|
|
904
|
+
function parsePickerOutput(stdout, exitCode2) {
|
|
905
|
+
if (exitCode2 !== 0 && stdout === "") {
|
|
906
|
+
return { cancelled: true, query: "", key: "", selected: "" };
|
|
907
|
+
}
|
|
908
|
+
const lines = stdout.replace(/\n$/, "").split("\n");
|
|
909
|
+
return {
|
|
910
|
+
cancelled: false,
|
|
911
|
+
query: lines[0] ?? "",
|
|
912
|
+
key: lines[1] ?? "",
|
|
913
|
+
selected: lines[2] ?? ""
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
function applyFilter(lines, filter) {
|
|
917
|
+
if (filter === "all") return [...lines];
|
|
918
|
+
return lines.filter((l) => sectionMatches(l.section, filter));
|
|
919
|
+
}
|
|
920
|
+
function sectionMatches(section, filter) {
|
|
921
|
+
return section === filter;
|
|
922
|
+
}
|
|
923
|
+
function nextFilter(current, step) {
|
|
924
|
+
const idx = FILTER_ORDER.indexOf(current);
|
|
925
|
+
const next = (idx + step + FILTER_ORDER.length) % FILTER_ORDER.length;
|
|
926
|
+
return FILTER_ORDER[next];
|
|
927
|
+
}
|
|
928
|
+
async function runHelpOverlay(runFzf2) {
|
|
929
|
+
await runFzf2({
|
|
930
|
+
args: [
|
|
931
|
+
"--prompt=help> ",
|
|
932
|
+
"--height=70%",
|
|
933
|
+
"--reverse",
|
|
934
|
+
"--layout=reverse",
|
|
935
|
+
"--info=hidden",
|
|
936
|
+
"--header-first",
|
|
937
|
+
"--header=press esc to close",
|
|
938
|
+
"--no-sort",
|
|
939
|
+
"--disabled"
|
|
940
|
+
],
|
|
941
|
+
input: HELP_BODY.join("\n")
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
var MAX_PROMPT_RETRIES = 5;
|
|
945
|
+
async function selectWithPrompt(allLines, options) {
|
|
946
|
+
const console = options.console;
|
|
947
|
+
let filter = options.initialFilter ?? "all";
|
|
948
|
+
for (let attempt = 0; attempt < MAX_PROMPT_RETRIES; attempt++) {
|
|
949
|
+
const visible = applyFilter(allLines, filter);
|
|
950
|
+
if (visible.length === 0) {
|
|
951
|
+
filter = "all";
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
console.errln("");
|
|
955
|
+
if (filter !== "all") console.errln(`(filter: ${filterLabel(filter)})`);
|
|
956
|
+
visible.forEach(
|
|
957
|
+
(line2, idx) => console.errln(
|
|
958
|
+
`${String(idx + 1).padStart(2)}) ${renderLine(line2).split(FIELD_SEP)[0] ?? line2.name}`
|
|
959
|
+
)
|
|
960
|
+
);
|
|
961
|
+
console.errln(
|
|
962
|
+
"type number to go, d <number> to delete, /<command> for slash mode, blank to cancel"
|
|
963
|
+
);
|
|
964
|
+
const answer = await console.ask("> ");
|
|
965
|
+
if (answer === null) return { kind: "cancelled" };
|
|
966
|
+
const trimmed = answer.trim();
|
|
967
|
+
if (trimmed === "") return { kind: "cancelled" };
|
|
968
|
+
if (trimmed.startsWith("/")) {
|
|
969
|
+
return { kind: "command-mode", initialInput: trimmed };
|
|
970
|
+
}
|
|
971
|
+
if (trimmed === "tab") {
|
|
972
|
+
filter = nextFilter(filter, 1);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (trimmed === "?") {
|
|
976
|
+
console.errln(HELP_BODY.join("\n"));
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const deleteMatch = /^d\s+(\d+)$/i.exec(trimmed);
|
|
980
|
+
if (deleteMatch) {
|
|
981
|
+
const n2 = Number.parseInt(deleteMatch[1], 10);
|
|
982
|
+
if (!Number.isFinite(n2) || n2 < 1 || n2 > visible.length) {
|
|
983
|
+
console.errln("cdwt: invalid selection");
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
return { kind: "delete-target", line: visible[n2 - 1] };
|
|
987
|
+
}
|
|
988
|
+
const n = Number.parseInt(trimmed, 10);
|
|
989
|
+
if (!Number.isFinite(n) || n < 1 || n > visible.length) {
|
|
990
|
+
console.errln("cdwt: invalid selection");
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
return { kind: "selected", line: visible[n - 1] };
|
|
994
|
+
}
|
|
995
|
+
console.errln("cdwt: too many invalid attempts; aborting");
|
|
996
|
+
return { kind: "cancelled" };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/commands/slash-commands.ts
|
|
1000
|
+
var NEW_CMD = {
|
|
1001
|
+
name: "new",
|
|
1002
|
+
aliases: ["n"],
|
|
1003
|
+
description: "create a worktree from the default branch",
|
|
1004
|
+
argHint: "<branch>",
|
|
1005
|
+
async execute(rawArgs, host) {
|
|
1006
|
+
const branch = rawArgs.trim() === "" ? void 0 : rawArgs.trim();
|
|
1007
|
+
const code = await host.createNewWorktree(branch);
|
|
1008
|
+
return { kind: "exit", code };
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
var MAIN_CMD = {
|
|
1012
|
+
name: "main",
|
|
1013
|
+
aliases: ["home"],
|
|
1014
|
+
description: "jump to the main worktree",
|
|
1015
|
+
execute(_rawArgs, host) {
|
|
1016
|
+
host.printMainDestination();
|
|
1017
|
+
return Promise.resolve({ kind: "exit", code: 0 });
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
var PR_CMD = {
|
|
1021
|
+
name: "pr",
|
|
1022
|
+
aliases: [],
|
|
1023
|
+
description: "load and filter pull requests",
|
|
1024
|
+
async execute(_rawArgs, host) {
|
|
1025
|
+
await host.loadPrs();
|
|
1026
|
+
return { kind: "continue" };
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
var REFRESH_CMD = {
|
|
1030
|
+
name: "refresh",
|
|
1031
|
+
aliases: ["reload", "r"],
|
|
1032
|
+
description: "reload worktrees, branches, and PRs",
|
|
1033
|
+
async execute(_rawArgs, host) {
|
|
1034
|
+
await host.refresh();
|
|
1035
|
+
return { kind: "continue" };
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
var HELP_CMD = {
|
|
1039
|
+
name: "help",
|
|
1040
|
+
aliases: ["h", "?"],
|
|
1041
|
+
description: "show command help",
|
|
1042
|
+
execute(_rawArgs, host) {
|
|
1043
|
+
host.console.errln(buildHelpText(SLASH_COMMANDS));
|
|
1044
|
+
return Promise.resolve({ kind: "continue" });
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
var SLASH_COMMANDS = [
|
|
1048
|
+
NEW_CMD,
|
|
1049
|
+
MAIN_CMD,
|
|
1050
|
+
PR_CMD,
|
|
1051
|
+
REFRESH_CMD,
|
|
1052
|
+
HELP_CMD
|
|
1053
|
+
];
|
|
1054
|
+
function findCommand(name) {
|
|
1055
|
+
if (name === "") return null;
|
|
1056
|
+
return SLASH_COMMANDS.find((c) => c.name === name || c.aliases.includes(name)) ?? null;
|
|
1057
|
+
}
|
|
1058
|
+
function parseCommandLine(input) {
|
|
1059
|
+
const trimmed = input.trim();
|
|
1060
|
+
if (!trimmed.startsWith("/")) return null;
|
|
1061
|
+
const body = trimmed.slice(1).trim();
|
|
1062
|
+
if (body === "") return null;
|
|
1063
|
+
const [head, ...rest] = body.split(/\s+/);
|
|
1064
|
+
if (head === void 0 || head === "") return null;
|
|
1065
|
+
const command = findCommand(head);
|
|
1066
|
+
if (!command) return null;
|
|
1067
|
+
return { command, args: rest.join(" ") };
|
|
1068
|
+
}
|
|
1069
|
+
function buildHelpText(commands) {
|
|
1070
|
+
const rows = commands.map((cmd) => {
|
|
1071
|
+
const aliases = cmd.aliases.map((a) => `/${a}`).join(", ");
|
|
1072
|
+
const left = `/${cmd.name}${cmd.argHint ? ` ${cmd.argHint}` : ""}`;
|
|
1073
|
+
const aliasPart = aliases ? ` (${aliases})` : "";
|
|
1074
|
+
return ` ${left.padEnd(20)} ${cmd.description}${aliasPart}`;
|
|
1075
|
+
});
|
|
1076
|
+
return ["slash commands", ...rows].join("\n");
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/ui/command-mode.ts
|
|
1080
|
+
var PROMPT = "cmd> ";
|
|
1081
|
+
async function runCommandMode(options) {
|
|
1082
|
+
if (options.initialInput !== void 0) {
|
|
1083
|
+
const parsed = parseCommandLine(options.initialInput);
|
|
1084
|
+
if (parsed) {
|
|
1085
|
+
return resolveArgs(parsed.command, parsed.args, options);
|
|
1086
|
+
}
|
|
1087
|
+
options.console.errln(`cdwt: unknown command: ${options.initialInput.trim()}`);
|
|
1088
|
+
}
|
|
1089
|
+
const useFzf = options.useFzf ?? await isFzfAvailable();
|
|
1090
|
+
if (useFzf) return runWithFzf(options);
|
|
1091
|
+
return runWithPrompt(options);
|
|
1092
|
+
}
|
|
1093
|
+
var MAX_PROMPT_RETRIES2 = 5;
|
|
1094
|
+
async function runWithPrompt(options) {
|
|
1095
|
+
const { console, registry } = options;
|
|
1096
|
+
for (let attempt = 0; attempt < MAX_PROMPT_RETRIES2; attempt++) {
|
|
1097
|
+
console.errln("");
|
|
1098
|
+
registry.forEach((cmd, idx) => console.errln(formatPaletteRow(cmd, idx + 1)));
|
|
1099
|
+
console.errln(
|
|
1100
|
+
"type number to pick, /<name> [args] to dispatch directly, blank to cancel"
|
|
1101
|
+
);
|
|
1102
|
+
const answer = await console.ask(PROMPT);
|
|
1103
|
+
if (answer === null) return { kind: "cancelled" };
|
|
1104
|
+
const trimmed = answer.trim();
|
|
1105
|
+
if (trimmed === "") return { kind: "cancelled" };
|
|
1106
|
+
if (trimmed.startsWith("/")) {
|
|
1107
|
+
const parsed = parseCommandLine(trimmed);
|
|
1108
|
+
if (parsed) return resolveArgs(parsed.command, parsed.args, options);
|
|
1109
|
+
console.errln(`cdwt: unknown command: ${trimmed}`);
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
const n = Number.parseInt(trimmed, 10);
|
|
1113
|
+
if (!Number.isFinite(n) || n < 1 || n > registry.length) {
|
|
1114
|
+
console.errln("cdwt: invalid selection");
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const command = registry[n - 1];
|
|
1118
|
+
return resolveArgs(command, "", options);
|
|
1119
|
+
}
|
|
1120
|
+
console.errln("cdwt: too many invalid attempts; aborting");
|
|
1121
|
+
return { kind: "cancelled" };
|
|
1122
|
+
}
|
|
1123
|
+
async function runWithFzf(options) {
|
|
1124
|
+
const runFzf2 = options.fzfRunner ?? runFzf;
|
|
1125
|
+
const { registry, console } = options;
|
|
1126
|
+
const inputLines = registry.map((cmd) => renderPaletteLine(cmd));
|
|
1127
|
+
const lookup = new Map(inputLines.map((line2, idx) => [line2, registry[idx]]));
|
|
1128
|
+
const result = await runFzf2({
|
|
1129
|
+
args: [
|
|
1130
|
+
`--prompt=${PROMPT}`,
|
|
1131
|
+
"--print-query",
|
|
1132
|
+
`--delimiter=${FIELD_SEP}`,
|
|
1133
|
+
"--nth=1",
|
|
1134
|
+
"--height=40%",
|
|
1135
|
+
"--reverse",
|
|
1136
|
+
"--layout=reverse",
|
|
1137
|
+
"--info=inline-right",
|
|
1138
|
+
"--header=pick a command"
|
|
1139
|
+
],
|
|
1140
|
+
input: inputLines.join("\n")
|
|
1141
|
+
});
|
|
1142
|
+
if (result.exitCode !== 0 && result.stdout === "") {
|
|
1143
|
+
return { kind: "cancelled" };
|
|
1144
|
+
}
|
|
1145
|
+
const lines = result.stdout.replace(/\n$/, "").split("\n");
|
|
1146
|
+
const query = lines[0] ?? "";
|
|
1147
|
+
const selected = lines[1] ?? "";
|
|
1148
|
+
if (query.startsWith("/")) {
|
|
1149
|
+
const parsed = parseCommandLine(query);
|
|
1150
|
+
if (parsed) return resolveArgs(parsed.command, parsed.args, options);
|
|
1151
|
+
console.errln(`cdwt: unknown command: ${query.trim()}`);
|
|
1152
|
+
return { kind: "cancelled" };
|
|
1153
|
+
}
|
|
1154
|
+
const command = selected ? lookup.get(selected) : void 0;
|
|
1155
|
+
if (!command) return { kind: "cancelled" };
|
|
1156
|
+
return resolveArgs(command, "", options);
|
|
1157
|
+
}
|
|
1158
|
+
async function resolveArgs(command, rawArgs, options) {
|
|
1159
|
+
if (command.argHint && rawArgs.trim() === "") {
|
|
1160
|
+
const answer = await options.console.ask(`/${command.name} ${command.argHint}> `);
|
|
1161
|
+
if (answer === null || answer.trim() === "") return { kind: "cancelled" };
|
|
1162
|
+
return { kind: "command", command, args: answer.trim() };
|
|
1163
|
+
}
|
|
1164
|
+
return { kind: "command", command, args: rawArgs };
|
|
1165
|
+
}
|
|
1166
|
+
function renderPaletteLine(cmd) {
|
|
1167
|
+
const left = `/${cmd.name}${cmd.argHint ? ` ${cmd.argHint}` : ""}`;
|
|
1168
|
+
return `${left.padEnd(20)}${FIELD_SEP}${cmd.description}`;
|
|
1169
|
+
}
|
|
1170
|
+
function formatPaletteRow(cmd, n) {
|
|
1171
|
+
const left = `/${cmd.name}${cmd.argHint ? ` ${cmd.argHint}` : ""}`;
|
|
1172
|
+
return `${String(n).padStart(2)}) ${left.padEnd(20)} ${cmd.description}`;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// src/commands/actions.ts
|
|
1176
|
+
import { stat as stat4 } from "fs/promises";
|
|
1177
|
+
|
|
1178
|
+
// src/io/copy-files.ts
|
|
1179
|
+
import { cp, mkdir as mkdir2, stat as stat3 } from "fs/promises";
|
|
1180
|
+
import path7 from "path";
|
|
1181
|
+
|
|
1182
|
+
// src/core/copy.ts
|
|
1183
|
+
import path6 from "path";
|
|
1184
|
+
var UnsafeCopyPathError = class extends Error {
|
|
1185
|
+
constructor(value) {
|
|
1186
|
+
super(`refusing unsafe copy path: ${value}`);
|
|
1187
|
+
this.value = value;
|
|
1188
|
+
this.name = "UnsafeCopyPathError";
|
|
1189
|
+
}
|
|
1190
|
+
value;
|
|
1191
|
+
};
|
|
1192
|
+
function validateCopyPath(relativePath) {
|
|
1193
|
+
if (relativePath === "" || relativePath.startsWith("/") || relativePath.includes("/../") || relativePath.startsWith("../") || relativePath === "..") {
|
|
1194
|
+
throw new UnsafeCopyPathError(relativePath);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
function patternsToPathspecs(patterns) {
|
|
1198
|
+
const specs = [];
|
|
1199
|
+
for (const p of patterns) {
|
|
1200
|
+
if (p.includes("/")) {
|
|
1201
|
+
specs.push(`:(glob)${p}`);
|
|
1202
|
+
} else {
|
|
1203
|
+
specs.push(`:(glob)**/${p}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return specs;
|
|
1207
|
+
}
|
|
1208
|
+
function copyPatternMatchesPath(relativePath, patterns) {
|
|
1209
|
+
const base = path6.basename(relativePath);
|
|
1210
|
+
for (const pattern of patterns) {
|
|
1211
|
+
validateCopyPath(pattern);
|
|
1212
|
+
if (pattern.includes("/")) {
|
|
1213
|
+
if (fnmatch(relativePath, pattern)) return true;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (fnmatch(base, pattern)) return true;
|
|
1217
|
+
if (relativePath === pattern) return true;
|
|
1218
|
+
if (relativePath.startsWith(`${pattern}/`)) return true;
|
|
1219
|
+
if (relativePath.endsWith(`/${pattern}`)) return true;
|
|
1220
|
+
if (relativePath.includes(`/${pattern}/`)) return true;
|
|
1221
|
+
}
|
|
1222
|
+
return false;
|
|
1223
|
+
}
|
|
1224
|
+
function fnmatch(input, pattern) {
|
|
1225
|
+
let regex = "^";
|
|
1226
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
1227
|
+
const ch = pattern[i];
|
|
1228
|
+
if (ch === "*") {
|
|
1229
|
+
regex += ".*";
|
|
1230
|
+
while (pattern[i + 1] === "*") i++;
|
|
1231
|
+
} else if (ch === "?") {
|
|
1232
|
+
regex += ".";
|
|
1233
|
+
} else if (ch === "[") {
|
|
1234
|
+
const close = pattern.indexOf("]", i + 1);
|
|
1235
|
+
if (close === -1) {
|
|
1236
|
+
regex += "\\[";
|
|
1237
|
+
} else {
|
|
1238
|
+
regex += pattern.slice(i, close + 1);
|
|
1239
|
+
i = close;
|
|
1240
|
+
}
|
|
1241
|
+
} else if (/[.+^${}()|\\]/.test(ch)) {
|
|
1242
|
+
regex += `\\${ch}`;
|
|
1243
|
+
} else {
|
|
1244
|
+
regex += ch;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
regex += "$";
|
|
1248
|
+
return new RegExp(regex).test(input);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/io/copy-files.ts
|
|
1252
|
+
async function copyConfiguredIgnoredPaths(options) {
|
|
1253
|
+
const { source, destination, config, console } = options;
|
|
1254
|
+
const t0Total = Date.now();
|
|
1255
|
+
console.debug(
|
|
1256
|
+
`copyConfiguredIgnoredPaths start source=${source} destination=${destination} paths=[${config.copyIgnored.paths.join(",")}] patterns=[${config.copyIgnored.patterns.join(",")}]`
|
|
1257
|
+
);
|
|
1258
|
+
let timeInIsIgnored = 0;
|
|
1259
|
+
let timeInCp = 0;
|
|
1260
|
+
let copiedCount = 0;
|
|
1261
|
+
let skippedCount = 0;
|
|
1262
|
+
for (const relative of config.copyIgnored.paths) {
|
|
1263
|
+
validateCopyPath(relative);
|
|
1264
|
+
await copyOneIgnoredPath(source, destination, relative, console, {
|
|
1265
|
+
onTimeIgnored: (ms) => {
|
|
1266
|
+
timeInIsIgnored += ms;
|
|
1267
|
+
},
|
|
1268
|
+
onTimeCp: (ms) => {
|
|
1269
|
+
timeInCp += ms;
|
|
1270
|
+
},
|
|
1271
|
+
onCopied: () => {
|
|
1272
|
+
copiedCount++;
|
|
1273
|
+
},
|
|
1274
|
+
onSkipped: () => {
|
|
1275
|
+
skippedCount++;
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
if (config.copyIgnored.patterns.length === 0) {
|
|
1280
|
+
console.debug(
|
|
1281
|
+
`copyConfiguredIgnoredPaths done (no patterns) elapsed=${Date.now() - t0Total}ms copied=${copiedCount} skipped=${skippedCount}`
|
|
1282
|
+
);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const pathspecs = patternsToPathspecs(config.copyIgnored.patterns);
|
|
1286
|
+
const t0Ls = Date.now();
|
|
1287
|
+
const ignored = await listIgnoredFilesMatching(source, pathspecs);
|
|
1288
|
+
const lsElapsed = Date.now() - t0Ls;
|
|
1289
|
+
console.debug(`git ls-files (targeted) returned ${ignored.length} entries in ${lsElapsed}ms`);
|
|
1290
|
+
const matched = ignored.filter((f) => copyPatternMatchesPath(f, config.copyIgnored.patterns));
|
|
1291
|
+
console.debug(`pattern filter: ${matched.length} of ${ignored.length} entries matched`);
|
|
1292
|
+
for (const relative of matched) {
|
|
1293
|
+
await copyOnePath(source, destination, relative, console, {
|
|
1294
|
+
onTimeCp: (ms) => {
|
|
1295
|
+
timeInCp += ms;
|
|
1296
|
+
},
|
|
1297
|
+
onCopied: () => {
|
|
1298
|
+
copiedCount++;
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
const totalElapsed = Date.now() - t0Total;
|
|
1303
|
+
console.debug(
|
|
1304
|
+
`copyConfiguredIgnoredPaths done elapsed=${totalElapsed}ms copied=${copiedCount} skipped=${skippedCount} timeInIsIgnored=${timeInIsIgnored}ms timeInCp=${timeInCp}ms other=${totalElapsed - timeInIsIgnored - timeInCp}ms`
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
async function copyOneIgnoredPath(source, destination, relative, console, timers) {
|
|
1308
|
+
validateCopyPath(relative);
|
|
1309
|
+
const sourceItem = path7.join(source, relative);
|
|
1310
|
+
const destinationItem = path7.join(destination, relative);
|
|
1311
|
+
const sourceStat = await safeStat(sourceItem);
|
|
1312
|
+
if (!sourceStat) {
|
|
1313
|
+
console.debug(`copy skip (not found): ${relative}`);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const t0Ignored = Date.now();
|
|
1317
|
+
const ignored = await isGitIgnored(source, relative);
|
|
1318
|
+
const ignoredElapsed = Date.now() - t0Ignored;
|
|
1319
|
+
timers.onTimeIgnored(ignoredElapsed);
|
|
1320
|
+
if (!ignored) {
|
|
1321
|
+
console.debug(`copy skip (not git-ignored, isGitIgnored took ${ignoredElapsed}ms): ${relative}`);
|
|
1322
|
+
timers.onSkipped();
|
|
1323
|
+
console.errln(`cdwt: copy path is not ignored by git, skipping: ${relative}`);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
const sizeInfo = sourceStat.isDirectory() ? "dir" : `${sourceStat.size}B`;
|
|
1327
|
+
console.debug(
|
|
1328
|
+
`copy ${relative} (${sizeInfo}) isGitIgnored=${ignoredElapsed}ms`
|
|
1329
|
+
);
|
|
1330
|
+
await mkdir2(path7.dirname(destinationItem), { recursive: true });
|
|
1331
|
+
const t0Cp = Date.now();
|
|
1332
|
+
await cp(sourceItem, destinationItem, {
|
|
1333
|
+
recursive: sourceStat.isDirectory(),
|
|
1334
|
+
preserveTimestamps: true,
|
|
1335
|
+
force: true
|
|
1336
|
+
});
|
|
1337
|
+
const cpElapsed = Date.now() - t0Cp;
|
|
1338
|
+
timers.onTimeCp(cpElapsed);
|
|
1339
|
+
timers.onCopied();
|
|
1340
|
+
console.debug(`copy done ${relative} cp=${cpElapsed}ms`);
|
|
1341
|
+
}
|
|
1342
|
+
async function copyOnePath(source, destination, relative, console, timers) {
|
|
1343
|
+
validateCopyPath(relative);
|
|
1344
|
+
const sourceItem = path7.join(source, relative);
|
|
1345
|
+
const destinationItem = path7.join(destination, relative);
|
|
1346
|
+
const sourceStat = await safeStat(sourceItem);
|
|
1347
|
+
if (!sourceStat) {
|
|
1348
|
+
console.debug(`copy skip (not found): ${relative}`);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const sizeInfo = sourceStat.isDirectory() ? "dir" : `${sourceStat.size}B`;
|
|
1352
|
+
console.debug(`copy ${relative} (${sizeInfo})`);
|
|
1353
|
+
await mkdir2(path7.dirname(destinationItem), { recursive: true });
|
|
1354
|
+
const t0Cp = Date.now();
|
|
1355
|
+
await cp(sourceItem, destinationItem, {
|
|
1356
|
+
recursive: sourceStat.isDirectory(),
|
|
1357
|
+
preserveTimestamps: true,
|
|
1358
|
+
force: true
|
|
1359
|
+
});
|
|
1360
|
+
const cpElapsed = Date.now() - t0Cp;
|
|
1361
|
+
timers.onTimeCp(cpElapsed);
|
|
1362
|
+
timers.onCopied();
|
|
1363
|
+
console.debug(`copy done ${relative} cp=${cpElapsed}ms`);
|
|
1364
|
+
}
|
|
1365
|
+
async function safeStat(target) {
|
|
1366
|
+
try {
|
|
1367
|
+
return await stat3(target);
|
|
1368
|
+
} catch {
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// src/commands/actions.ts
|
|
1374
|
+
var EXIT_CANCELLED = 130;
|
|
1375
|
+
var MAX_BRANCH_PROMPT_RETRIES = 5;
|
|
1376
|
+
function printDestination(console, target) {
|
|
1377
|
+
console.outln(target);
|
|
1378
|
+
}
|
|
1379
|
+
async function deleteWorktreeAction(ctx, target) {
|
|
1380
|
+
if (target === ctx.repo.mainWorktree) {
|
|
1381
|
+
throw new CdwtError("refusing to delete the default branch worktree");
|
|
1382
|
+
}
|
|
1383
|
+
if (!await ctx.console.confirm(`Delete worktree at "${target}"? [y/N] `)) {
|
|
1384
|
+
ctx.console.debug(`delete cancelled by user for ${target}`);
|
|
1385
|
+
return { kind: "cancelled" };
|
|
1386
|
+
}
|
|
1387
|
+
ctx.console.debug(`attempting git worktree remove for ${target}`);
|
|
1388
|
+
const result = await removeWorktree(ctx.repo.mainWorktree, target);
|
|
1389
|
+
ctx.console.debug(
|
|
1390
|
+
`git worktree remove exit=${result.exitCode} stderr=${result.stderr.length}B`
|
|
1391
|
+
);
|
|
1392
|
+
if (result.exitCode === 0) {
|
|
1393
|
+
ctx.console.debug(`delete succeeded (clean) for ${target}`);
|
|
1394
|
+
return { kind: "deleted" };
|
|
1395
|
+
}
|
|
1396
|
+
if (result.stderr) ctx.console.errln(result.stderr.trimEnd());
|
|
1397
|
+
ctx.console.debug(`worktree is dirty, prompting for force delete`);
|
|
1398
|
+
if (!await ctx.console.confirm(`Worktree has uncommitted changes. Force delete? [y/N] `)) {
|
|
1399
|
+
ctx.console.debug(`force delete cancelled by user for ${target}`);
|
|
1400
|
+
return { kind: "cancelled" };
|
|
1401
|
+
}
|
|
1402
|
+
ctx.console.debug(`attempting force remove for ${target}`);
|
|
1403
|
+
const force = await removeWorktreeForceRaw(ctx.repo.mainWorktree, target);
|
|
1404
|
+
ctx.console.debug(`force remove exit=${force.exitCode} stderr=${force.stderr.length}B`);
|
|
1405
|
+
if (force.exitCode !== 0) {
|
|
1406
|
+
if (force.stderr) ctx.console.errln(force.stderr.trimEnd());
|
|
1407
|
+
throw new CdwtError(
|
|
1408
|
+
`git worktree remove --force failed for ${target} (exit ${force.exitCode})`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
ctx.console.debug(`force delete succeeded for ${target}`);
|
|
1412
|
+
return { kind: "deleted" };
|
|
1413
|
+
}
|
|
1414
|
+
async function createWorktreeForBranchAction(ctx, branch, target) {
|
|
1415
|
+
await assertDestinationFree(target);
|
|
1416
|
+
await addWorktreeForBranch(ctx.repo.cwd, target, branch);
|
|
1417
|
+
await copyConfiguredIgnoredPaths({
|
|
1418
|
+
source: ctx.repo.mainWorktree,
|
|
1419
|
+
destination: target,
|
|
1420
|
+
config: ctx.config,
|
|
1421
|
+
console: ctx.console
|
|
1422
|
+
});
|
|
1423
|
+
printDestination(ctx.console, target);
|
|
1424
|
+
return 0;
|
|
1425
|
+
}
|
|
1426
|
+
async function createNewWorktreeAction(ctx, branchArg) {
|
|
1427
|
+
const ref = ctx.repo.defaultBranchRef;
|
|
1428
|
+
if (!ref) throw new CdwtError("failed to detect the default branch");
|
|
1429
|
+
const branch = branchArg !== void 0 && branchArg !== "" ? await validateNewBranch(ctx, branchArg) : await readNewBranchName(ctx);
|
|
1430
|
+
if (branch === null) return EXIT_CANCELLED;
|
|
1431
|
+
const target = makeBranchPath(branch, ctx.repo.mainWorktree, ctx.repo.repoName);
|
|
1432
|
+
await assertDestinationFree(target);
|
|
1433
|
+
await addWorktreeNewBranch(ctx.repo.cwd, target, branch, ref);
|
|
1434
|
+
await copyConfiguredIgnoredPaths({
|
|
1435
|
+
source: ctx.repo.mainWorktree,
|
|
1436
|
+
destination: target,
|
|
1437
|
+
config: ctx.config,
|
|
1438
|
+
console: ctx.console
|
|
1439
|
+
});
|
|
1440
|
+
printDestination(ctx.console, target);
|
|
1441
|
+
return 0;
|
|
1442
|
+
}
|
|
1443
|
+
async function createPrWorktreeAction(ctx, prNumber, target) {
|
|
1444
|
+
await assertDestinationFree(target);
|
|
1445
|
+
await addWorktreeDetached(ctx.repo.cwd, target);
|
|
1446
|
+
const ok = await checkoutPr(target, prNumber);
|
|
1447
|
+
if (!ok) {
|
|
1448
|
+
await removeWorktreeForce(ctx.repo.cwd, target, ctx.console);
|
|
1449
|
+
throw new CdwtError(`failed to checkout PR #${prNumber}`);
|
|
1450
|
+
}
|
|
1451
|
+
await copyConfiguredIgnoredPaths({
|
|
1452
|
+
source: ctx.repo.mainWorktree,
|
|
1453
|
+
destination: target,
|
|
1454
|
+
config: ctx.config,
|
|
1455
|
+
console: ctx.console
|
|
1456
|
+
});
|
|
1457
|
+
printDestination(ctx.console, target);
|
|
1458
|
+
return 0;
|
|
1459
|
+
}
|
|
1460
|
+
async function validateNewBranch(ctx, raw) {
|
|
1461
|
+
const branch = raw.trim();
|
|
1462
|
+
if (branch === "") return null;
|
|
1463
|
+
if (!await checkRefFormat(ctx.repo.cwd, branch)) {
|
|
1464
|
+
ctx.console.errln(`cdwt: invalid branch name: ${branch}`);
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
if (await branchExists(ctx.repo.cwd, branch)) {
|
|
1468
|
+
ctx.console.errln(`cdwt: branch already exists: ${branch}`);
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
if (ctx.branchesWithWorktree.has(branch)) {
|
|
1472
|
+
ctx.console.errln(`cdwt: branch already has a worktree: ${branch}`);
|
|
1473
|
+
return null;
|
|
1474
|
+
}
|
|
1475
|
+
return branch;
|
|
1476
|
+
}
|
|
1477
|
+
async function readNewBranchName(ctx) {
|
|
1478
|
+
for (let attempt = 0; attempt < MAX_BRANCH_PROMPT_RETRIES; attempt++) {
|
|
1479
|
+
const raw = await ctx.console.ask("New branch name: ");
|
|
1480
|
+
if (raw === null) return null;
|
|
1481
|
+
const branch = raw.trim();
|
|
1482
|
+
if (branch === "") return null;
|
|
1483
|
+
const validated = await validateNewBranch(ctx, branch);
|
|
1484
|
+
if (validated !== null) return validated;
|
|
1485
|
+
}
|
|
1486
|
+
ctx.console.errln(`cdwt: too many invalid branch name attempts; aborting`);
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
async function assertDestinationFree(target) {
|
|
1490
|
+
try {
|
|
1491
|
+
await stat4(target);
|
|
1492
|
+
} catch {
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
throw new CdwtError(`destination already exists: ${target}`);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/commands/select.ts
|
|
1499
|
+
async function runSelect(options) {
|
|
1500
|
+
const { console } = options;
|
|
1501
|
+
setGitDebug((msg) => console.debug(msg));
|
|
1502
|
+
console.debug(`runSelect start cwd=${options.cwd}`);
|
|
1503
|
+
const repo = await loadRepoContext(options.cwd);
|
|
1504
|
+
console.debug(
|
|
1505
|
+
`repo context: mainWorktree=${repo.mainWorktree} defaultBranchRef=${repo.defaultBranchRef ?? "null"} worktrees=${repo.worktrees.length}`
|
|
1506
|
+
);
|
|
1507
|
+
if (options.defaultBranchOnly) {
|
|
1508
|
+
printDestination(console, repo.mainWorktree);
|
|
1509
|
+
return 0;
|
|
1510
|
+
}
|
|
1511
|
+
const configFiles = await discoverConfigFiles({
|
|
1512
|
+
cwd: repo.cwd,
|
|
1513
|
+
mainWorktree: repo.mainWorktree,
|
|
1514
|
+
home: options.home,
|
|
1515
|
+
override: options.configOverride,
|
|
1516
|
+
debug: (msg) => console.debug(msg)
|
|
1517
|
+
});
|
|
1518
|
+
const config = await readMergedConfig(configFiles);
|
|
1519
|
+
console.debug(
|
|
1520
|
+
`merged config: patterns=[${config.copyIgnored.patterns.join(",")}] paths=[${config.copyIgnored.paths.join(",")}]`
|
|
1521
|
+
);
|
|
1522
|
+
if (options.newBranch !== void 0) {
|
|
1523
|
+
const ctx = {
|
|
1524
|
+
repo,
|
|
1525
|
+
config,
|
|
1526
|
+
branchesWithWorktree: deriveBranchesWithWorktree(repo),
|
|
1527
|
+
console
|
|
1528
|
+
};
|
|
1529
|
+
const branchArg = options.newBranch === true ? void 0 : options.newBranch;
|
|
1530
|
+
return createNewWorktreeAction(ctx, branchArg);
|
|
1531
|
+
}
|
|
1532
|
+
const localBranches = await listLocalBranches(repo.cwd);
|
|
1533
|
+
console.debug(`localBranches=${localBranches.length}`);
|
|
1534
|
+
let prs = [];
|
|
1535
|
+
let prsLoaded = false;
|
|
1536
|
+
if (options.prFilter) {
|
|
1537
|
+
prs = await loadPrs(options.cwd, console);
|
|
1538
|
+
prsLoaded = true;
|
|
1539
|
+
}
|
|
1540
|
+
const state = {
|
|
1541
|
+
repo,
|
|
1542
|
+
prs,
|
|
1543
|
+
prsLoaded,
|
|
1544
|
+
localBranches,
|
|
1545
|
+
lines: buildSections({ repo, prs, localBranches, home: options.home })
|
|
1546
|
+
};
|
|
1547
|
+
if (state.lines.length === 0) {
|
|
1548
|
+
throw new CdwtError("no worktree or branch candidates found");
|
|
1549
|
+
}
|
|
1550
|
+
let initialFilter = options.prFilter ? "pr" : void 0;
|
|
1551
|
+
while (true) {
|
|
1552
|
+
const ctx = {
|
|
1553
|
+
repo: state.repo,
|
|
1554
|
+
config,
|
|
1555
|
+
branchesWithWorktree: deriveBranchesWithWorktree(state.repo),
|
|
1556
|
+
console
|
|
1557
|
+
};
|
|
1558
|
+
const outcome = await selectInteractive(state.lines, {
|
|
1559
|
+
console,
|
|
1560
|
+
...initialFilter ? { initialFilter } : {},
|
|
1561
|
+
...options.selectorOptions
|
|
1562
|
+
});
|
|
1563
|
+
initialFilter = void 0;
|
|
1564
|
+
switch (outcome.kind) {
|
|
1565
|
+
case "cancelled":
|
|
1566
|
+
return EXIT_CANCELLED;
|
|
1567
|
+
case "command-mode": {
|
|
1568
|
+
const code = await enterCommandMode(state, ctx, options, outcome.initialInput);
|
|
1569
|
+
if (code !== void 0) return code;
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
case "delete-target": {
|
|
1573
|
+
const code = await handleDeleteTarget(state, ctx, options, outcome.line);
|
|
1574
|
+
if (code !== void 0) return code;
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
case "selected":
|
|
1578
|
+
return dispatchSelected(state, ctx, options, outcome.line);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
async function dispatchSelected(_state, ctx, options, selected) {
|
|
1583
|
+
options.console.debug(
|
|
1584
|
+
`selected: kind=${selected.kind} branch="${selected.branch}" destination="${selected.destination}"`
|
|
1585
|
+
);
|
|
1586
|
+
switch (selected.kind) {
|
|
1587
|
+
case "worktree":
|
|
1588
|
+
printDestination(options.console, selected.destination);
|
|
1589
|
+
return 0;
|
|
1590
|
+
case "branch":
|
|
1591
|
+
return createWorktreeForBranchAction(ctx, selected.branch, selected.destination);
|
|
1592
|
+
case "pr": {
|
|
1593
|
+
if (selected.prNumber === null) throw new CdwtError("missing PR number");
|
|
1594
|
+
if (ctx.branchesWithWorktree.has(selected.branch)) {
|
|
1595
|
+
printDestination(options.console, selected.destination);
|
|
1596
|
+
return 0;
|
|
1597
|
+
}
|
|
1598
|
+
return createPrWorktreeAction(ctx, selected.prNumber, selected.destination);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
async function enterCommandMode(state, ctx, options, initialInput) {
|
|
1603
|
+
const cmdOptions = {
|
|
1604
|
+
console: options.console,
|
|
1605
|
+
registry: SLASH_COMMANDS,
|
|
1606
|
+
...options.selectorOptions?.useFzf !== void 0 ? { useFzf: options.selectorOptions.useFzf } : {},
|
|
1607
|
+
...options.selectorOptions?.fzfRunner ? { fzfRunner: options.selectorOptions.fzfRunner } : {},
|
|
1608
|
+
...initialInput !== void 0 ? { initialInput } : {}
|
|
1609
|
+
};
|
|
1610
|
+
const picked = await runCommandMode(cmdOptions);
|
|
1611
|
+
if (picked.kind === "cancelled") return void 0;
|
|
1612
|
+
const host = makeCommandHost(state, ctx, options);
|
|
1613
|
+
const result = await picked.command.execute(picked.args, host);
|
|
1614
|
+
if (result.kind === "exit") return result.code;
|
|
1615
|
+
return void 0;
|
|
1616
|
+
}
|
|
1617
|
+
function makeCommandHost(state, ctx, options) {
|
|
1618
|
+
return {
|
|
1619
|
+
console: options.console,
|
|
1620
|
+
printMainDestination() {
|
|
1621
|
+
printDestination(options.console, state.repo.mainWorktree);
|
|
1622
|
+
},
|
|
1623
|
+
createNewWorktree(branch) {
|
|
1624
|
+
return createNewWorktreeAction(ctx, branch);
|
|
1625
|
+
},
|
|
1626
|
+
async loadPrs() {
|
|
1627
|
+
if (state.prsLoaded) return;
|
|
1628
|
+
state.prs = await loadPrs(options.cwd, options.console);
|
|
1629
|
+
state.prsLoaded = true;
|
|
1630
|
+
rebuildLines(state, options.home);
|
|
1631
|
+
},
|
|
1632
|
+
async refresh() {
|
|
1633
|
+
await refresh(state, options);
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
async function handleDeleteTarget(state, ctx, options, target) {
|
|
1638
|
+
if (target.section === "main") {
|
|
1639
|
+
options.console.errln("cdwt: refusing to delete the default branch worktree");
|
|
1640
|
+
return void 0;
|
|
1641
|
+
}
|
|
1642
|
+
if (target.section !== "wt") {
|
|
1643
|
+
options.console.errln(
|
|
1644
|
+
`cdwt: ctrl-d only deletes worktree entries (got [${sectionLabel(target.section)}])`
|
|
1645
|
+
);
|
|
1646
|
+
return void 0;
|
|
1647
|
+
}
|
|
1648
|
+
const result = await deleteWorktreeAction(ctx, target.destination);
|
|
1649
|
+
if (result.kind === "cancelled") return void 0;
|
|
1650
|
+
await refresh(state, options);
|
|
1651
|
+
if (state.lines.filter((l) => l.section === "wt").length === 0) {
|
|
1652
|
+
printDestination(options.console, state.repo.mainWorktree);
|
|
1653
|
+
return 0;
|
|
1654
|
+
}
|
|
1655
|
+
return void 0;
|
|
1656
|
+
}
|
|
1657
|
+
async function refresh(state, options) {
|
|
1658
|
+
state.repo = await loadRepoContext(options.cwd);
|
|
1659
|
+
state.localBranches = await listLocalBranches(state.repo.cwd);
|
|
1660
|
+
if (state.prsLoaded) {
|
|
1661
|
+
state.prs = await loadPrs(options.cwd, options.console);
|
|
1662
|
+
}
|
|
1663
|
+
rebuildLines(state, options.home);
|
|
1664
|
+
}
|
|
1665
|
+
function rebuildLines(state, home) {
|
|
1666
|
+
state.lines = buildSections({
|
|
1667
|
+
repo: state.repo,
|
|
1668
|
+
prs: state.prs,
|
|
1669
|
+
localBranches: state.localBranches,
|
|
1670
|
+
home
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
async function loadPrs(cwd, console) {
|
|
1674
|
+
const t0 = Date.now();
|
|
1675
|
+
const ghAvailable = await isGhAvailable();
|
|
1676
|
+
console.debug(`gh available=${ghAvailable}`);
|
|
1677
|
+
if (!ghAvailable) return [];
|
|
1678
|
+
const prs = await listPullRequests(cwd);
|
|
1679
|
+
console.debug(`listed ${prs.length} PRs in ${Date.now() - t0}ms`);
|
|
1680
|
+
return prs;
|
|
1681
|
+
}
|
|
1682
|
+
function defaultHome2() {
|
|
1683
|
+
return process.env["HOME"] ?? homedir2();
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// src/io/console.ts
|
|
1687
|
+
import { createInterface } from "readline/promises";
|
|
1688
|
+
var YES = /^y(es)?$/i;
|
|
1689
|
+
var _startTime = Date.now();
|
|
1690
|
+
function createDefaultConsole(streams = {}) {
|
|
1691
|
+
const stdin = streams.stdin ?? process.stdin;
|
|
1692
|
+
const stdout = streams.stdout ?? process.stdout;
|
|
1693
|
+
const stderr = streams.stderr ?? process.stderr;
|
|
1694
|
+
const isInteractive = Boolean(stdin.isTTY);
|
|
1695
|
+
const verbose = streams.verbose ?? false;
|
|
1696
|
+
const console = {
|
|
1697
|
+
out: (chunk) => stdout.write(chunk),
|
|
1698
|
+
outln: (message = "") => stdout.write(`${message}
|
|
1699
|
+
`),
|
|
1700
|
+
err: (chunk) => stderr.write(chunk),
|
|
1701
|
+
errln: (message = "") => stderr.write(`${message}
|
|
1702
|
+
`),
|
|
1703
|
+
debug(message) {
|
|
1704
|
+
if (!verbose) return;
|
|
1705
|
+
const elapsed = Date.now() - _startTime;
|
|
1706
|
+
stderr.write(`[cdwt verbose +${elapsed}ms] ${message}
|
|
1707
|
+
`);
|
|
1708
|
+
},
|
|
1709
|
+
isInteractive,
|
|
1710
|
+
async ask(prompt) {
|
|
1711
|
+
if (!isInteractive) return null;
|
|
1712
|
+
const rl = createInterface({ input: stdin, output: stderr, terminal: true });
|
|
1713
|
+
try {
|
|
1714
|
+
return await rl.question(prompt);
|
|
1715
|
+
} catch {
|
|
1716
|
+
return null;
|
|
1717
|
+
} finally {
|
|
1718
|
+
rl.close();
|
|
1719
|
+
}
|
|
1720
|
+
},
|
|
1721
|
+
async confirm(prompt) {
|
|
1722
|
+
const answer = await this.ask(prompt);
|
|
1723
|
+
if (answer === null) return false;
|
|
1724
|
+
return YES.test(answer.trim());
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
return console;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/cli.ts
|
|
1731
|
+
function buildProgram(consoleFactory) {
|
|
1732
|
+
const program = new Command();
|
|
1733
|
+
const state = { exitCode: 0 };
|
|
1734
|
+
program.name("cdwt").description(
|
|
1735
|
+
"Interactive git worktree switcher. Prints the destination path on stdout; use the bundled zsh wrapper (installed via `cdwt install`) so the shell can `cd` into it."
|
|
1736
|
+
).version("0.1.0").option("-v, --verbose", "write timestamped diagnostic logs to stderr");
|
|
1737
|
+
program.option("--default-branch", "print the path of the default branch worktree and exit").option("--pr", "open the picker pre-filtered to PRs (loads `gh pr list` immediately)").option(
|
|
1738
|
+
"--new [branch]",
|
|
1739
|
+
"create a new worktree from the default branch and cd into it (prompts for name if omitted)"
|
|
1740
|
+
).option(
|
|
1741
|
+
"--config <file>",
|
|
1742
|
+
"use only the given settings file (overrides .cdwt/settings.json discovery)"
|
|
1743
|
+
).action(async (opts) => {
|
|
1744
|
+
const verbose = Boolean(opts.verbose ?? program.opts().verbose);
|
|
1745
|
+
const console = consoleFactory(verbose);
|
|
1746
|
+
state.exitCode = await runSelect({
|
|
1747
|
+
defaultBranchOnly: Boolean(opts.defaultBranch),
|
|
1748
|
+
prFilter: Boolean(opts.pr),
|
|
1749
|
+
...opts.new !== void 0 ? { newBranch: opts.new } : {},
|
|
1750
|
+
cwd: process.cwd(),
|
|
1751
|
+
configOverride: opts.config ?? process.env["CDWT_CONFIG"],
|
|
1752
|
+
home: defaultHome2(),
|
|
1753
|
+
console
|
|
1754
|
+
});
|
|
1755
|
+
});
|
|
1756
|
+
program.command("install").description("install the zsh shell wrapper to ~/.local/share/cdwt and update ~/.zshrc").option("--rc <file>", "rc file to update (default: $HOME/.zshrc)").action(async (opts) => {
|
|
1757
|
+
const verbose = Boolean(opts.verbose ?? program.opts().verbose);
|
|
1758
|
+
const console = consoleFactory(verbose);
|
|
1759
|
+
state.exitCode = await runInstall({
|
|
1760
|
+
home: defaultHome(),
|
|
1761
|
+
rcFile: opts.rc,
|
|
1762
|
+
console
|
|
1763
|
+
});
|
|
1764
|
+
});
|
|
1765
|
+
return { program, state };
|
|
1766
|
+
}
|
|
1767
|
+
async function main() {
|
|
1768
|
+
const { program, state } = buildProgram((verbose) => createDefaultConsole({ verbose }));
|
|
1769
|
+
const errorConsole = createDefaultConsole();
|
|
1770
|
+
try {
|
|
1771
|
+
await program.parseAsync(process.argv);
|
|
1772
|
+
return state.exitCode;
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
if (error instanceof CdwtError) {
|
|
1775
|
+
errorConsole.errln(`${pc2.red("cdwt:")} ${error.message}`);
|
|
1776
|
+
return error.code;
|
|
1777
|
+
}
|
|
1778
|
+
errorConsole.errln(`${pc2.red("cdwt:")} ${error.message}`);
|
|
1779
|
+
return 1;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
var exitCode = await main();
|
|
1783
|
+
process.exit(exitCode);
|
|
1784
|
+
export {
|
|
1785
|
+
buildProgram
|
|
1786
|
+
};
|
|
1787
|
+
//# sourceMappingURL=cli.js.map
|