@solaqua/gji 0.8.0 → 0.9.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.
Files changed (53) hide show
  1. package/README.md +24 -30
  2. package/dist/browser.d.ts +7 -0
  3. package/dist/browser.js +37 -0
  4. package/dist/clean.js +34 -19
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +59 -4
  7. package/dist/doctor.d.ts +28 -0
  8. package/dist/doctor.js +454 -0
  9. package/dist/gji-bundle.mjs +2514 -378
  10. package/dist/go.d.ts +2 -1
  11. package/dist/go.js +4 -1
  12. package/dist/index.js +12 -6
  13. package/dist/init.d.ts +15 -0
  14. package/dist/init.js +232 -16
  15. package/dist/open.d.ts +2 -1
  16. package/dist/open.js +2 -1
  17. package/dist/pr-open.d.ts +19 -0
  18. package/dist/pr-open.js +290 -0
  19. package/dist/pull-requests.d.ts +33 -0
  20. package/dist/pull-requests.js +384 -0
  21. package/dist/remove.js +2 -2
  22. package/dist/repo-registry.d.ts +5 -0
  23. package/dist/repo-registry.js +87 -14
  24. package/dist/shell-completion.js +278 -10
  25. package/dist/shell-setup.d.ts +9 -0
  26. package/dist/shell-setup.js +56 -0
  27. package/dist/warp.d.ts +3 -0
  28. package/dist/warp.js +8 -3
  29. package/dist/worktree-management.d.ts +2 -1
  30. package/dist/worktree-management.js +23 -7
  31. package/dist/worktree-picker.d.ts +12 -1
  32. package/dist/worktree-picker.js +84 -6
  33. package/man/man1/gji-back.1 +1 -1
  34. package/man/man1/gji-clean.1 +1 -1
  35. package/man/man1/gji-completion.1 +1 -1
  36. package/man/man1/gji-config.1 +1 -1
  37. package/man/man1/gji-doctor.1 +19 -0
  38. package/man/man1/gji-go.1 +1 -1
  39. package/man/man1/gji-history.1 +1 -1
  40. package/man/man1/gji-init.1 +6 -3
  41. package/man/man1/gji-ls.1 +1 -1
  42. package/man/man1/gji-new.1 +1 -1
  43. package/man/man1/gji-open.1 +1 -1
  44. package/man/man1/gji-pr.1 +5 -1
  45. package/man/man1/gji-remove.1 +1 -1
  46. package/man/man1/gji-root.1 +1 -1
  47. package/man/man1/gji-run-hook.1 +1 -1
  48. package/man/man1/gji-status.1 +1 -1
  49. package/man/man1/gji-sync-files.1 +1 -1
  50. package/man/man1/gji-sync.1 +1 -1
  51. package/man/man1/gji-warp.1 +1 -1
  52. package/man/man1/gji.1 +6 -2
  53. package/package.json +1 -1
