@solaqua/gji 0.10.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 (98) hide show
  1. package/README.md +89 -18
  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 +101 -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 +5491 -1253
  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 +13 -2
  47. package/dist/new.js +414 -39
  48. package/dist/pr.d.ts +6 -2
  49. package/dist/pr.js +135 -30
  50. package/dist/remove.js +20 -0
  51. package/dist/repo.d.ts +3 -0
  52. package/dist/repo.js +57 -6
  53. package/dist/safe-destination.d.ts +15 -0
  54. package/dist/safe-destination.js +166 -0
  55. package/dist/shell-completion.js +43 -12
  56. package/dist/sync-directories.d.ts +33 -0
  57. package/dist/sync-directories.js +97 -0
  58. package/dist/sync-plan.d.ts +16 -0
  59. package/dist/sync-plan.js +128 -0
  60. package/dist/sync.js +7 -13
  61. package/dist/undo.d.ts +36 -0
  62. package/dist/undo.js +392 -0
  63. package/dist/warp.d.ts +3 -1
  64. package/dist/warp.js +32 -125
  65. package/dist/worktree-bootstrap.d.ts +35 -0
  66. package/dist/worktree-bootstrap.js +93 -0
  67. package/dist/worktree-management.d.ts +1 -0
  68. package/dist/worktree-management.js +23 -4
  69. package/dist/worktree-picker.d.ts +21 -8
  70. package/dist/worktree-picker.js +107 -15
  71. package/dist/worktree-prompts.js +1 -1
  72. package/dist/worktree-source.d.ts +6 -0
  73. package/dist/worktree-source.js +1 -0
  74. package/dist/worktree-sources.d.ts +3 -0
  75. package/dist/worktree-sources.js +31 -0
  76. package/man/man1/gji-back.1 +1 -1
  77. package/man/man1/gji-clean.1 +1 -1
  78. package/man/man1/gji-completion.1 +1 -1
  79. package/man/man1/gji-config.1 +1 -1
  80. package/man/man1/gji-doctor.1 +1 -1
  81. package/man/man1/gji-done.1 +19 -0
  82. package/man/man1/gji-go.1 +9 -3
  83. package/man/man1/gji-history.1 +1 -1
  84. package/man/man1/gji-init.1 +1 -1
  85. package/man/man1/gji-ls.1 +1 -1
  86. package/man/man1/gji-new.1 +18 -3
  87. package/man/man1/gji-open.1 +1 -1
  88. package/man/man1/gji-pr.1 +7 -1
  89. package/man/man1/gji-remove.1 +3 -3
  90. package/man/man1/gji-root.1 +1 -1
  91. package/man/man1/gji-run-hook.1 +1 -1
  92. package/man/man1/gji-status.1 +1 -1
  93. package/man/man1/gji-sync-files.1 +1 -1
  94. package/man/man1/gji-sync.1 +1 -1
  95. package/man/man1/gji-undo.1 +16 -0
  96. package/man/man1/gji-warp.1 +3 -6
  97. package/man/man1/gji.1 +13 -5
  98. 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,23 +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")
123
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)")
124
153
  .option("--open", "open the new worktree in an editor after creation")
125
154
  .option("--editor <cli>", "editor CLI to use with --open (code, cursor, zed, …)")
126
155
  .option("--dry-run", "show what would be created without executing any git commands or writing files")
127
156
  .option("--json", "emit JSON on success or error instead of human-readable output")
128
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"));
129
171
  program
130
172
  .command("init [shell]")
131
173
  .description("set up shell integration interactively or print a shell wrapper")
@@ -176,8 +218,10 @@ function registerCommands(program) {
176
218
  program
177
219
  .command("go [branch]")
178
220
  .alias("jump")
179
- .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")
180
223
  .option("--print", "print the resolved worktree path explicitly")
224
+ .option("--json", "emit JSON for an existing worktree destination")
181
225
  .action(notImplemented("go"));
182
226
  program
183
227
  .command("root")
@@ -233,7 +277,7 @@ function registerCommands(program) {
233
277
  program
234
278
  .command("remove [branch]")
235
279
  .alias("rm")
236
- .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")
237
281
  .option("-f, --force", "bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch")
238
282
  .option("--dry-run", "show what would be deleted without removing anything")
239
283
  .option("--json", "emit JSON on success or error instead of human-readable output")
@@ -245,8 +289,7 @@ function registerCommands(program) {
245
289
  .action(notImplemented("run-hook"));
246
290
  program
247
291
  .command("warp [branch]")
248
- .description("jump to any worktree across all known repos")
249
- .option("-n, --new [branch]", "create a new worktree in a registered repo")
292
+ .description("deprecated: use gji go (press Tab for all known repos)")
250
293
  // --print is the shell-wrapper bypass signal (see SHELL_WRAPPED_COMMANDS in init.ts).
251
294
  // The shell omits GJI_WARP_OUTPUT_FILE, so writeShellOutput falls through to stdout.
252
295
  .option("--print", "print the resolved worktree path without changing directory")
@@ -276,18 +319,50 @@ function attachCommandActions(program, options) {
276
319
  const exitCode = await runNewCommand({
277
320
  ...options,
278
321
  branch,
322
+ copy: commandOptions.copy,
279
323
  detached: commandOptions.detached,
280
324
  dryRun: commandOptions.dryRun,
281
325
  editor: commandOptions.editor,
282
326
  fromCurrent: commandOptions.fromCurrent,
283
327
  force: commandOptions.force,
328
+ noFetch: commandOptions.fetch === false,
284
329
  json: commandOptions.json,
285
330
  open: commandOptions.open,
331
+ take: commandOptions.take,
286
332
  });
287
333
  if (exitCode !== 0) {
288
334
  throw commanderExit(exitCode);
289
335
  }
290
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
+ });
291
366
  program.commands
292
367
  .find((command) => command.name() === "init")
293
368
  ?.action(async (shell, commandOptions) => {
@@ -415,7 +490,9 @@ function attachCommandActions(program, options) {
415
490
  const exitCode = await runGoCommand({
416
491
  branch,
417
492
  cwd: options.cwd,
493
+ json: commandOptions.json,
418
494
  print: commandOptions.print,
495
+ root: commandOptions.root,
419
496
  stderr: options.stderr,
420
497
  stdout: options.stdout,
421
498
  });
@@ -564,7 +641,12 @@ function attachCommandActions(program, options) {
564
641
  };
565
642
  program.commands
566
643
  .find((command) => command.name() === "remove")
567
- ?.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
+ });
568
650
  program.commands
569
651
  .find((command) => command.name() === "run-hook")
570
652
  ?.action(async (hook) => {
@@ -580,14 +662,13 @@ function attachCommandActions(program, options) {
580
662
  program.commands
581
663
  .find((command) => command.name() === "warp")
582
664
  ?.action(async (branch, commandOptions) => {
583
- const newFlag = commandOptions.new;
584
- const newWorktree = newFlag !== undefined && newFlag !== false;
585
- 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
+ }
586
668
  const exitCode = await runWarpCommand({
587
- branch: newWorktree ? (newBranch ?? branch) : branch,
669
+ branch,
588
670
  cwd: options.cwd,
589
671
  json: commandOptions.json,
590
- newWorktree,
591
672
  stderr: options.stderr,
592
673
  stdout: options.stdout,
593
674
  });
@@ -625,6 +706,7 @@ function attachCommandActions(program, options) {
625
706
  action: "set",
626
707
  cwd: options.cwd,
627
708
  key,
709
+ stderr: options.stderr,
628
710
  stdout: options.stdout,
629
711
  value,
630
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;