@solaqua/gji 0.10.0 → 0.12.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 (122) hide show
  1. package/README.md +83 -426
  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 +27 -3
  9. package/dist/cli.d.ts +4 -0
  10. package/dist/cli.js +140 -22
  11. package/dist/clone-failure-store.d.ts +18 -0
  12. package/dist/clone-failure-store.js +230 -0
  13. package/dist/command-runner.d.ts +6 -0
  14. package/dist/command-runner.js +38 -0
  15. package/dist/completion.js +7 -2
  16. package/dist/config-command.d.ts +1 -0
  17. package/dist/config-command.js +7 -1
  18. package/dist/config.d.ts +26 -1
  19. package/dist/config.js +110 -6
  20. package/dist/context-card.d.ts +1 -0
  21. package/dist/context-card.js +25 -0
  22. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  23. package/dist/dependency-bootstrap-prompt.js +103 -0
  24. package/dist/dependency-bootstrap.d.ts +129 -0
  25. package/dist/dependency-bootstrap.js +566 -0
  26. package/dist/dir-clone.d.ts +33 -0
  27. package/dist/dir-clone.js +505 -0
  28. package/dist/doctor.js +21 -0
  29. package/dist/done.d.ts +10 -0
  30. package/dist/done.js +210 -0
  31. package/dist/file-sync.js +71 -22
  32. package/dist/format-bytes.d.ts +1 -0
  33. package/dist/format-bytes.js +14 -0
  34. package/dist/fs-utils.d.ts +4 -0
  35. package/dist/fs-utils.js +26 -0
  36. package/dist/git.d.ts +5 -0
  37. package/dist/git.js +50 -0
  38. package/dist/gji-bundle.mjs +6189 -1313
  39. package/dist/go-resolver.d.ts +35 -0
  40. package/dist/go-resolver.js +185 -0
  41. package/dist/go.d.ts +6 -2
  42. package/dist/go.js +270 -62
  43. package/dist/hooks.d.ts +2 -1
  44. package/dist/hooks.js +20 -8
  45. package/dist/hub.d.ts +31 -0
  46. package/dist/hub.js +287 -0
  47. package/dist/init.js +30 -22
  48. package/dist/install-prompt.d.ts +1 -0
  49. package/dist/install-prompt.js +3 -25
  50. package/dist/ls.js +9 -1
  51. package/dist/navigation-output.d.ts +11 -0
  52. package/dist/navigation-output.js +6 -0
  53. package/dist/new.d.ts +14 -2
  54. package/dist/new.js +422 -38
  55. package/dist/pr-open.js +2 -10
  56. package/dist/pr.d.ts +6 -2
  57. package/dist/pr.js +135 -30
  58. package/dist/pull-requests.d.ts +1 -0
  59. package/dist/pull-requests.js +32 -3
  60. package/dist/remove.js +22 -0
  61. package/dist/repo.d.ts +4 -0
  62. package/dist/repo.js +61 -6
  63. package/dist/run-hook.js +2 -0
  64. package/dist/safe-destination.d.ts +15 -0
  65. package/dist/safe-destination.js +166 -0
  66. package/dist/shell-completion.js +52 -13
  67. package/dist/slots.d.ts +8 -0
  68. package/dist/slots.js +61 -0
  69. package/dist/status.d.ts +2 -0
  70. package/dist/status.js +9 -1
  71. package/dist/sync-directories.d.ts +33 -0
  72. package/dist/sync-directories.js +97 -0
  73. package/dist/sync-plan.d.ts +16 -0
  74. package/dist/sync-plan.js +128 -0
  75. package/dist/sync.js +7 -13
  76. package/dist/task-command.d.ts +9 -0
  77. package/dist/task-command.js +29 -0
  78. package/dist/task.d.ts +9 -0
  79. package/dist/task.js +37 -0
  80. package/dist/terminal-text.d.ts +4 -0
  81. package/dist/terminal-text.js +72 -0
  82. package/dist/undo.d.ts +36 -0
  83. package/dist/undo.js +392 -0
  84. package/dist/warp.d.ts +3 -1
  85. package/dist/warp.js +32 -125
  86. package/dist/worktree-bootstrap.d.ts +35 -0
  87. package/dist/worktree-bootstrap.js +96 -0
  88. package/dist/worktree-info.d.ts +6 -1
  89. package/dist/worktree-info.js +14 -4
  90. package/dist/worktree-management.d.ts +1 -0
  91. package/dist/worktree-management.js +23 -4
  92. package/dist/worktree-picker.d.ts +24 -11
  93. package/dist/worktree-picker.js +176 -95
  94. package/dist/worktree-prompts.js +1 -1
  95. package/dist/worktree-source.d.ts +6 -0
  96. package/dist/worktree-source.js +1 -0
  97. package/dist/worktree-sources.d.ts +4 -0
  98. package/dist/worktree-sources.js +77 -0
  99. package/man/man1/gji-back.1 +1 -1
  100. package/man/man1/gji-clean.1 +1 -1
  101. package/man/man1/gji-completion.1 +1 -1
  102. package/man/man1/gji-config.1 +1 -1
  103. package/man/man1/gji-doctor.1 +1 -1
  104. package/man/man1/gji-done.1 +19 -0
  105. package/man/man1/gji-go.1 +12 -3
  106. package/man/man1/gji-history.1 +1 -1
  107. package/man/man1/gji-init.1 +1 -1
  108. package/man/man1/gji-ls.1 +1 -1
  109. package/man/man1/gji-new.1 +21 -3
  110. package/man/man1/gji-open.1 +1 -1
  111. package/man/man1/gji-pr.1 +7 -1
  112. package/man/man1/gji-remove.1 +3 -3
  113. package/man/man1/gji-root.1 +1 -1
  114. package/man/man1/gji-run-hook.1 +1 -1
  115. package/man/man1/gji-status.1 +1 -1
  116. package/man/man1/gji-sync-files.1 +1 -1
  117. package/man/man1/gji-sync.1 +1 -1
  118. package/man/man1/gji-task.1 +16 -0
  119. package/man/man1/gji-undo.1 +16 -0
  120. package/man/man1/gji-warp.1 +3 -6
  121. package/man/man1/gji.1 +17 -5
  122. package/package.json +3 -1