package/dist/doctor.js ADDED
@@ -0,0 +1,454 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { confirm, isCancel } from "@clack/prompts";
8
+ import { CONFIG_FILE_NAME, GLOBAL_CONFIG_FILE_PATH, KNOWN_CONFIG_KEYS, KNOWN_GLOBAL_CONFIG_KEYS, } from "./config.js";
9
+ import { EDITORS } from "./editor.js";
10
+ import { isHeadless } from "./headless.js";
11
+ import { detectRepository } from "./repo.js";
12
+ import { loadRegistry, REGISTRY_FILE_PATH, removeMissingRegistryEntries, } from "./repo-registry.js";
13
+ import { resolveSupportedShell } from "./shell.js";
14
+ import { executableExists, hasShellIntegration, resolveCompletionPath, resolveShellConfigPath, } from "./shell-setup.js";
15
+ const execFileAsync = promisify(execFile);
16
+ const MINIMUM_GIT_VERSION = { major: 2, minor: 17 };
17
+ export async function runDoctorCommand(options) {
18
+ if (options.yes && !options.fix) {
19
+ const message = "--yes requires --fix";
20
+ if (options.json) {
21
+ (options.stderr ?? options.stdout)(`${JSON.stringify({ error: message })}\n`);
22
+ }
23
+ else {
24
+ (options.stderr ?? options.stdout)(`gji doctor: ${message}\n`);
25
+ }
26
+ return 1;
27
+ }
28
+ const home = options.home ?? homedir();
29
+ const shell = resolveSupportedShell(undefined, options.shell ?? process.env.SHELL);
30
+ let inspection = await collectDoctorInspection(options.cwd, home, shell);
31
+ let fixes = [];
32
+ if (options.fix) {
33
+ fixes = buildDoctorFixes(inspection.missingRegistryPaths);
34
+ if (fixes.length > 0) {
35
+ const approval = await requestFixApproval(fixes, options);
36
+ if (approval === "apply") {
37
+ fixes = await applyDoctorFixes(fixes, home);
38
+ }
39
+ else {
40
+ fixes = fixes.map((fix) => ({
41
+ ...fix,
42
+ status: approval,
43
+ hint: approval === "pending"
44
+ ? "re-run with --yes to apply this fix without a prompt"
45
+ : "run gji doctor --fix again to review this fix",
46
+ }));
47
+ }
48
+ }
49
+ inspection = await collectDoctorInspection(options.cwd, home, shell);
50
+ }
51
+ const problems = inspection.checks.filter((check) => check.status === "fail").length;
52
+ if (options.json) {
53
+ const output = options.fix
54
+ ? { checks: inspection.checks, problems, fixes }
55
+ : { checks: inspection.checks, problems };
56
+ options.stdout(`${JSON.stringify(output)}\n`);
57
+ }
58
+ else {
59
+ options.stdout(renderDoctorChecks(inspection.checks, problems));
60
+ if (options.fix) {
61
+ options.stdout(renderDoctorFixes(fixes));
62
+ }
63
+ }
64
+ return problems > 0 ? 1 : 0;
65
+ }
66
+ async function collectDoctorInspection(cwd, home, shell) {
67
+ const repository = await detectRepositoryOrSkip(cwd);
68
+ const globalConfig = await inspectConfig(GLOBAL_CONFIG_FILE_PATH(home), "global", KNOWN_GLOBAL_CONFIG_KEYS);
69
+ const localConfig = repository
70
+ ? await inspectConfig(join(repository.repoRoot, CONFIG_FILE_NAME), "local", KNOWN_CONFIG_KEYS)
71
+ : null;
72
+ const effectiveConfig = resolveEffectiveConfig(globalConfig.config, localConfig?.config ?? {}, repository?.repoRoot, home);
73
+ const registry = await inspectRegistry(home);
74
+ return {
75
+ checks: [
76
+ await checkGitVersion(),
77
+ await checkShellIntegration(shell, home),
78
+ await checkCompletion(shell, home),
79
+ globalConfig.check,
80
+ localConfig?.check ??
81
+ skippedCheck("local-config", "local config not checked outside a Git repository"),
82
+ await checkWorktreeBase(repository, effectiveConfig, home),
83
+ registry.check,
84
+ await checkEditor(effectiveConfig),
85
+ ],
86
+ missingRegistryPaths: registry.missingPaths,
87
+ };
88
+ }
89
+ function buildDoctorFixes(missingRegistryPaths) {
90
+ if (missingRegistryPaths.length === 0)
91
+ return [];
92
+ const count = missingRegistryPaths.length;
93
+ return [
94
+ {
95
+ id: "repo-registry",
96
+ message: `remove ${count} stale ${count === 1 ? "repository entry" : "repository entries"} from the registry`,
97
+ paths: missingRegistryPaths,
98
+ status: "pending",
99
+ },
100
+ ];
101
+ }
102
+ async function requestFixApproval(fixes, options) {
103
+ if (options.yes)
104
+ return "apply";
105
+ if (!isDoctorInteractive(options))
106
+ return "pending";
107
+ const confirmed = options.confirmFixes
108
+ ? await options.confirmFixes(fixes)
109
+ : await confirm({
110
+ initialValue: true,
111
+ message: `Apply ${fixes.length} automatic ${fixes.length === 1 ? "fix" : "fixes"} (${fixes.flatMap((fix) => fix.paths ?? []).join(", ")})?`,
112
+ });
113
+ if (isCancel(confirmed) || !confirmed)
114
+ return "declined";
115
+ return "apply";
116
+ }
117
+ function isDoctorInteractive(options) {
118
+ if (options.json || isHeadless())
119
+ return false;
120
+ if (options.interactive !== undefined)
121
+ return options.interactive;
122
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
123
+ }
124
+ async function applyDoctorFixes(fixes, home) {
125
+ return Promise.all(fixes.map(async (fix) => {
126
+ try {
127
+ const result = await removeMissingRegistryEntries(new Set(fix.paths ?? []), home);
128
+ const removedCount = result.removedPaths.length;
129
+ const skippedCount = result.skippedPaths.length;
130
+ return {
131
+ ...fix,
132
+ message: removedCount === 0
133
+ ? "no stale repository entries were removed"
134
+ : `removed ${removedCount} stale ${removedCount === 1 ? "repository entry" : "repository entries"} from the registry`,
135
+ status: removedCount === 0 ? "skipped" : "applied",
136
+ hint: skippedCount > 0
137
+ ? `${skippedCount} path(s) were no longer confirmed missing`
138
+ : undefined,
139
+ };
140
+ }
141
+ catch (error) {
142
+ return {
143
+ ...fix,
144
+ status: "failed",
145
+ hint: error instanceof Error ? error.message : String(error),
146
+ };
147
+ }
148
+ }));
149
+ }
150
+ async function detectRepositoryOrSkip(cwd) {
151
+ try {
152
+ return await detectRepository(cwd);
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ async function inspectConfig(path, label, knownKeys) {
159
+ try {
160
+ const contents = await readFile(path, "utf8");
161
+ const value = JSON.parse(contents);
162
+ if (!isConfigObject(value)) {
163
+ return {
164
+ check: failedCheck(`${label}-config`, `${label} config must contain a JSON object (${path})`, "replace the file contents with a JSON object"),
165
+ config: {},
166
+ };
167
+ }
168
+ const unknownKeys = Object.keys(value).filter((key) => !knownKeys.has(key));
169
+ const warning = unknownKeys.length > 0
170
+ ? `; warning: unknown ${unknownKeys.length === 1 ? "key" : "keys"} ${unknownKeys.map((key) => `"${key}"`).join(", ")}`
171
+ : "";
172
+ return {
173
+ check: okCheck(`${label}-config`, `${label} config valid (${path}${warning})`),
174
+ config: value,
175
+ };
176
+ }
177
+ catch (error) {
178
+ if (isMissingFileError(error)) {
179
+ return {
180
+ check: okCheck(`${label}-config`, `${label} config not found (optional)`),
181
+ config: {},
182
+ };
183
+ }
184
+ return {
185
+ check: failedCheck(`${label}-config`, `${label} config is invalid (${path})`, "fix the JSON syntax and run gji doctor again"),
186
+ config: {},
187
+ };
188
+ }
189
+ }
190
+ function isConfigObject(value) {
191
+ return typeof value === "object" && value !== null && !Array.isArray(value);
192
+ }
193
+ function isMissingFileError(error) {
194
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
195
+ }
196
+ function resolveEffectiveConfig(globalConfig, localConfig, repoRoot, home) {
197
+ const globalBase = { ...globalConfig };
198
+ const repos = globalBase.repos;
199
+ delete globalBase.repos;
200
+ return {
201
+ ...globalBase,
202
+ ...resolvePerRepoConfig(repos, repoRoot, home),
203
+ ...localConfig,
204
+ };
205
+ }
206
+ function resolvePerRepoConfig(repos, repoRoot, home) {
207
+ if (!repoRoot || !isConfigObject(repos)) {
208
+ return {};
209
+ }
210
+ for (const [path, config] of Object.entries(repos)) {
211
+ if (expandTilde(path, home) === repoRoot && isConfigObject(config)) {
212
+ return config;
213
+ }
214
+ }
215
+ return {};
216
+ }
217
+ function expandTilde(path, home) {
218
+ if (path === "~")
219
+ return home;
220
+ if (path.startsWith("~/"))
221
+ return join(home, path.slice(2));
222
+ return path;
223
+ }
224
+ async function checkGitVersion() {
225
+ try {
226
+ const { stdout } = await execFileAsync("git", ["--version"]);
227
+ const version = parseGitVersion(stdout);
228
+ if (!version) {
229
+ return failedCheck("git-version", "could not parse the installed Git version", "install Git 2.17 or newer");
230
+ }
231
+ if (isGitVersionSupported(version)) {
232
+ return okCheck("git-version", `git ${version.raw}`);
233
+ }
234
+ return failedCheck("git-version", `git ${version.raw} is too old (requires Git 2.17 or newer)`, "upgrade Git and run gji doctor again");
235
+ }
236
+ catch {
237
+ return failedCheck("git-version", "git is not available on PATH", "install Git 2.17 or newer");
238
+ }
239
+ }
240
+ function parseGitVersion(output) {
241
+ const match = /(?:^|\s)(\d+)\.(\d+)(?:\.(\d+))?/.exec(output);
242
+ if (!match)
243
+ return null;
244
+ return {
245
+ major: Number(match[1]),
246
+ minor: Number(match[2]),
247
+ raw: match.slice(1).filter(Boolean).join("."),
248
+ };
249
+ }
250
+ function isGitVersionSupported(version) {
251
+ return (version.major > MINIMUM_GIT_VERSION.major ||
252
+ (version.major === MINIMUM_GIT_VERSION.major &&
253
+ version.minor >= MINIMUM_GIT_VERSION.minor));
254
+ }
255
+ async function checkShellIntegration(shell, home) {
256
+ if (!shell) {
257
+ return skippedCheck("shell-integration", "shell integration not checked (unable to detect a supported shell)");
258
+ }
259
+ const rcPath = resolveShellConfigPath(shell, home);
260
+ try {
261
+ const contents = await readFile(rcPath, "utf8");
262
+ if (hasShellIntegration(contents, shell)) {
263
+ return okCheck("shell-integration", `${shell} integration found in ${rcPath}`);
264
+ }
265
+ }
266
+ catch (error) {
267
+ if (isMissingFileError(error)) {
268
+ return failedCheck("shell-integration", `shell integration not found because ${rcPath} does not exist`, `create ${rcPath}, then add: eval "$(gji init ${shell})"`);
269
+ }
270
+ return failedCheck("shell-integration", `could not read ${rcPath}`, `add: eval "$(gji init ${shell})"`);
271
+ }
272
+ return failedCheck("shell-integration", `shell integration not found in ${rcPath}`, `add: eval "$(gji init ${shell})"`);
273
+ }
274
+ async function checkCompletion(shell, home) {
275
+ if (!shell) {
276
+ return skippedCheck("completion", "shell completion not checked (unable to detect a supported shell)");
277
+ }
278
+ const path = resolveCompletionPath(shell, home);
279
+ if (await pathExists(path)) {
280
+ return okCheck("completion", `${shell} completion installed (${path})`);
281
+ }
282
+ return skippedCheck("completion", `${shell} completion not installed (optional)`, `run: gji completion ${shell} > ${path}`);
283
+ }
284
+ async function checkWorktreeBase(repository, config, home) {
285
+ if (!repository) {
286
+ return skippedCheck("worktree-base", "worktree base not checked outside a Git repository");
287
+ }
288
+ const basePath = resolveWorktreeBase(repository.repoRoot, config, home);
289
+ const writablePath = await findNearestExistingPath(basePath);
290
+ if (!writablePath) {
291
+ return failedCheck("worktree-base", `worktree base cannot be created (${basePath})`, "create a writable parent directory or update worktreePath");
292
+ }
293
+ try {
294
+ await access(writablePath, constants.W_OK | constants.X_OK);
295
+ return okCheck("worktree-base", `worktree base writable (${basePath})`);
296
+ }
297
+ catch {
298
+ return failedCheck("worktree-base", `worktree base is not writable (${writablePath})`, "update worktreePath to use a writable directory");
299
+ }
300
+ }
301
+ function resolveWorktreeBase(repoRoot, config, home) {
302
+ const configuredPath = config.worktreePath;
303
+ if (typeof configuredPath === "string" &&
304
+ (configuredPath.startsWith("/") || configuredPath.startsWith("~"))) {
305
+ return expandTilde(configuredPath, home);
306
+ }
307
+ return join(dirname(repoRoot), "worktrees", basename(repoRoot));
308
+ }
309
+ async function findNearestExistingPath(path) {
310
+ let candidate = path;
311
+ while (true) {
312
+ try {
313
+ await access(candidate, constants.F_OK);
314
+ return candidate;
315
+ }
316
+ catch {
317
+ const parent = dirname(candidate);
318
+ if (parent === candidate)
319
+ return null;
320
+ candidate = parent;
321
+ }
322
+ }
323
+ }
324
+ async function inspectRegistry(home) {
325
+ const entries = await loadRegistry(home);
326
+ const missingEntries = await Promise.all(entries.map(async (entry) => ({
327
+ entry,
328
+ status: await inspectRegistryPath(entry.path),
329
+ })));
330
+ const missingCount = missingEntries.filter(({ status }) => status === "missing").length;
331
+ const unreadableCount = missingEntries.filter(({ status }) => status === "unreadable").length;
332
+ const missingPaths = missingEntries
333
+ .filter(({ status }) => status === "missing")
334
+ .map(({ entry }) => entry.path);
335
+ if (missingCount === 0 && unreadableCount === 0) {
336
+ return {
337
+ check: okCheck("repo-registry", `${entries.length} repos registered, all reachable`),
338
+ missingPaths,
339
+ };
340
+ }
341
+ const messageParts = [];
342
+ if (missingCount > 0) {
343
+ messageParts.push(`${missingCount} ${missingCount === 1 ? "path is" : "paths are"} missing`);
344
+ }
345
+ if (unreadableCount > 0) {
346
+ messageParts.push(`${unreadableCount} ${unreadableCount === 1 ? "path is" : "paths are"} not accessible`);
347
+ }
348
+ return {
349
+ check: failedCheck("repo-registry", `${entries.length} repos registered, ${messageParts.join(", ")}`, missingCount > 0
350
+ ? `remove confirmed stale entries from ${REGISTRY_FILE_PATH(home)}; check permissions for inaccessible paths`
351
+ : "check permissions for inaccessible paths before removing registry entries"),
352
+ missingPaths,
353
+ };
354
+ }
355
+ async function inspectRegistryPath(path) {
356
+ try {
357
+ await access(path, constants.F_OK);
358
+ return "exists";
359
+ }
360
+ catch (error) {
361
+ if (isMissingPathError(error))
362
+ return "missing";
363
+ return "unreadable";
364
+ }
365
+ }
366
+ function isMissingPathError(error) {
367
+ if (!(error instanceof Error) || !("code" in error))
368
+ return false;
369
+ const code = error.code;
370
+ return code === "ENOENT" || code === "ENOTDIR";
371
+ }
372
+ async function pathExists(path) {
373
+ try {
374
+ await access(path, constants.F_OK);
375
+ return true;
376
+ }
377
+ catch {
378
+ return false;
379
+ }
380
+ }
381
+ async function checkEditor(config) {
382
+ const editor = config.editor;
383
+ if (typeof editor !== "string" || editor.length === 0) {
384
+ return skippedCheck("editor", "editor not configured (optional)");
385
+ }
386
+ if (await executableExists(editor)) {
387
+ return okCheck("editor", `editor "${editor}" found on PATH`);
388
+ }
389
+ const knownEditor = EDITORS.some(({ cli }) => cli === editor);
390
+ return failedCheck("editor", `editor "${editor}" was not found on PATH`, knownEditor
391
+ ? `install ${editor} or choose another editor with: gji open --save`
392
+ : "choose another editor with: gji open --save");
393
+ }
394
+ function okCheck(id, message) {
395
+ return { id, message, status: "ok" };
396
+ }
397
+ function failedCheck(id, message, hint) {
398
+ return { hint, id, message, status: "fail" };
399
+ }
400
+ function skippedCheck(id, message, hint) {
401
+ return { hint, id, message, status: "skip" };
402
+ }
403
+ function renderDoctorChecks(checks, problems) {
404
+ const lines = ["gji doctor", ""];
405
+ for (const check of checks) {
406
+ lines.push(` ${statusSymbol(check.status)} ${check.message}`);
407
+ if (check.hint)
408
+ lines.push(` ${check.hint}`);
409
+ }
410
+ lines.push("", `${problems} ${problems === 1 ? "problem" : "problems"} found.`);
411
+ return `${lines.join("\n")}\n`;
412
+ }
413
+ function renderDoctorFixes(fixes) {
414
+ const lines = ["", "Automatic fixes:"];
415
+ if (fixes.length === 0) {
416
+ lines.push(" No automatic fixes available.");
417
+ }
418
+ else {
419
+ for (const fix of fixes) {
420
+ lines.push(` ${fixStatusSymbol(fix.status)} ${fix.message}`);
421
+ if (fix.paths && fix.paths.length > 0) {
422
+ for (const path of fix.paths)
423
+ lines.push(` ${path}`);
424
+ }
425
+ if (fix.hint)
426
+ lines.push(` ${fix.hint}`);
427
+ }
428
+ }
429
+ return `${lines.join("\n")}\n`;
430
+ }
431
+ function fixStatusSymbol(status) {
432
+ switch (status) {
433
+ case "applied":
434
+ return "✓";
435
+ case "declined":
436
+ return "-";
437
+ case "failed":
438
+ return "✗";
439
+ case "pending":
440
+ return "!";
441
+ case "skipped":
442
+ return "-";
443
+ }
444
+ }
445
+ function statusSymbol(status) {
446
+ switch (status) {
447
+ case "fail":
448
+ return "✗";
449
+ case "ok":
450
+ return "✓";
451
+ case "skip":
452
+ return "-";
453
+ }
454
+ }