@solaqua/gji 0.9.0 → 0.11.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 (100) hide show
  1. package/README.md +93 -20
  2. package/dist/back.d.ts +3 -0
  3. package/dist/back.js +19 -7
  4. package/dist/bootstrap-output.d.ts +3 -0
  5. package/dist/bootstrap-output.js +27 -0
  6. package/dist/bootstrap-preview.d.ts +10 -0
  7. package/dist/bootstrap-preview.js +21 -0
  8. package/dist/clean.js +25 -3
  9. package/dist/cli.js +105 -19
  10. package/dist/clone-failure-store.d.ts +18 -0
  11. package/dist/clone-failure-store.js +230 -0
  12. package/dist/command-runner.d.ts +6 -0
  13. package/dist/command-runner.js +38 -0
  14. package/dist/completion.js +7 -2
  15. package/dist/config-command.d.ts +1 -0
  16. package/dist/config-command.js +7 -1
  17. package/dist/config.d.ts +26 -1
  18. package/dist/config.js +110 -6
  19. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  20. package/dist/dependency-bootstrap-prompt.js +103 -0
  21. package/dist/dependency-bootstrap.d.ts +129 -0
  22. package/dist/dependency-bootstrap.js +566 -0
  23. package/dist/dir-clone.d.ts +33 -0
  24. package/dist/dir-clone.js +505 -0
  25. package/dist/done.d.ts +10 -0
  26. package/dist/done.js +208 -0
  27. package/dist/file-sync.js +71 -22
  28. package/dist/format-bytes.d.ts +1 -0
  29. package/dist/format-bytes.js +14 -0
  30. package/dist/fs-utils.d.ts +4 -0
  31. package/dist/fs-utils.js +26 -0
  32. package/dist/git.d.ts +5 -0
  33. package/dist/git.js +50 -0
  34. package/dist/gji-bundle.mjs +5489 -1206
  35. package/dist/go-resolver.d.ts +35 -0
  36. package/dist/go-resolver.js +185 -0
  37. package/dist/go.d.ts +5 -2
  38. package/dist/go.js +262 -62
  39. package/dist/hooks.d.ts +1 -1
  40. package/dist/hooks.js +13 -7
  41. package/dist/init.js +30 -22
  42. package/dist/install-prompt.d.ts +1 -0
  43. package/dist/install-prompt.js +3 -25
  44. package/dist/navigation-output.d.ts +11 -0
  45. package/dist/navigation-output.js +6 -0
  46. package/dist/new.d.ts +14 -2
  47. package/dist/new.js +434 -36
  48. package/dist/open.d.ts +1 -0
  49. package/dist/open.js +14 -2
  50. package/dist/pr.d.ts +6 -2
  51. package/dist/pr.js +135 -30
  52. package/dist/remove.js +20 -0
  53. package/dist/repo.d.ts +3 -0
  54. package/dist/repo.js +57 -6
  55. package/dist/safe-destination.d.ts +15 -0
  56. package/dist/safe-destination.js +166 -0
  57. package/dist/shell-completion.js +47 -14
  58. package/dist/sync-directories.d.ts +33 -0
  59. package/dist/sync-directories.js +97 -0
  60. package/dist/sync-plan.d.ts +16 -0
  61. package/dist/sync-plan.js +128 -0
  62. package/dist/sync.js +7 -13
  63. package/dist/undo.d.ts +36 -0
  64. package/dist/undo.js +392 -0
  65. package/dist/warp.d.ts +3 -1
  66. package/dist/warp.js +32 -125
  67. package/dist/worktree-bootstrap.d.ts +35 -0
  68. package/dist/worktree-bootstrap.js +93 -0
  69. package/dist/worktree-management.d.ts +1 -0
  70. package/dist/worktree-management.js +23 -4
  71. package/dist/worktree-picker.d.ts +21 -8
  72. package/dist/worktree-picker.js +107 -15
  73. package/dist/worktree-prompts.js +1 -1
  74. package/dist/worktree-source.d.ts +6 -0
  75. package/dist/worktree-source.js +1 -0
  76. package/dist/worktree-sources.d.ts +3 -0
  77. package/dist/worktree-sources.js +31 -0
  78. package/man/man1/gji-back.1 +1 -1
  79. package/man/man1/gji-clean.1 +1 -1
  80. package/man/man1/gji-completion.1 +1 -1
  81. package/man/man1/gji-config.1 +1 -1
  82. package/man/man1/gji-doctor.1 +1 -1
  83. package/man/man1/gji-done.1 +19 -0
  84. package/man/man1/gji-go.1 +9 -3
  85. package/man/man1/gji-history.1 +1 -1
  86. package/man/man1/gji-init.1 +1 -1
  87. package/man/man1/gji-ls.1 +1 -1
  88. package/man/man1/gji-new.1 +21 -3
  89. package/man/man1/gji-open.1 +4 -1
  90. package/man/man1/gji-pr.1 +7 -1
  91. package/man/man1/gji-remove.1 +3 -3
  92. package/man/man1/gji-root.1 +1 -1
  93. package/man/man1/gji-run-hook.1 +1 -1
  94. package/man/man1/gji-status.1 +1 -1
  95. package/man/man1/gji-sync-files.1 +1 -1
  96. package/man/man1/gji-sync.1 +1 -1
  97. package/man/man1/gji-undo.1 +16 -0
  98. package/man/man1/gji-warp.1 +3 -6
  99. package/man/man1/gji.1 +13 -5
  100. package/package.json +3 -1
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { runCleanCommand } from "./clean.js";
6
6
  import { runCompletionCommand } from "./completion.js";