@@ -0,0 +1,10 @@
1
+ import type { DependencyBootstrapMode } from "./config.js";
2
+ import { type BootstrapTarget, type DependencyBootstrapPreview } from "./dependency-bootstrap.js";
3
+ export declare function createDependencyBootstrapPreview(mode: DependencyBootstrapMode, context: {
4
+ repoRoot: string;
5
+ currentRoot?: string;
6
+ worktreePath: string;
7
+ cargoBuildCommand?: string;
8
+ checkUvRuntime?: (target: BootstrapTarget) => Promise<boolean>;
9
+ }): Promise<DependencyBootstrapPreview>;
10
+ export declare function formatDependencyBootstrapPreview(preview: DependencyBootstrapPreview | undefined): string;
@@ -0,0 +1,21 @@
1
+ import { prepareDependencyBootstrap, previewDependencyBootstrap, } from "./dependency-bootstrap.js";
2
+ export async function createDependencyBootstrapPreview(mode, context) {
3
+ return previewDependencyBootstrap(await prepareDependencyBootstrap(mode, context));
4
+ }
5
+ export function formatDependencyBootstrapPreview(preview) {
6
+ if (!preview)
7
+ return "";
8
+ return preview.targets
9
+ .map(({ adapter, target, strategy }) => `Would ${formatBootstrapStrategy(strategy)} ${target} with ${adapter}\n`)
10
+ .join("");
11
+ }
12
+ function formatBootstrapStrategy(strategy) {
13
+ switch (strategy) {
14
+ case "cow-then-repair":
15
+ return "seed and repair";
16
+ case "repair-only":
17
+ return "repair";
18
+ case "install-only":
19
+ return "install";
20
+ }
21
+ }
package/dist/clean.js CHANGED
@@ -2,8 +2,10 @@ import { confirm, isCancel } from "@clack/prompts";
2
2
  import { loadEffectiveConfig } from "./config.js";
3
3
  import { isBranchMergedInto, readWorktreeHealth, resolveRemoteDefaultBranch, runGit, } from "./git.js";
4
4
  import { isHeadless } from "./headless.js";
5
+ import { releaseWorktreeSlot } from "./slots.js";
6
+ import { finalizeUndoOperation, recordUndoOperation } from "./undo.js";
5
7
  import { formatLastCommit, formatUpstreamState, readWorktreeInfos, serializeWorktreeInfo, } from "./worktree-info.js";
6
- import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDeletionError, isWorktreeForceRemovalError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
8
+ import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
7
9
  import { buildWorktreePromptEntries, promptForMultipleWorktrees, } from "./worktree-picker.js";
8
10
  import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
9
11
  export function createCleanCommand(dependencies = {}) {
@@ -79,6 +81,18 @@ export function createCleanCommand(dependencies = {}) {
79
81
  }
80
82
  const removedWorktrees = [];
81
83
  const failures = [];
84
+ let journal;
85
+ try {
86
+ journal = await recordUndoOperation("clean", repository.repoRoot, selectedWorktrees);
87
+ }
88
+ catch (error) {
89
+ emitError(options, `could not write undo journal; no worktrees were removed: ${toMessage(error)}`);
90
+ return 1;
91
+ }
92
+ if (!journal) {
93
+ emitError(options, "could not capture undo state; no worktrees were removed");
94
+ return 1;
95
+ }
82
96
  for (const worktree of selectedWorktrees) {
83
97
  if (options.stale &&
84
98
  !(await isStaleCleanupCandidate(repository.repoRoot, worktree, staleBaseRef))) {
@@ -97,7 +111,9 @@ export function createCleanCommand(dependencies = {}) {
97
111
  });
98
112
  continue;
99
113
  }
100
- if (options.stale && !isWorktreeDeletionError(error)) {
114
+ if (options.stale &&
115
+ !isWorktreeDeletionError(error) &&
116
+ !isSubmoduleWorktreeRemovalError(error)) {
101
117
  options.stderr(`Skipped ${worktree.path}: no longer a safe stale cleanup candidate\n`);
102
118
  continue;
103
119
  }
@@ -129,7 +145,12 @@ export function createCleanCommand(dependencies = {}) {
129
145
  }
130
146
  catch (error) {
131
147
  if (!isBranchUnmergedError(error)) {
132
- throw error;
148
+ failures.push({
149
+ branch: worktree.branch,
150
+ message: toMessage(error),
151
+ path: worktree.path,
152
+ });
153
+ continue;
133
154
  }
134
155
  if (options.force ||
135
156
  (await confirmForceDeleteBranch(worktree.branch))) {
@@ -146,6 +167,8 @@ export function createCleanCommand(dependencies = {}) {
146
167
  }
147
168
  }
148
169
  }
170
+ await finalizeUndoOperation(journal, removedWorktrees);
171
+ await Promise.all(removedWorktrees.map((worktree) => releaseWorktreeSlot(worktree.path)));
149
172
  if (options.json) {
150
173
  const removed = removedWorktrees.map((worktree) => {
151
174
  const info = selectedInfoByPath.get(worktree.path);
@@ -160,6 +183,7 @@ export function createCleanCommand(dependencies = {}) {
160
183
  reportCleanFailures(failures, options.stderr);
161
184
  }
162
185
  else {
186
+ options.stderr("undo: gji undo\n");
163
187
  options.stdout(`${repository.repoRoot}\n`);
164
188
  }
165
189
  return failures.length === 0 ? 0 : 1;
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { Command } from "commander";
2
+ import { type HubCommandDependencies } from "./hub.js";
2
3
  import { type PrOpenCommandDependencies } from "./pr-open.js";
3
4
  export interface RunCliOptions {
5
+ columns?: number;
4
6
  cwd?: string;
7
+ now?: number;
5
8
  prOpenDependencies?: Partial<PrOpenCommandDependencies>;
9
+ hubDependencies?: Partial<HubCommandDependencies>;
6
10
  stderr?: (chunk: string) => void;
7
11
  stdout?: (chunk: string) => void;
8
12
  }
package/dist/cli.js CHANGED
@@ -6,9 +6,11 @@ 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";
13
+ import { runHubCommand } from "./hub.js";
12
14
  import { runInitCommand } from "./init.js";
13
15
  import { runLsCommand } from "./ls.js";
14
16
  import { runNewCommand } from "./new.js";
@@ -23,6 +25,8 @@ import { runHookCommand } from "./run-hook.js";
23
25
  import { runStatusCommand } from "./status.js";
24
26
  import { runSyncCommand } from "./sync.js";
25
27
  import { runSyncFilesCommand } from "./sync-files-command.js";
28
+ import { runTaskCommand } from "./task-command.js";
29
+ import { runUndoCommand } from "./undo.js";
26
30
  import { runWarpCommand } from "./warp.js";
27
31
  export function createProgram() {
28
32
  const program = new Command();
@@ -34,6 +38,11 @@ export function createProgram() {
34
38
  .showHelpAfterError()
35
39
  .showSuggestionAfterError();
36
40
  registerCommands(program);
41
+ program.addHelpText("after", "\nCommon workflows:\n" +
42
+ " gji new <branch> create a worktree\n" +
43
+ " gji go <branch> navigate to a worktree\n" +
44
+ " gji go choose; press Tab for all repositories\n" +
45
+ " gji go - return to the previous worktree\n");
37
46
  return program;
38
47
  }
39
48
  function readPackageMetadata() {
@@ -46,21 +55,28 @@ function readPackageMetadata() {
46
55
  }
47
56
  export async function runCli(argv, options = {}) {
48
57
  await maybeNotifyForUpdates(argv);
49
- if (argv[0] !== "doctor") {
50
- maybeRegisterCurrentRepo(options.cwd ?? process.cwd());
51
- }
52
- const program = createProgram();
53
58
  const cwd = options.cwd ?? process.cwd();
54
59
  const stdout = options.stdout ?? (() => undefined);
55
60
  const stderr = options.stderr ?? (() => undefined);
61
+ if (shouldRegisterCurrentRepo(argv) || isHubInvocation(argv)) {
62
+ await maybeRegisterCurrentRepo(cwd);
63
+ }
64
+ const program = createProgram();
56
65
  program.configureOutput({
57
66
  writeErr: stderr,
58
67
  writeOut: stdout,
59
68
  });
60
69
  program.exitOverride();
61
- if (argv.length === 0) {
62
- program.outputHelp();
63
- return { exitCode: 0 };
70
+ if (isHubInvocation(argv)) {
71
+ const exitCode = await runHubCommand({
72
+ columns: options.columns,
73
+ cwd,
74
+ json: argv.includes("--json"),
75
+ now: options.now,
76
+ stderr,
77
+ stdout,
78
+ }, options.hubDependencies);
79
+ return { exitCode };
64
80
  }
65
81
  try {
66
82
  attachCommandActions(program, {
@@ -109,23 +125,62 @@ function defaultNotifyForUpdates(pkg) {
109
125
  const notifier = updateNotifier({ pkg });
110
126
  notifier.notify();
111
127
  }
112
- function maybeRegisterCurrentRepo(cwd) {
113
- detectRepository(cwd)
114
- .then(({ repoRoot }) => registerRepo(repoRoot))
115
- .catch(() => undefined);
128
+ function shouldRegisterCurrentRepo(argv) {
129
+ const command = argv[0];
130
+ return (argv.length > 0 &&
131
+ ![
132
+ "--help",
133
+ "-h",
134
+ "--version",
135
+ "-V",
136
+ "completion",
137
+ "config",
138
+ "doctor",
139
+ "help",
140
+ "init",
141
+ ].includes(command ?? ""));
142
+ }
143
+ function isHubInvocation(argv) {
144
+ return argv.length === 0 || (argv.length === 1 && argv[0] === "--json");
145
+ }
146
+ async function maybeRegisterCurrentRepo(cwd) {
147
+ try {
148
+ const { repoRoot } = await detectRepository(cwd);
149
+ await registerRepo(repoRoot);
150
+ }
151
+ catch {
152
+ // Registration is best effort; command behaviour should not depend on it.
153
+ }
116
154
  }
117
155
  function registerCommands(program) {
118
156
  program
119
157
  .command("new [branch]")
120
- .description("create a new branch or detached linked worktree")
158
+ .description("create a new branch or detached linked worktree and CoW-bootstrap configured directories")
121
159
  .option("-f, --force", "remove and recreate the worktree if the target path already exists")
122
160
  .option("--detached", "create a detached worktree without a branch")
123
161
  .option("--from-current", "base the new branch on the current worktree instead of the main worktree (cannot be combined with --detached)")
162
+ .option("--no-fetch", "skip refreshing the remote default branch before creating the new branch")
163
+ .option("--take", "move current uncommitted changes into the new worktree")
164
+ .option("--task <description>", "record the purpose of the new worktree")
165
+ .option("--copy", "copy current uncommitted changes instead of moving them (requires --take)")
124
166
  .option("--open", "open the new worktree in an editor after creation")
125
167
  .option("--editor <cli>", "editor CLI to use with --open (code, cursor, zed, …)")
126
168
  .option("--dry-run", "show what would be created without executing any git commands or writing files")
127
169
  .option("--json", "emit JSON on success or error instead of human-readable output")
128
170
  .action(notImplemented("new"));
171
+ program
172
+ .command("done [branch]")
173
+ .description("finish the current linked worktree and return safely")
174
+ .option("--force", "remove dirty or unmerged worktrees without prompting")
175
+ .option("--keep-branch", "remove the worktree but preserve its branch")
176
+ .option("--json", "emit JSON on success or error instead of human-readable output")
177
+ .action(notImplemented("done"));
178
+ program
179
+ .command("undo [id]")
180
+ .description("restore the most recent destructive worktree operation")
181
+ .option("--list", "list recoverable operations")
182
+ .option("--json", "emit JSON on success or error instead of human-readable output")
183
+ .action(notImplemented("undo"));
129
184
  program
130
185
  .command("init [shell]")
131
186
  .description("set up shell integration interactively or print a shell wrapper")
@@ -176,8 +231,11 @@ function registerCommands(program) {
176
231
  program
177
232
  .command("go [branch]")
178
233
  .alias("jump")
179
- .description("print or select a worktree path")
234
+ .description("resolve and jump to a worktree path")
235
+ .option("--root", "navigate to the main repository root")
180
236
  .option("--print", "print the resolved worktree path explicitly")
237
+ .option("--quiet", "suppress the context card")
238
+ .option("--json", "emit JSON for an existing worktree destination")
181
239
  .action(notImplemented("go"));
182
240
  program
183
241
  .command("root")
@@ -189,6 +247,12 @@ function registerCommands(program) {
189
247
  .description("summarize repository and worktree health")
190
248
  .option("--json", "print repository and worktree health as JSON")
191
249
  .action(notImplemented("status"));
250
+ program
251
+ .command("task [task]")
252
+ .description("show or update the current worktree task")
253
+ .option("--clear", "clear the current worktree task")
254
+ .option("--json", "emit task metadata as JSON")
255
+ .action(notImplemented("task"));
192
256
  program
193
257
  .command("sync")
194
258
  .description("fetch and update one or all worktrees")
@@ -233,7 +297,7 @@ function registerCommands(program) {
233
297
  program
234
298
  .command("remove [branch]")
235
299
  .alias("rm")
236
- .description("remove a linked worktree and delete its branch when present")
300
+ .description("deprecated: use gji done for one worktree or gji clean for bulk cleanup")
237
301
  .option("-f, --force", "bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch")
238
302
  .option("--dry-run", "show what would be deleted without removing anything")
239
303
  .option("--json", "emit JSON on success or error instead of human-readable output")
@@ -245,8 +309,7 @@ function registerCommands(program) {
245
309
  .action(notImplemented("run-hook"));
246
310
  program
247
311
  .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")
312
+ .description("deprecated: use gji go (press Tab for all known repos)")
250
313
  // --print is the shell-wrapper bypass signal (see SHELL_WRAPPED_COMMANDS in init.ts).
251
314
  // The shell omits GJI_WARP_OUTPUT_FILE, so writeShellOutput falls through to stdout.
252
315
  .option("--print", "print the resolved worktree path without changing directory")
@@ -276,18 +339,51 @@ function attachCommandActions(program, options) {
276
339
  const exitCode = await runNewCommand({
277
340
  ...options,
278
341
  branch,
342
+ copy: commandOptions.copy,
279
343
  detached: commandOptions.detached,
280
344
  dryRun: commandOptions.dryRun,
281
345
  editor: commandOptions.editor,
282
346
  fromCurrent: commandOptions.fromCurrent,
283
347
  force: commandOptions.force,
348
+ noFetch: commandOptions.fetch === false,
284
349
  json: commandOptions.json,
285
350
  open: commandOptions.open,
351
+ take: commandOptions.take,
352
+ task: commandOptions.task,
286
353
  });
287
354
  if (exitCode !== 0) {
288
355
  throw commanderExit(exitCode);
289
356
  }
290
357
  });
358
+ program.commands
359
+ .find((command) => command.name() === "done")
360
+ ?.action(async (branch, commandOptions) => {
361
+ const exitCode = await runDoneCommand({
362
+ branch,
363
+ cwd: options.cwd,
364
+ force: commandOptions.force,
365
+ json: commandOptions.json,
366
+ keepBranch: commandOptions.keepBranch,
367
+ stderr: options.stderr,
368
+ stdout: options.stdout,
369
+ });
370
+ if (exitCode !== 0)
371
+ throw commanderExit(exitCode);
372
+ });
373
+ program.commands
374
+ .find((command) => command.name() === "undo")
375
+ ?.action(async (id, commandOptions) => {
376
+ const exitCode = await runUndoCommand({
377
+ cwd: options.cwd,
378
+ id,
379
+ json: commandOptions.json,
380
+ list: commandOptions.list,
381
+ stderr: options.stderr,
382
+ stdout: options.stdout,
383
+ });
384
+ if (exitCode !== 0)
385
+ throw commanderExit(exitCode);
386
+ });
291
387
  program.commands
292
388
  .find((command) => command.name() === "init")
293
389
  ?.action(async (shell, commandOptions) => {
@@ -415,7 +511,10 @@ function attachCommandActions(program, options) {
415
511
  const exitCode = await runGoCommand({
416
512
  branch,
417
513
  cwd: options.cwd,
514
+ json: commandOptions.json,
418
515
  print: commandOptions.print,
516
+ quiet: commandOptions.quiet,
517
+ root: commandOptions.root,
419
518
  stderr: options.stderr,
420
519
  stdout: options.stdout,
421
520
  });
@@ -447,6 +546,20 @@ function attachCommandActions(program, options) {
447
546
  throw commanderExit(exitCode);
448
547
  }
449
548
  });
549
+ program.commands
550
+ .find((command) => command.name() === "task")
551
+ ?.action(async (task, commandOptions) => {
552
+ const exitCode = await runTaskCommand({
553
+ clear: commandOptions.clear,
554
+ cwd: options.cwd,
555
+ json: commandOptions.json,
556
+ stderr: options.stderr,
557
+ task,
558
+ stdout: options.stdout,
559
+ });
560
+ if (exitCode !== 0)
561
+ throw commanderExit(exitCode);
562
+ });
450
563
  program.commands
451
564
  .find((command) => command.name() === "sync")
452
565
  ?.action(async (commandOptions) => {
@@ -564,7 +677,12 @@ function attachCommandActions(program, options) {
564
677
  };
565
678
  program.commands
566
679
  .find((command) => command.name() === "remove")
567
- ?.action(runRemovalCommand);
680
+ ?.action(async (branch, commandOptions) => {
681
+ if (!commandOptions.json) {
682
+ options.stderr("gji remove is deprecated; use 'gji done' for one worktree or 'gji clean' for bulk cleanup.\n");
683
+ }
684
+ return runRemovalCommand(branch, commandOptions);
685
+ });
568
686
  program.commands
569
687
  .find((command) => command.name() === "run-hook")
570
688
  ?.action(async (hook) => {
@@ -580,14 +698,13 @@ function attachCommandActions(program, options) {
580
698
  program.commands
581
699
  .find((command) => command.name() === "warp")
582
700
  ?.action(async (branch, commandOptions) => {
583
- const newFlag = commandOptions.new;
584
- const newWorktree = newFlag !== undefined && newFlag !== false;
585
- const newBranch = typeof newFlag === "string" ? newFlag : undefined;
701
+ if (!commandOptions.json) {
702
+ options.stderr("gji warp is deprecated; use 'gji go' (press Tab for all repositories).\n");
703
+ }
586
704
  const exitCode = await runWarpCommand({
587
- branch: newWorktree ? (newBranch ?? branch) : branch,
705
+ branch,
588
706
  cwd: options.cwd,
589
707
  json: commandOptions.json,
590
- newWorktree,
591
708
  stderr: options.stderr,
592
709
  stdout: options.stdout,
593
710
  });
@@ -625,6 +742,7 @@ function attachCommandActions(program, options) {
625
742
  action: "set",
626
743
  cwd: options.cwd,
627
744
  key,
745
+ stderr: options.stderr,
628
746
  stdout: options.stdout,
629
747
  value,
630
748
  });
@@ -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;