7
7
  import { runConfigCommand } from "./config-command.js";
8
8
  import { runDoctorCommand } from "./doctor.js";
9
+ import { runDoneCommand } from "./done.js";
9
10
  import { runGoCommand } from "./go.js";
10
11
  import { isHeadless } from "./headless.js";
11
12
  import { runHistoryCommand } from "./history-command.js";
@@ -23,6 +24,7 @@ import { runHookCommand } from "./run-hook.js";
23
24
  import { runStatusCommand } from "./status.js";
24
25
  import { runSyncCommand } from "./sync.js";
25
26
  import { runSyncFilesCommand } from "./sync-files-command.js";
27
+ import { runUndoCommand } from "./undo.js";
26
28
  import { runWarpCommand } from "./warp.js";
27
29
  export function createProgram() {
28
30
  const program = new Command();
@@ -34,6 +36,11 @@ export function createProgram() {
34
36
  .showHelpAfterError()
35
37
  .showSuggestionAfterError();
36
38
  registerCommands(program);
39
+ program.addHelpText("after", "\nCommon workflows:\n" +
40
+ " gji new <branch> create a worktree\n" +
41
+ " gji go <branch> navigate to a worktree\n" +
42
+ " gji go choose; press Tab for all repositories\n" +
43
+ " gji go - return to the previous worktree\n");
37
44
  return program;
38
45
  }
39
46
  function readPackageMetadata() {
@@ -46,13 +53,13 @@ function readPackageMetadata() {
46
53
  }
47
54
  export async function runCli(argv, options = {}) {
48
55
  await maybeNotifyForUpdates(argv);
49
- if (argv[0] !== "doctor") {
50
- maybeRegisterCurrentRepo(options.cwd ?? process.cwd());
51
- }
52
- const program = createProgram();
53
56
  const cwd = options.cwd ?? process.cwd();
54
57
  const stdout = options.stdout ?? (() => undefined);
55
58
  const stderr = options.stderr ?? (() => undefined);
59
+ if (shouldRegisterCurrentRepo(argv)) {
60
+ await maybeRegisterCurrentRepo(cwd);
61
+ }
62
+ const program = createProgram();
56
63
  program.configureOutput({
57
64
  writeErr: stderr,
58
65
  writeOut: stdout,
@@ -109,22 +116,58 @@ function defaultNotifyForUpdates(pkg) {
109
116
  const notifier = updateNotifier({ pkg });
110
117
  notifier.notify();
111
118
  }
112
- function maybeRegisterCurrentRepo(cwd) {
113
- detectRepository(cwd)
114
- .then(({ repoRoot }) => registerRepo(repoRoot))
115
- .catch(() => undefined);
119
+ function shouldRegisterCurrentRepo(argv) {
120
+ const command = argv[0];
121
+ return (argv.length > 0 &&
122
+ ![
123
+ "--help",
124
+ "-h",
125
+ "--version",
126
+ "-V",
127
+ "completion",
128
+ "config",
129
+ "doctor",
130
+ "help",
131
+ "init",
132
+ ].includes(command ?? ""));
133
+ }
134
+ async function maybeRegisterCurrentRepo(cwd) {
135
+ try {
136
+ const { repoRoot } = await detectRepository(cwd);
137
+ await registerRepo(repoRoot);
138
+ }
139
+ catch {
140
+ // Registration is best effort; command behaviour should not depend on it.
141
+ }
116
142
  }
117
143
  function registerCommands(program) {
118
144
  program
119
145
  .command("new [branch]")
120
- .description("create a new branch or detached linked worktree")
146
+ .description("create a new branch or detached linked worktree and CoW-bootstrap configured directories")
121
147
  .option("-f, --force", "remove and recreate the worktree if the target path already exists")
122
148
  .option("--detached", "create a detached worktree without a branch")
149
+ .option("--from-current", "base the new branch on the current worktree instead of the main worktree (cannot be combined with --detached)")
150
+ .option("--no-fetch", "skip refreshing the remote default branch before creating the new branch")
151
+ .option("--take", "move current uncommitted changes into the new worktree")
152
+ .option("--copy", "copy current uncommitted changes instead of moving them (requires --take)")
123
153
  .option("--open", "open the new worktree in an editor after creation")
124
154
  .option("--editor <cli>", "editor CLI to use with --open (code, cursor, zed, …)")
125
155
  .option("--dry-run", "show what would be created without executing any git commands or writing files")
126
156
  .option("--json", "emit JSON on success or error instead of human-readable output")
127
157
  .action(notImplemented("new"));
158
+ program
159
+ .command("done [branch]")
160
+ .description("finish the current linked worktree and return safely")
161
+ .option("--force", "remove dirty or unmerged worktrees without prompting")
162
+ .option("--keep-branch", "remove the worktree but preserve its branch")
163
+ .option("--json", "emit JSON on success or error instead of human-readable output")
164
+ .action(notImplemented("done"));
165
+ program
166
+ .command("undo [id]")
167
+ .description("restore the most recent destructive worktree operation")
168
+ .option("--list", "list recoverable operations")
169
+ .option("--json", "emit JSON on success or error instead of human-readable output")
170
+ .action(notImplemented("undo"));
128
171
  program
129
172
  .command("init [shell]")
130
173
  .description("set up shell integration interactively or print a shell wrapper")
@@ -167,6 +210,7 @@ function registerCommands(program) {
167
210
  program
168
211
  .command("open [branch]")
169
212
  .description("open the worktree in an editor")
213
+ .option("--select", "choose a worktree with the interactive selector")
170
214
  .option("--editor <cli>", "editor CLI to use (code, cursor, zed, windsurf, subl, …)")
171
215
  .option("--save", "save the chosen editor to global config")
172
216
  .option("--workspace", "generate a .code-workspace file before opening (VS Code / Cursor / Windsurf)")
@@ -174,8 +218,10 @@ function registerCommands(program) {
174
218
  program
175
219
  .command("go [branch]")
176
220
  .alias("jump")
177
- .description("print or select a worktree path")
221
+ .description("resolve and jump to a worktree path")
222
+ .option("--root", "navigate to the main repository root")
178
223
  .option("--print", "print the resolved worktree path explicitly")
224
+ .option("--json", "emit JSON for an existing worktree destination")
179
225
  .action(notImplemented("go"));
180
226
  program
181
227
  .command("root")
@@ -231,7 +277,7 @@ function registerCommands(program) {
231
277
  program
232
278
  .command("remove [branch]")
233
279
  .alias("rm")
234
- .description("remove a linked worktree and delete its branch when present")
280
+ .description("deprecated: use gji done for one worktree or gji clean for bulk cleanup")
235
281
  .option("-f, --force", "bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch")
236
282
  .option("--dry-run", "show what would be deleted without removing anything")
237
283
  .option("--json", "emit JSON on success or error instead of human-readable output")
@@ -243,8 +289,7 @@ function registerCommands(program) {
243
289
  .action(notImplemented("run-hook"));
244
290
  program
245
291
  .command("warp [branch]")
246
- .description("jump to any worktree across all known repos")
247
- .option("-n, --new [branch]", "create a new worktree in a registered repo")
292
+ .description("deprecated: use gji go (press Tab for all known repos)")
248
293
  // --print is the shell-wrapper bypass signal (see SHELL_WRAPPED_COMMANDS in init.ts).
249
294
  // The shell omits GJI_WARP_OUTPUT_FILE, so writeShellOutput falls through to stdout.
250
295
  .option("--print", "print the resolved worktree path without changing directory")
@@ -274,17 +319,50 @@ function attachCommandActions(program, options) {
274
319
  const exitCode = await runNewCommand({
275
320
  ...options,
276
321
  branch,
322
+ copy: commandOptions.copy,
277
323
  detached: commandOptions.detached,
278
324
  dryRun: commandOptions.dryRun,
279
325
  editor: commandOptions.editor,
326
+ fromCurrent: commandOptions.fromCurrent,
280
327
  force: commandOptions.force,
328
+ noFetch: commandOptions.fetch === false,
281
329
  json: commandOptions.json,
282
330
  open: commandOptions.open,
331
+ take: commandOptions.take,
283
332
  });
284
333
  if (exitCode !== 0) {
285
334
  throw commanderExit(exitCode);
286
335
  }
287
336
  });
337
+ program.commands
338
+ .find((command) => command.name() === "done")
339
+ ?.action(async (branch, commandOptions) => {
340
+ const exitCode = await runDoneCommand({
341
+ branch,
342
+ cwd: options.cwd,
343
+ force: commandOptions.force,
344
+ json: commandOptions.json,
345
+ keepBranch: commandOptions.keepBranch,
346
+ stderr: options.stderr,
347
+ stdout: options.stdout,
348
+ });
349
+ if (exitCode !== 0)
350
+ throw commanderExit(exitCode);
351
+ });
352
+ program.commands
353
+ .find((command) => command.name() === "undo")
354
+ ?.action(async (id, commandOptions) => {
355
+ const exitCode = await runUndoCommand({
356
+ cwd: options.cwd,
357
+ id,
358
+ json: commandOptions.json,
359
+ list: commandOptions.list,
360
+ stderr: options.stderr,
361
+ stdout: options.stdout,
362
+ });
363
+ if (exitCode !== 0)
364
+ throw commanderExit(exitCode);
365
+ });
288
366
  program.commands
289
367
  .find((command) => command.name() === "init")
290
368
  ?.action(async (shell, commandOptions) => {
@@ -397,6 +475,7 @@ function attachCommandActions(program, options) {
397
475
  cwd: options.cwd,
398
476
  editor: commandOptions.editor,
399
477
  save: commandOptions.save,
478
+ select: commandOptions.select,
400
479
  stderr: options.stderr,
401
480
  stdout: options.stdout,
402
481
  workspace: commandOptions.workspace,
@@ -411,7 +490,9 @@ function attachCommandActions(program, options) {
411
490
  const exitCode = await runGoCommand({
412
491
  branch,
413
492
  cwd: options.cwd,
493
+ json: commandOptions.json,
414
494
  print: commandOptions.print,
495
+ root: commandOptions.root,
415
496
  stderr: options.stderr,
416
497
  stdout: options.stdout,
417
498
  });
@@ -560,7 +641,12 @@ function attachCommandActions(program, options) {
560
641
  };
561
642
  program.commands
562
643
  .find((command) => command.name() === "remove")
563
- ?.action(runRemovalCommand);
644
+ ?.action(async (branch, commandOptions) => {
645
+ if (!commandOptions.json) {
646
+ options.stderr("gji remove is deprecated; use 'gji done' for one worktree or 'gji clean' for bulk cleanup.\n");
647
+ }
648
+ return runRemovalCommand(branch, commandOptions);
649
+ });
564
650
  program.commands
565
651
  .find((command) => command.name() === "run-hook")
566
652
  ?.action(async (hook) => {
@@ -576,14 +662,13 @@ function attachCommandActions(program, options) {
576
662
  program.commands
577
663
  .find((command) => command.name() === "warp")
578
664
  ?.action(async (branch, commandOptions) => {
579
- const newFlag = commandOptions.new;
580
- const newWorktree = newFlag !== undefined && newFlag !== false;
581
- const newBranch = typeof newFlag === "string" ? newFlag : undefined;
665
+ if (!commandOptions.json) {
666
+ options.stderr("gji warp is deprecated; use 'gji go' (press Tab for all repositories).\n");
667
+ }
582
668
  const exitCode = await runWarpCommand({
583
- branch: newWorktree ? (newBranch ?? branch) : branch,
669
+ branch,
584
670
  cwd: options.cwd,
585
671
  json: commandOptions.json,
586
- newWorktree,
587
672
  stderr: options.stderr,
588
673
  stdout: options.stdout,
589
674
  });
@@ -621,6 +706,7 @@ function attachCommandActions(program, options) {
621
706
  action: "set",
622
707
  cwd: options.cwd,
623
708
  key,
709
+ stderr: options.stderr,
624
710
  stdout: options.stdout,
625
711
  value,
626
712
  });
@@ -0,0 +1,18 @@
1
+ export interface CloneFailureStore {
2
+ isCached(repoRoot: string, directory: string, scope?: string): Promise<boolean>;
3
+ cache(repoRoot: string, directory: string, reason: string, scope?: string): Promise<void>;
4
+ clear(repoRoot: string, directory: string, scope?: string): Promise<void>;
5
+ }
6
+ export declare class FileCloneFailureStore implements CloneFailureStore {
7
+ private updateQueue;
8
+ isCached(repoRoot: string, directory: string, scope?: string): Promise<boolean>;
9
+ cache(repoRoot: string, directory: string, reason: string, scope?: string): Promise<void>;
10
+ clear(repoRoot: string, directory: string, scope?: string): Promise<void>;
11
+ private update;
12
+ private withStateLock;
13
+ private readState;
14
+ private writeState;
15
+ private stateFilePath;
16
+ }
17
+ export declare const defaultCloneFailureStore: CloneFailureStore;
18
+ export declare function cloneFailureScope(source: string, destination: string): Promise<string>;
@@ -0,0 +1,230 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, mkdtemp, readdir, readFile, rename, rm, rmdir, stat, unlink, utimes, writeFile, } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
6
+ const STATE_FILE_NAME = "state.json";
7
+ const STATE_LOCK_SUFFIX = ".lock";
8
+ const CLONE_FAILURE_TTL_MS = 24 * 60 * 60 * 1000;
9
+ const STATE_LOCK_TTL_MS = 30 * 1000;
10
+ export class FileCloneFailureStore {
11
+ updateQueue = Promise.resolve();
12
+ async isCached(repoRoot, directory, scope) {
13
+ const state = await this.readState();
14
+ const repoState = state.syncDirs?.[repoRoot];
15
+ const key = failureKey(directory, scope);
16
+ if (!repoState || !Object.hasOwn(repoState, key))
17
+ return false;
18
+ const failure = repoState[key];
19
+ return (isPlainObject(failure) &&
20
+ typeof failure.failedAt === "number" &&
21
+ Date.now() - failure.failedAt < CLONE_FAILURE_TTL_MS);
22
+ }
23
+ async cache(repoRoot, directory, reason, scope) {
24
+ await this.update(async () => {
25
+ const state = await this.readState();
26
+ const syncDirs = state.syncDirs ?? {};
27
+ const repoState = syncDirs[repoRoot] ?? {};
28
+ const key = failureKey(directory, scope);
29
+ syncDirs[repoRoot] = {
30
+ ...repoState,
31
+ [key]: { failedAt: Date.now(), reason },
32
+ };
33
+ await this.writeState({ ...state, syncDirs });
34
+ });
35
+ }
36
+ async clear(repoRoot, directory, scope) {
37
+ await this.update(async () => {
38
+ const state = await this.readState();
39
+ const repoState = state.syncDirs?.[repoRoot];
40
+ const key = failureKey(directory, scope);
41
+ if (!repoState || !Object.hasOwn(repoState, key))
42
+ return;
43
+ const nextRepoState = { ...repoState };
44
+ delete nextRepoState[key];
45
+ const syncDirs = { ...state.syncDirs };
46
+ if (Object.keys(nextRepoState).length === 0)
47
+ delete syncDirs[repoRoot];
48
+ else
49
+ syncDirs[repoRoot] = nextRepoState;
50
+ await this.writeState({ ...state, syncDirs });
51
+ });
52
+ }
53
+ async update(operation) {
54
+ const next = this.updateQueue.then(() => this.withStateLock(operation), () => this.withStateLock(operation));
55
+ this.updateQueue = next.then(() => undefined, () => undefined);
56
+ await next;
57
+ }
58
+ async withStateLock(operation) {
59
+ const lockPath = `${this.stateFilePath()}${STATE_LOCK_SUFFIX}`;
60
+ const lockToken = await acquireStateLock(lockPath);
61
+ if (lockToken === undefined)
62
+ return;
63
+ const stopHeartbeat = startStateLockHeartbeat(lockPath, lockToken);
64
+ try {
65
+ await operation();
66
+ }
67
+ finally {
68
+ stopHeartbeat();
69
+ await releaseStateLock(lockPath, lockToken);
70
+ }
71
+ }
72
+ async readState() {
73
+ try {
74
+ const raw = await readFile(this.stateFilePath(), "utf8");
75
+ const parsed = JSON.parse(raw);
76
+ if (!isPlainObject(parsed))
77
+ return {};
78
+ return isPlainObject(parsed.syncDirs)
79
+ ? parsed
80
+ : { ...parsed, syncDirs: {} };
81
+ }
82
+ catch {
83
+ return {};
84
+ }
85
+ }
86
+ async writeState(state) {
87
+ try {
88
+ const path = this.stateFilePath();
89
+ const directory = dirname(path);
90
+ await mkdir(directory, { recursive: true });
91
+ const temporaryDirectory = await mkdtemp(join(directory, `.gji-state-${randomUUID()}-`));
92
+ const temporaryPath = join(temporaryDirectory, STATE_FILE_NAME);
93
+ try {
94
+ await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
95
+ await rename(temporaryPath, path);
96
+ }
97
+ finally {
98
+ await rm(temporaryDirectory, { force: true, recursive: true });
99
+ }
100
+ }
101
+ catch {
102
+ // The cache is advisory and must never block worktree creation.
103
+ }
104
+ }
105
+ stateFilePath(home = homedir()) {
106
+ const configuredDirectory = process.env.GJI_CONFIG_DIR;
107
+ const directory = configuredDirectory
108
+ ? resolve(configuredDirectory)
109
+ : join(home, GLOBAL_CONFIG_DIRECTORY);
110
+ return join(directory, STATE_FILE_NAME);
111
+ }
112
+ }
113
+ async function acquireStateLock(lockPath) {
114
+ const lockToken = randomUUID();
115
+ await mkdir(dirname(lockPath), { recursive: true }).catch(() => undefined);
116
+ for (let attempt = 0; attempt < 200; attempt += 1) {
117
+ try {
118
+ await mkdir(lockPath);
119
+ }
120
+ catch (error) {
121
+ if (!isErrorCode(error, "EEXIST"))
122
+ return undefined;
123
+ try {
124
+ const freshnessPath = await lockFreshnessPath(lockPath);
125
+ const lockStats = await stat(freshnessPath);
126
+ if (Date.now() - lockStats.mtimeMs >= STATE_LOCK_TTL_MS) {
127
+ const stalePath = `${lockPath}.stale-${randomUUID()}`;
128
+ try {
129
+ await rename(lockPath, stalePath);
130
+ }
131
+ catch (renameError) {
132
+ if (isErrorCode(renameError, "ENOENT"))
133
+ continue;
134
+ return undefined;
135
+ }
136
+ await rm(stalePath, { force: true, recursive: true });
137
+ continue;
138
+ }
139
+ }
140
+ catch (lockError) {
141
+ if (!isErrorCode(lockError, "ENOENT"))
142
+ return undefined;
143
+ }
144
+ await new Promise((resolve) => setTimeout(resolve, 25));
145
+ continue;
146
+ }
147
+ try {
148
+ await writeFile(join(lockPath, ownerFileName(lockToken)), `${lockToken}\n`, {
149
+ flag: "wx",
150
+ });
151
+ return lockToken;
152
+ }
153
+ catch {
154
+ await rm(lockPath, { force: true, recursive: true }).catch(() => undefined);
155
+ return undefined;
156
+ }
157
+ }
158
+ return undefined;
159
+ }
160
+ async function lockFreshnessPath(lockPath) {
161
+ try {
162
+ const owner = (await readdir(lockPath)).find((entry) => entry.startsWith("owner-"));
163
+ return owner ? join(lockPath, owner) : lockPath;
164
+ }
165
+ catch (error) {
166
+ if (isErrorCode(error, "ENOENT"))
167
+ throw error;
168
+ return lockPath;
169
+ }
170
+ }
171
+ function startStateLockHeartbeat(lockPath, lockToken) {
172
+ const timer = setInterval(() => {
173
+ void refreshStateLock(lockPath, lockToken);
174
+ }, STATE_LOCK_TTL_MS / 3);
175
+ timer.unref?.();
176
+ return () => clearInterval(timer);
177
+ }
178
+ async function refreshStateLock(lockPath, lockToken) {
179
+ try {
180
+ const ownerPath = join(lockPath, ownerFileName(lockToken));
181
+ await readFile(ownerPath, "utf8");
182
+ const now = new Date();
183
+ await utimes(ownerPath, now, now);
184
+ }
185
+ catch {
186
+ // The cache is advisory; a failed heartbeat must not block worktree creation.
187
+ }
188
+ }
189
+ async function releaseStateLock(lockPath, lockToken) {
190
+ try {
191
+ const ownerPath = join(lockPath, ownerFileName(lockToken));
192
+ await unlink(ownerPath);
193
+ await rmdir(lockPath);
194
+ }
195
+ catch (error) {
196
+ if (!isErrorCode(error, "ENOENT") && !isErrorCode(error, "ENOTEMPTY")) {
197
+ return;
198
+ }
199
+ }
200
+ }
201
+ function ownerFileName(lockToken) {
202
+ return `owner-${lockToken}`;
203
+ }
204
+ export const defaultCloneFailureStore = new FileCloneFailureStore();
205
+ export async function cloneFailureScope(source, destination) {
206
+ const sourcePath = resolve(source);
207
+ const destinationParent = resolve(dirname(destination));
208
+ const [sourceDevice, destinationDevice] = await Promise.all([
209
+ readDevice(sourcePath),
210
+ readDevice(destinationParent),
211
+ ]);
212
+ return JSON.stringify([sourcePath, sourceDevice, destinationDevice]);
213
+ }
214
+ async function readDevice(path) {
215
+ try {
216
+ return (await stat(path)).dev;
217
+ }
218
+ catch {
219
+ return undefined;
220
+ }
221
+ }
222
+ function failureKey(directory, scope) {
223
+ return scope === undefined ? directory : JSON.stringify([scope, directory]);
224
+ }
225
+ function isPlainObject(value) {
226
+ return typeof value === "object" && value !== null && !Array.isArray(value);
227
+ }
228
+ function isErrorCode(error, code) {
229
+ return error instanceof Error && "code" in error && error.code === code;
230
+ }
@@ -0,0 +1,6 @@
1
+ export type CommandRunner = (command: string, cwd: string, stderr: (chunk: string) => void, stdout?: (chunk: string) => void, options?: CommandRunnerOptions) => Promise<void>;
2
+ export interface CommandRunnerOptions {
3
+ env?: NodeJS.ProcessEnv;
4
+ shell?: boolean;
5
+ }
6
+ export declare const runCommand: CommandRunner;
@@ -0,0 +1,38 @@
1
+ import { spawn } from "node:child_process";
2
+ export const runCommand = async (command, cwd, stderr, stdout = (chunk) => process.stdout.write(chunk), options) => {
3
+ await new Promise((resolve, reject) => {
4
+ const shell = options?.shell ?? true;
5
+ const [executable, args] = shell ? [command, []] : splitCommand(command);
6
+ const child = spawn(executable, args, {
7
+ cwd,
8
+ env: options?.env ? { ...process.env, ...options.env } : undefined,
9
+ shell,
10
+ stdio: ["ignore", "pipe", "pipe"],
11
+ });
12
+ child.stdout.on("data", (chunk) => {
13
+ stdout(chunk.toString());
14
+ });
15
+ child.stderr.on("data", (chunk) => {
16
+ stderr(chunk.toString());
17
+ });
18
+ child.on("close", (code) => {
19
+ if (code !== 0) {
20
+ reject(new Error(`exited with code ${code}`));
21
+ }
22
+ else {
23
+ resolve();
24
+ }
25
+ });
26
+ child.on("error", reject);
27
+ });
28
+ };
29
+ function splitCommand(command) {
30
+ const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/gu) ?? [];
31
+ const [first, ...rest] = tokens;
32
+ if (!first)
33
+ throw new Error("command must not be empty");
34
+ return [
35
+ first.replace(/^(["'])(.*)\1$/u, "$2"),
36
+ rest.map((token) => token.replace(/^(["'])(.*)\1$/u, "$2")),
37
+ ];
38
+ }
@@ -1,9 +1,14 @@
1
1
  import { resolveSupportedShell } from "./shell.js";
2
2
  import { renderShellCompletion } from "./shell-completion.js";
3
3
  export async function runCompletionCommand(options) {
4
- const shell = resolveSupportedShell(options.shell, process.env.SHELL);
4
+ const shell = options.shell
5
+ ? resolveSupportedShell(options.shell, undefined)
6
+ : resolveSupportedShell(undefined, process.env.SHELL);
5
7
  if (!shell) {
6
- options.stderr?.("Unable to detect a supported shell. Specify one explicitly: bash, fish, or zsh.\n");
8
+ const message = options.shell
9
+ ? `Unsupported shell "${options.shell}". Supported shells: bash, fish, or zsh.`
10
+ : "Unable to detect a supported shell. Specify one explicitly: bash, fish, or zsh.";
11
+ options.stderr?.(`${message}\n`);
7
12
  return 1;
8
13
  }
9
14
  options.stdout(renderShellCompletion(shell));
@@ -2,6 +2,7 @@ export interface ConfigCommandOptions {
2
2
  action?: string;
3
3
  cwd: string;
4
4
  key?: string;
5
+ stderr?: (chunk: string) => void;
5
6
  stdout: (chunk: string) => void;
6
7
  value?: string;
7
8
  }
@@ -13,7 +13,13 @@ export async function runConfigCommand(options) {
13
13
  }
14
14
  case "set":
15
15
  if (options.key && options.value !== undefined) {
16
- await updateGlobalConfigKey(options.key, parseConfigValue(options.value));
16
+ try {
17
+ await updateGlobalConfigKey(options.key, parseConfigValue(options.value));
18
+ }
19
+ catch (error) {
20
+ options.stderr?.(`gji config: ${error instanceof Error ? error.message : String(error)}\n`);
21
+ return 1;
22
+ }
17
23
  return 0;
18
24
  }
19
25
  break;
package/dist/config.d.ts CHANGED
@@ -4,14 +4,35 @@ export declare const GLOBAL_CONFIG_NAME = "config.json";
4
4
  export declare const KNOWN_CONFIG_KEYS: ReadonlySet<string>;
5
5
  export declare const KNOWN_GLOBAL_CONFIG_KEYS: ReadonlySet<string>;
6
6
  export type GjiConfig = Record<string, unknown>;
7
+ export type DependencyBootstrapMode = "off" | "cow-then-repair" | "install-only";
8
+ export interface EffectiveGjiConfig extends GjiConfig {
9
+ branchPrefix?: string;
10
+ dependencyBuildCommand?: string;
11
+ dependencyBootstrap?: DependencyBootstrapMode;
12
+ editor?: string;
13
+ hooks?: Record<string, unknown>;
14
+ installSaveTarget?: string;
15
+ shellIntegration?: string;
16
+ skipInstallPrompt?: boolean;
17
+ syncDirs?: readonly string[];
18
+ syncFiles?: readonly string[];
19
+ syncDefaultBranch?: string;
20
+ syncRemote?: string;
21
+ worktreePath?: string;
22
+ }
7
23
  export interface LoadedConfig {
8
24
  config: GjiConfig;
9
25
  exists: boolean;
10
26
  path: string;
11
27
  }
28
+ export interface EffectiveConfigResult {
29
+ config: EffectiveGjiConfig;
30
+ dependencyBootstrapExplicit: boolean;
31
+ }
12
32
  export declare const DEFAULT_CONFIG: GjiConfig;
13
33
  export declare function loadConfig(root: string): Promise<LoadedConfig>;
14
- export declare function loadEffectiveConfig(root: string, home?: string, onWarning?: (message: string) => void): Promise<GjiConfig>;
34
+ export declare function loadEffectiveConfig(root: string, home?: string, onWarning?: (message: string) => void): Promise<EffectiveGjiConfig>;
35
+ export declare function loadEffectiveConfigResult(root: string, home?: string, onWarning?: (message: string) => void): Promise<EffectiveConfigResult>;
15
36
  export declare function loadGlobalConfig(home?: string): Promise<LoadedConfig>;
16
37
  export declare function saveLocalConfig(root: string, config: GjiConfig): Promise<string>;
17
38
  export declare function updateLocalConfigKey(root: string, key: string, value: unknown): Promise<GjiConfig>;
@@ -22,3 +43,7 @@ export declare function updateGlobalRepoConfigKey(repoRoot: string, key: string,
22
43
  export declare function GLOBAL_CONFIG_FILE_PATH(home?: string): string;
23
44
  export declare function parseConfigValue(value: string): unknown;
24
45
  export declare function resolveConfigString(config: GjiConfig, key: string): string | undefined;
46
+ export declare function validateSyncDirsConfig(value: unknown): void;
47
+ export declare function normalizeDependencyBootstrap(value: unknown): DependencyBootstrapMode | undefined;
48
+ export declare function normalizeSyncDirsConfig(value: unknown): readonly string[] | undefined;
49
+ export declare function validateSyncDirPattern(pattern: string): string;