@solaqua/gji 0.14.0 → 0.16.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 (67) hide show
  1. package/README.md +63 -120
  2. package/dist/cli/commands/back.d.ts +1 -1
  3. package/dist/cli/commands/back.js +1 -0
  4. package/dist/cli/commands/clean.d.ts +2 -1
  5. package/dist/cli/commands/clean.js +66 -18
  6. package/dist/cli/commands/done.js +3 -2
  7. package/dist/cli/commands/go.js +18 -5
  8. package/dist/cli/commands/init.js +0 -14
  9. package/dist/cli/commands/new.d.ts +2 -1
  10. package/dist/cli/commands/new.js +71 -63
  11. package/dist/cli/commands/open.js +1 -1
  12. package/dist/cli/commands/pr-open.js +2 -2
  13. package/dist/cli/commands/pr.js +17 -6
  14. package/dist/cli/commands/run-hook.js +4 -0
  15. package/dist/cli/commands/undo.js +10 -74
  16. package/dist/cli/dependencies.d.ts +5 -1
  17. package/dist/cli/dependencies.js +7 -2
  18. package/dist/cli/program.js +6 -56
  19. package/dist/cli/runtime/headless.d.ts +3 -3
  20. package/dist/cli/runtime/headless.js +6 -4
  21. package/dist/domain/worktree/path-filter.d.ts +13 -0
  22. package/dist/domain/worktree/path-filter.js +72 -0
  23. package/dist/domain/worktree/policy.d.ts +1 -0
  24. package/dist/domain/worktree/policy.js +1 -1
  25. package/dist/gji-bundle.mjs +738 -969
  26. package/dist/infrastructure/filesystem/locked-file.d.ts +4 -0
  27. package/dist/infrastructure/filesystem/locked-file.js +53 -0
  28. package/dist/infrastructure/git/runner.d.ts +7 -0
  29. package/dist/infrastructure/git/runner.js +35 -1
  30. package/dist/infrastructure/persistence/history.js +12 -10
  31. package/dist/infrastructure/persistence/slots.js +24 -21
  32. package/dist/infrastructure/process/hooks.js +2 -0
  33. package/dist/infrastructure/repository/registry.js +11 -41
  34. package/dist/infrastructure/worktree/lifecycle.d.ts +25 -0
  35. package/dist/infrastructure/worktree/lifecycle.js +120 -19
  36. package/dist/ports/git.d.ts +1 -0
  37. package/dist/ports/hooks.d.ts +7 -0
  38. package/dist/presentation/shell/completion.js +14 -32
  39. package/dist/presentation/terminal/context-card.js +1 -1
  40. package/man/man1/gji-back.1 +1 -1
  41. package/man/man1/gji-clean.1 +4 -1
  42. package/man/man1/gji-completion.1 +1 -1
  43. package/man/man1/gji-config.1 +1 -1
  44. package/man/man1/gji-doctor.1 +1 -1
  45. package/man/man1/gji-done.1 +1 -1
  46. package/man/man1/gji-go.1 +1 -1
  47. package/man/man1/gji-history.1 +1 -1
  48. package/man/man1/gji-init.1 +1 -1
  49. package/man/man1/gji-ls.1 +1 -1
  50. package/man/man1/gji-new.1 +1 -1
  51. package/man/man1/gji-open.1 +1 -1
  52. package/man/man1/gji-pr.1 +1 -1
  53. package/man/man1/gji-root.1 +1 -1
  54. package/man/man1/gji-run-hook.1 +3 -3
  55. package/man/man1/gji-status.1 +1 -1
  56. package/man/man1/gji-sync-files.1 +1 -1
  57. package/man/man1/gji-sync.1 +1 -1
  58. package/man/man1/gji-task.1 +1 -1
  59. package/man/man1/gji-undo.1 +1 -1
  60. package/man/man1/gji.1 +2 -12
  61. package/package.json +3 -2
  62. package/dist/cli/commands/remove.d.ts +0 -21
  63. package/dist/cli/commands/remove.js +0 -189
  64. package/dist/cli/commands/warp.d.ts +0 -28
  65. package/dist/cli/commands/warp.js +0 -123
  66. package/man/man1/gji-remove.1 +0 -19
  67. package/man/man1/gji-warp.1 +0 -16
@@ -1,5 +1,5 @@
1
- import { access, mkdir } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
1
+ import { access, mkdir, rmdir } from "node:fs/promises";
2
+ import { basename, dirname, resolve } from "node:path";
3
3
  import { confirm, isCancel, text } from "@clack/prompts";
4
4
  import { resolveWorktreePath, validateBranchName, } from "../../domain/worktree/policy.js";
5
5
  import { createBootstrapReporter } from "../../presentation/bootstrap/output.js";
@@ -56,7 +56,7 @@ export function createNewCommand(dependencies = {}) {
56
56
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
57
57
  }
58
58
  else {
59
- options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
59
+ options.stderr(`gji new: ${message} in non-interactive mode\n`);
60
60
  }
61
61
  return 1;
62
62
  }
@@ -103,7 +103,7 @@ export function createNewCommand(dependencies = {}) {
103
103
  if (options.force) {
104
104
  if (!options.dryRun) {
105
105
  try {
106
- await runtime.git.runGit(repository.repoRoot, [
106
+ await runtime.git.runGitRetryingContention(repository.repoRoot, [
107
107
  "worktree",
108
108
  "remove",
109
109
  "--force",
@@ -111,6 +111,8 @@ export function createNewCommand(dependencies = {}) {
111
111
  ]);
112
112
  }
113
113
  catch (err) {
114
+ // The worktree claims its path below, which an empty leftover would block.
115
+ await rmdir(worktreePath).catch(() => undefined);
114
116
  if (!isNotRegisteredWorktreeError(err)) {
115
117
  const msg = `could not remove existing worktree at ${worktreePath}: ${toExecMessage(err)}`;
116
118
  if (options.json) {
@@ -141,7 +143,7 @@ export function createNewCommand(dependencies = {}) {
141
143
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
142
144
  }
143
145
  else {
144
- options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
146
+ options.stderr(`gji new: ${message} in non-interactive mode\n`);
145
147
  options.stderr(`Hint: Use 'gji done ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
146
148
  options.stderr(`Hint: Use 'gji run-hook after-create' inside the worktree to re-run setup hooks\n`);
147
149
  }
@@ -229,13 +231,14 @@ export function createNewCommand(dependencies = {}) {
229
231
  if (options.take && submoduleFiles.length > 0)
230
232
  options.stderr(`Warning: submodule changes are not transferred: ${submoduleFiles.join(", ")}\n`);
231
233
  const existingLocalBranch = await localBranchExists(repository.repoRoot, worktreeName, runtime);
232
- const shouldRefreshBase = !options.noFetch &&
233
- !options.detached &&
234
- !options.fromCurrent &&
235
- !options.take &&
234
+ const createsBranch = !options.detached &&
236
235
  options.mode !== "track" &&
237
236
  options.mode !== "checkout" &&
238
237
  !existingLocalBranch;
238
+ const shouldRefreshBase = createsBranch &&
239
+ !options.noFetch &&
240
+ !options.fromCurrent &&
241
+ !options.take;
239
242
  const freshBaseRef = shouldRefreshBase
240
243
  ? await resolveFreshBaseRef(repository.repoRoot, config, options, promptForFetchFailure, runtime)
241
244
  : undefined;
@@ -253,42 +256,21 @@ export function createNewCommand(dependencies = {}) {
253
256
  return emitNewError(options, `could not stash changes safely: ${toExecMessage(error)}`);
254
257
  }
255
258
  }
256
- const gitArgs = options.detached
257
- ? [
258
- "worktree",
259
- "add",
260
- "--detach",
261
- worktreePath,
262
- ...(takeStartPoint ? [takeStartPoint] : []),
263
- ]
264
- : options.mode === "track"
265
- ? [
266
- "worktree",
267
- "add",
268
- "--track",
269
- "-b",
270
- worktreeName,
271
- worktreePath,
272
- `${options.remote ?? "origin"}/${worktreeName}`,
273
- ]
274
- : options.mode === "checkout" || existingLocalBranch
275
- ? ["worktree", "add", worktreePath, worktreeName]
276
- : [
277
- "worktree",
278
- "add",
279
- "-b",
280
- worktreeName,
281
- worktreePath,
282
- ...(takeStartPoint
283
- ? [takeStartPoint]
284
- : startPoint
285
- ? [startPoint]
286
- : freshBaseRef
287
- ? [freshBaseRef]
288
- : []),
289
- ];
259
+ const addsNewBranch = createsBranch || (!options.detached && options.mode === "track");
290
260
  try {
291
- await runtime.git.runGit(repository.repoRoot, gitArgs);
261
+ await runtime.git.addWorktree(repository.repoRoot, worktreePath, options.detached
262
+ ? { detachedAt: takeStartPoint }
263
+ : addsNewBranch
264
+ ? {
265
+ branch: worktreeName,
266
+ create: {
267
+ startPoint: options.mode === "track"
268
+ ? `${options.remote ?? "origin"}/${worktreeName}`
269
+ : (takeStartPoint ?? startPoint ?? freshBaseRef),
270
+ track: options.mode === "track",
271
+ },
272
+ }
273
+ : { branch: worktreeName });
292
274
  }
293
275
  catch (error) {
294
276
  if (stashSha)
@@ -297,17 +279,7 @@ export function createNewCommand(dependencies = {}) {
297
279
  }
298
280
  if (stashSha &&
299
281
  !(await applyTakeStash(worktreePath, options.cwd, stashSha, !!options.copy, options.stderr, runtime.git.runGit))) {
300
- try {
301
- await runtime.git.runGit(repository.repoRoot, [
302
- "worktree",
303
- "remove",
304
- "--force",
305
- worktreePath,
306
- ]);
307
- }
308
- catch {
309
- /* rollback best effort */
310
- }
282
+ await runtime.git.removeCreatedWorktree(repository.repoRoot, worktreePath, addsNewBranch ? worktreeName : undefined);
311
283
  return emitNewError(options, `could not apply taken changes; your changes are safe in stash: ${stashSha}`);
312
284
  }
313
285
  if (options.task !== undefined) {
@@ -338,6 +310,17 @@ export function createNewCommand(dependencies = {}) {
338
310
  syncFiles: bootstrap.syncFileFailures,
339
311
  });
340
312
  }
313
+ if (createsBranch) {
314
+ await runNewHooks({
315
+ branch: worktreeName,
316
+ config,
317
+ hookNames: ["after-branch-create"],
318
+ options,
319
+ path: worktreePath,
320
+ repository,
321
+ runtime,
322
+ });
323
+ }
341
324
  if (options.json) {
342
325
  const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName);
343
326
  const output = options.take
@@ -438,7 +421,7 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
438
421
  try {
439
422
  let startPoint = baseBranch;
440
423
  if (!options.noFetch) {
441
- await runtime.git.runGit(repository.repoRoot, [
424
+ await runtime.git.runGitRetryingContention(repository.repoRoot, [
442
425
  "fetch",
443
426
  "--prune",
444
427
  remote,
@@ -446,16 +429,29 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
446
429
  ]);
447
430
  startPoint = `${remote}/${baseBranch}`;
448
431
  }
449
- await runtime.git.runGit(options.cwd, [
450
- "switch",
451
- "-c",
452
- branchName,
432
+ await runtime.git.createBranch(repository.repoRoot, branchName, {
453
433
  startPoint,
454
- ]);
434
+ });
435
+ try {
436
+ await runtime.git.runGit(options.cwd, ["switch", branchName]);
437
+ }
438
+ catch (error) {
439
+ await runtime.git.deleteCreatedBranch(repository.repoRoot, branchName);
440
+ throw error;
441
+ }
455
442
  }
456
443
  catch (error) {
457
444
  return emitNewError(options, `failed to create branch in the current worktree: ${toExecMessage(error)}`);
458
445
  }
446
+ await runNewHooks({
447
+ branch: branchName,
448
+ config,
449
+ hookNames: ["after-branch-create", "after-switch"],
450
+ options,
451
+ path: repository.currentRoot,
452
+ repository,
453
+ runtime,
454
+ });
459
455
  if (options.json) {
460
456
  const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), repository.currentRoot, branchName);
461
457
  options.stdout(`${JSON.stringify({ ...navigation, baseBranch, branchOnly: true }, null, 2)}\n`);
@@ -467,6 +463,18 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
467
463
  }
468
464
  return 0;
469
465
  }
466
+ async function runNewHooks({ branch, config, hookNames, options, path, repository, runtime, }) {
467
+ const hooks = runtime.hooks.extractHooks(config);
468
+ const context = {
469
+ branch,
470
+ path,
471
+ repo: basename(repository.repoRoot),
472
+ slot: await runtime.slots.getWorktreeSlot(path),
473
+ };
474
+ for (const hookName of hookNames) {
475
+ await runtime.hooks.runHook(hooks[hookName], path, context, options.json ? () => undefined : options.stderr, options.json ? () => undefined : options.stdout);
476
+ }
477
+ }
470
478
  export const runNewCommand = createNewCommand();
471
479
  async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFailure, runtime) {
472
480
  const remote = runtime.config.resolveConfigString(config, "syncRemote") ?? "origin";
@@ -480,7 +488,7 @@ async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFail
480
488
  throw new Error("the remote default branch is unknown");
481
489
  }
482
490
  baseBranch = remoteBase.branch;
483
- await runtime.git.runGit(repoRoot, [
491
+ await runtime.git.runGitRetryingContention(repoRoot, [
484
492
  "fetch",
485
493
  "--prune",
486
494
  remote,
@@ -640,7 +648,7 @@ function generateBranchPlaceholderSuffix(random) {
640
648
  }
641
649
  return suffix;
642
650
  }
643
- function applyConfiguredBranchPrefix(branch, branchPrefix) {
651
+ export function applyConfiguredBranchPrefix(branch, branchPrefix) {
644
652
  if (typeof branchPrefix !== "string" || branchPrefix.length === 0) {
645
653
  return branch;
646
654
  }
@@ -26,7 +26,7 @@ export function createOpenCommand(dependencies = {}) {
26
26
  return 1;
27
27
  }
28
28
  if (options.select && isHeadless()) {
29
- options.stderr("gji open --select: selector is unavailable in non-interactive mode (GJI_NO_TUI=1)\n");
29
+ options.stderr("gji open --select: selector is unavailable in non-interactive mode; pass a branch instead\n");
30
30
  return 1;
31
31
  }
32
32
  const [worktrees, repository] = await Promise.all([
@@ -23,7 +23,7 @@ export function createPrOpenCommand(dependencies = {}) {
23
23
  return 1;
24
24
  }
25
25
  if (options.select && isHeadless()) {
26
- options.stderr("gji pr open --select: selector is unavailable in non-interactive mode (GJI_NO_TUI=1)\n");
26
+ options.stderr("gji pr open --select: selector is unavailable in non-interactive mode; pass a branch or PR number instead\n");
27
27
  return 1;
28
28
  }
29
29
  const repository = await detectRepository(options.cwd).catch((error) => {
@@ -236,7 +236,7 @@ async function openPullRequests(pullRequests, options, promptForPullRequest, ope
236
236
  return 1;
237
237
  }
238
238
  if (pullRequests.length > 1 && isHeadless()) {
239
- options.stderr("gji pr open: multiple open PRs found; pass an explicit PR number in non-interactive mode (GJI_NO_TUI=1)\n");
239
+ options.stderr("gji pr open: multiple open PRs found; pass an explicit PR number in non-interactive mode\n");
240
240
  return 1;
241
241
  }
242
242
  const selected = pullRequests.length === 1
@@ -54,7 +54,7 @@ export function createPrCommand(dependencies = {}) {
54
54
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
55
55
  }
56
56
  else {
57
- options.stderr(`gji pr: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
57
+ options.stderr(`gji pr: ${message} in non-interactive mode\n`);
58
58
  options.stderr(`Hint: Use 'gji done pr/${prNumber}' or 'gji clean' to remove the existing worktree\n`);
59
59
  }
60
60
  return 1;
@@ -108,10 +108,21 @@ export function createPrCommand(dependencies = {}) {
108
108
  }
109
109
  await mkdir(dirname(worktreePath), { recursive: true });
110
110
  const branchAlreadyExists = await localBranchExists(repository.repoRoot, branchName, runtime);
111
- const worktreeArgs = branchAlreadyExists
112
- ? ["worktree", "add", worktreePath, branchName]
113
- : ["worktree", "add", "-b", branchName, worktreePath, remoteRef];
114
- await runtime.git.runGit(repository.repoRoot, worktreeArgs);
111
+ try {
112
+ await runtime.git.addWorktree(repository.repoRoot, worktreePath, branchAlreadyExists
113
+ ? { branch: branchName }
114
+ : { branch: branchName, create: { startPoint: remoteRef } });
115
+ }
116
+ catch (error) {
117
+ const message = `failed to create worktree: ${error instanceof Error ? error.message : String(error)}`;
118
+ if (options.json) {
119
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
120
+ }
121
+ else {
122
+ options.stderr(`gji pr: ${message}\n`);
123
+ }
124
+ return 1;
125
+ }
115
126
  const bootstrap = await runtime.bootstrap.bootstrapWorktree({
116
127
  branch: branchName,
117
128
  config,
@@ -173,7 +184,7 @@ export const runPrCommand = createPrCommand();
173
184
  async function fetchPullRequestRef(repoRoot, input, prNumber, remoteRef, runtime) {
174
185
  for (const sourceRef of listPullRequestSourceRefs(input, prNumber)) {
175
186
  try {
176
- await runtime.git.runGit(repoRoot, [
187
+ await runtime.git.runGitRetryingContention(repoRoot, [
177
188
  "fetch",
178
189
  "origin",
179
190
  `${sourceRef}:${remoteRef}`,
@@ -1,12 +1,16 @@
1
1
  import { defaultCliDependencies } from "../dependencies.js";
2
2
  const VALID_HOOKS = [
3
+ "after-branch-create",
3
4
  "after-create",
4
5
  "after-enter",
6
+ "after-switch",
5
7
  "before-remove",
6
8
  ];
7
9
  const CAMEL_ALIASES = {
10
+ afterBranchCreate: "after-branch-create",
8
11
  afterCreate: "after-create",
9
12
  afterEnter: "after-enter",
13
+ afterSwitch: "after-switch",
10
14
  beforeRemove: "before-remove",
11
15
  };
12
16
  function isValidHook(hook) {
@@ -1,14 +1,11 @@
1
- import { access, mkdir, open, readFile, rename, stat, unlink, writeFile, } from "node:fs/promises";
1
+ import { access, readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { confirm, isCancel } from "@clack/prompts";
5
+ import { withFileLock, writeJsonAtomic, } from "../../infrastructure/filesystem/locked-file.js";
5
6
  import { defaultCliDependencies, } from "../dependencies.js";
6
7
  import { isHeadless } from "../runtime/headless.js";
7
8
  const MAX_UNDO_RECORDS = 20;
8
- const UNDO_LOCK_STALE_AFTER_MS = 60_000;
9
- const UNDO_LOCK_RETRY_DELAY_MS = 5;
10
- const UNDO_LOCK_WAIT_TIMEOUT_MS = UNDO_LOCK_STALE_AFTER_MS + UNDO_LOCK_RETRY_DELAY_MS;
11
- const undoLogQueues = new Map();
12
9
  export function undoLogPath(home = homedir(), globalConfigDirectory = defaultCliDependencies.configStore
13
10
  .GLOBAL_CONFIG_DIRECTORY) {
14
11
  const configured = process.env.GJI_CONFIG_DIR;
@@ -65,16 +62,16 @@ export async function recordUndoOperation(op, repoRoot, worktrees, home = homedi
65
62
  timestamp,
66
63
  entries,
67
64
  };
68
- return enqueueUndoLogWrite(home, async () => {
65
+ return withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
69
66
  const existing = await loadUndoRecords(home, globalConfigDirectory);
70
67
  await writeUndoRecords(home, [record, ...existing].slice(0, MAX_UNDO_RECORDS), globalConfigDirectory);
71
68
  return record;
72
- }, globalConfigDirectory);
69
+ });
73
70
  }
74
71
  export async function finalizeUndoOperation(record, removedWorktrees, home = homedir(), globalConfigDirectory = defaultCliDependencies.configStore
75
72
  .GLOBAL_CONFIG_DIRECTORY) {
76
73
  const removedPaths = new Set(removedWorktrees.map((worktree) => worktree.path));
77
- await enqueueUndoLogWrite(home, async () => {
74
+ await withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
78
75
  const records = await loadUndoRecords(home, globalConfigDirectory);
79
76
  const remaining = records.flatMap((candidate) => {
80
77
  if (candidate.id !== record.id)
@@ -83,7 +80,7 @@ export async function finalizeUndoOperation(record, removedWorktrees, home = hom
83
80
  return entries.length === 0 ? [] : [{ ...candidate, entries }];
84
81
  });
85
82
  await writeUndoRecords(home, remaining, globalConfigDirectory);
86
- }, globalConfigDirectory);
83
+ });
87
84
  }
88
85
  export async function restoreUndoRecord(record, home = homedir(), runtime = defaultCliDependencies) {
89
86
  const globalConfigDirectory = runtime.configStore.GLOBAL_CONFIG_DIRECTORY;
@@ -104,7 +101,7 @@ export async function restoreUndoRecord(record, home = homedir(), runtime = defa
104
101
  unresolved.push(entry);
105
102
  }
106
103
  }
107
- await enqueueUndoLogWrite(home, async () => {
104
+ await withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
108
105
  const records = await loadUndoRecords(home, globalConfigDirectory);
109
106
  const remaining = records.flatMap((candidate) => {
110
107
  if (candidate.id !== record.id)
@@ -114,7 +111,7 @@ export async function restoreUndoRecord(record, home = homedir(), runtime = defa
114
111
  return [{ ...candidate, entries: unresolved }];
115
112
  });
116
113
  await writeUndoRecords(home, remaining, globalConfigDirectory);
117
- }, globalConfigDirectory);
114
+ });
118
115
  return { restored, failed };
119
116
  }
120
117
  export async function runUndoCommand(options) {
@@ -292,70 +289,9 @@ async function assertCommitExists(repoRoot, headSha, runGit) {
292
289
  throw new Error("commit no longer exists (gc'd) — cannot restore");
293
290
  }
294
291
  }
295
- async function enqueueUndoLogWrite(home, operation, globalConfigDirectory = defaultCliDependencies.configStore
296
- .GLOBAL_CONFIG_DIRECTORY) {
297
- const path = undoLogPath(home, globalConfigDirectory);
298
- await mkdir(dirname(path), { recursive: true });
299
- const previous = undoLogQueues.get(path) ?? Promise.resolve();
300
- const runLocked = async () => {
301
- const release = await acquireUndoLogLock(path);
302
- try {
303
- return await operation();
304
- }
305
- finally {
306
- await release();
307
- }
308
- };
309
- const current = previous.then(runLocked, runLocked);
310
- const settled = current.then(() => undefined, () => undefined);
311
- undoLogQueues.set(path, settled);
312
- try {
313
- return await current;
314
- }
315
- finally {
316
- if (undoLogQueues.get(path) === settled)
317
- undoLogQueues.delete(path);
318
- }
319
- }
320
- async function acquireUndoLogLock(path) {
321
- const lockPath = `${path}.lock`;
322
- const deadline = Date.now() + UNDO_LOCK_WAIT_TIMEOUT_MS;
323
- while (Date.now() < deadline) {
324
- try {
325
- const handle = await open(lockPath, "wx");
326
- return async () => {
327
- await handle.close();
328
- await unlink(lockPath).catch(() => undefined);
329
- };
330
- }
331
- catch (error) {
332
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
333
- throw error;
334
- const lockStat = await stat(lockPath).catch(() => null);
335
- if (lockStat && Date.now() - lockStat.mtimeMs > UNDO_LOCK_STALE_AFTER_MS)
336
- await unlink(lockPath).catch(() => undefined);
337
- const remainingMs = deadline - Date.now();
338
- if (remainingMs > 0)
339
- await new Promise((resolve) => setTimeout(resolve, Math.min(UNDO_LOCK_RETRY_DELAY_MS, remainingMs)));
340
- }
341
- }
342
- throw new Error("timed out waiting for the undo journal lock");
343
- }
344
292
  async function writeUndoRecords(home, records, globalConfigDirectory = defaultCliDependencies.configStore
345
293
  .GLOBAL_CONFIG_DIRECTORY) {
346
- const path = undoLogPath(home, globalConfigDirectory);
347
- await mkdir(dirname(path), { recursive: true });
348
- const temporaryPath = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
349
- try {
350
- await writeFile(temporaryPath, `${JSON.stringify(records, null, 2)}\n`, {
351
- encoding: "utf8",
352
- flag: "wx",
353
- });
354
- await rename(temporaryPath, path);
355
- }
356
- finally {
357
- await unlink(temporaryPath).catch(() => undefined);
358
- }
294
+ await writeJsonAtomic(undoLogPath(home, globalConfigDirectory), records);
359
295
  }
360
296
  function isUndoRecord(value) {
361
297
  if (!value || typeof value !== "object")
@@ -12,7 +12,7 @@ import { clearTask, readTask, writeTask } from "../infrastructure/persistence/ta
12
12
  import { extractHooks, runHook } from "../infrastructure/process/hooks.js";
13
13
  import { loadRegistry, REGISTRY_FILE_PATH, registerRepo, removeMissingRegistryEntries } from "../infrastructure/repository/registry.js";
14
14
  import { readWorktreeInfo, readWorktreeInfos, serializeWorktreeInfo } from "../infrastructure/worktree/info.js";
15
- import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeWorktree } from "../infrastructure/worktree/lifecycle.js";
15
+ import { addWorktree, createBranch, deleteBranch, deleteCreatedBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeCreatedWorktree, removeWorktree } from "../infrastructure/worktree/lifecycle.js";
16
16
  import type { DependencyBootstrapPort, WorktreeBootstrapPort } from "../ports/bootstrap.js";
17
17
  import type { ConfigPort } from "../ports/config.js";
18
18
  import type { GitCommandPort } from "../ports/git.js";
@@ -42,6 +42,10 @@ export interface CliDependencies {
42
42
  runHook: typeof runHook;
43
43
  };
44
44
  git: GitCommandPort & {
45
+ addWorktree: typeof addWorktree;
46
+ createBranch: typeof createBranch;
47
+ deleteCreatedBranch: typeof deleteCreatedBranch;
48
+ removeCreatedWorktree: typeof removeCreatedWorktree;
45
49
  isDirtyWorktree: typeof isDirtyWorktree;
46
50
  readWorktreeHealth: typeof readWorktreeHealth;
47
51
  isBranchMergedInto: typeof isBranchMergedInto;
@@ -2,7 +2,7 @@ import { prepareDependencyBootstrap, previewDependencyBootstrap, resolveDependen
2
2
  import { validateSyncFilePattern } from "../infrastructure/filesystem/file-sync.js";
3
3
  import { isDirtyWorktree, readWorktreeHealth, } from "../infrastructure/git/health.js";
4
4
  import { isBranchMergedInto, readBranchLastCommitTimestamp, resolveRemoteBase, resolveRemoteDefaultBranch, } from "../infrastructure/git/refs.js";
5
- import { runGit, runGitRaw } from "../infrastructure/git/runner.js";
5
+ import { runGit, runGitRaw, runGitRetryingContention, } from "../infrastructure/git/runner.js";
6
6
  import { openBrowser } from "../infrastructure/integrations/browser.js";
7
7
  import { defaultSpawnEditor, EDITORS, } from "../infrastructure/integrations/editor.js";
8
8
  import { createPullRequestQuery } from "../infrastructure/integrations/pull-requests.js";
@@ -15,7 +15,7 @@ import { configPort, repositoryContextPort, repositoryRefPort, repositoryRegistr
15
15
  import { loadRegistry, REGISTRY_FILE_PATH, registerRepo, removeMissingRegistryEntries, } from "../infrastructure/repository/registry.js";
16
16
  import { bootstrapWorktree } from "../infrastructure/worktree/bootstrap.js";
17
17
  import { readWorktreeInfo, readWorktreeInfos, serializeWorktreeInfo, } from "../infrastructure/worktree/info.js";
18
- import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeWorktree, } from "../infrastructure/worktree/lifecycle.js";
18
+ import { addWorktree, createBranch, deleteBranch, deleteCreatedBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeCreatedWorktree, removeWorktree, } from "../infrastructure/worktree/lifecycle.js";
19
19
  import { formatLastCommit, formatRelativeAge, formatUpstreamState, } from "../presentation/worktree/format.js";
20
20
  export function createCliDependencies() {
21
21
  const pullRequests = createPullRequestQuery();
@@ -49,6 +49,11 @@ export function createCliDependencies() {
49
49
  git: {
50
50
  runGit,
51
51
  runGitRaw,
52
+ runGitRetryingContention,
53
+ addWorktree,
54
+ createBranch,
55
+ deleteCreatedBranch,
56
+ removeCreatedWorktree,
52
57
  isDirtyWorktree,
53
58
  readWorktreeHealth,
54
59
  isBranchMergedInto,
@@ -16,7 +16,6 @@ import { runNewCommand } from "./commands/new.js";
16
16
  import { runOpenCommand } from "./commands/open.js";
17
17
  import { runPrCommand } from "./commands/pr.js";
18
18
  import { createPrOpenCommand, } from "./commands/pr-open.js";
19
- import { runRemoveCommand } from "./commands/remove.js";
20
19
  import { runRootCommand } from "./commands/root.js";
21
20
  import { runHookCommand } from "./commands/run-hook.js";
22
21
  import { runStatusCommand } from "./commands/status.js";
@@ -24,7 +23,6 @@ import { runSyncCommand } from "./commands/sync.js";
24
23
  import { runSyncFilesCommand } from "./commands/sync-files-command.js";
25
24
  import { runTaskCommand } from "./commands/task-command.js";
26
25
  import { runUndoCommand } from "./commands/undo.js";
27
- import { runWarpCommand } from "./commands/warp.js";
28
26
  import { createCliDependencies } from "./dependencies.js";
29
27
  import { isHeadless } from "./runtime/headless.js";
30
28
  export function createProgram() {
@@ -295,30 +293,15 @@ function registerCommands(program) {
295
293
  .description("interactively prune linked worktrees")
296
294
  .option("-f, --force", "bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches")
297
295
  .option("--stale", "only target clean worktrees whose upstream is gone and branch is merged into the default branch")
296
+ .option("--path <pattern>", "only target worktrees under a directory or matching a path glob (repeatable)", collectOption)
298
297
  .option("--dry-run", "show what would be deleted without removing anything")
299
298
  .option("--json", "emit JSON on success or error instead of human-readable output")
300
299
  .action(notImplemented("clean"));
301
- program
302
- .command("remove [branch]")
303
- .alias("rm")
304
- .description("deprecated: use gji done for one worktree or gji clean for bulk cleanup")
305
- .option("-f, --force", "bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch")
306
- .option("--dry-run", "show what would be deleted without removing anything")
307
- .option("--json", "emit JSON on success or error instead of human-readable output")
308
- .action(notImplemented("remove"));
309
300
  program
310
301
  .command("run-hook <hook>")
311
302
  .alias("trigger-hook")
312
- .description("run a named hook (after-create, after-enter, before-remove) in the current worktree")
303
+ .description("run a named hook (after-branch-create, after-create, after-enter, after-switch, before-remove) in the current worktree")
313
304
  .action(notImplemented("run-hook"));
314
- program
315
- .command("warp [branch]")
316
- .description("deprecated: use gji go (press Tab for all known repos)")
317
- // --print is the shell-wrapper bypass signal (see SHELL_WRAPPED_COMMANDS in init.ts).
318
- // The shell omits GJI_WARP_OUTPUT_FILE, so writeShellOutput falls through to stdout.
319
- .option("--print", "print the resolved worktree path without changing directory")
320
- .option("--json", "emit JSON on success or error instead of human-readable output")
321
- .action(notImplemented("warp"));
322
305
  const configCommand = program
323
306
  .command("config")
324
307
  .description("manage global config defaults")
@@ -638,6 +621,7 @@ function attachCommandActions(program, options) {
638
621
  dryRun: commandOptions.dryRun,
639
622
  force: commandOptions.force,
640
623
  json: commandOptions.json,
624
+ paths: commandOptions.path,
641
625
  stale: commandOptions.stale,
642
626
  runtime: options.dependencies,
643
627
  stderr: options.stderr,
@@ -647,27 +631,6 @@ function attachCommandActions(program, options) {
647
631
  throw commanderExit(exitCode);
648
632
  }
649
633
  });
650
- const runRemovalCommand = async (branch, commandOptions = {}) => {
651
- const exitCode = await runRemoveCommand({
652
- branch,
653
- cwd: options.cwd,
654
- dryRun: commandOptions.dryRun,
655
- force: commandOptions.force,
656
- json: commandOptions.json,
657
- runtime: options.dependencies,
658
- stderr: options.stderr,
659
- stdout: options.stdout,
660
- });
661
- if (exitCode !== 0) {
662
- throw commanderExit(exitCode);
663
- }
664
- };
665
- requireCommand(program, "remove").action(async (branch, commandOptions) => {
666
- if (!commandOptions.json) {
667
- options.stderr("gji remove is deprecated; use 'gji done' for one worktree or 'gji clean' for bulk cleanup.\n");
668
- }
669
- return runRemovalCommand(branch, commandOptions);
670
- });
671
634
  requireCommand(program, "run-hook").action(async (hook) => {
672
635
  const exitCode = await runHookCommand({
673
636
  cwd: options.cwd,
@@ -679,22 +642,6 @@ function attachCommandActions(program, options) {
679
642
  throw commanderExit(exitCode);
680
643
  }
681
644
  });
682
- requireCommand(program, "warp").action(async (branch, commandOptions) => {
683
- if (!commandOptions.json) {
684
- options.stderr("gji warp is deprecated; use 'gji go' (press Tab for all repositories).\n");
685
- }
686
- const exitCode = await runWarpCommand({
687
- branch,
688
- cwd: options.cwd,
689
- json: commandOptions.json,
690
- runtime: options.dependencies,
691
- stderr: options.stderr,
692
- stdout: options.stdout,
693
- });
694
- if (exitCode !== 0) {
695
- throw commanderExit(exitCode);
696
- }
697
- });
698
645
  const configCommand = requireCommand(program, "config");
699
646
  configCommand.action(async () => {
700
647
  const exitCode = await runConfigCommand({
@@ -752,6 +699,9 @@ function requireCommand(parent, name) {
752
699
  }
753
700
  return command;
754
701
  }
702
+ function collectOption(value, previous = []) {
703
+ return [...previous, value];
704
+ }
755
705
  function notImplemented(commandName) {
756
706
  return () => {
757
707
  throw new Error(`'${commandName}' is not implemented yet.`);
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Returns true when running in a non-interactive (headless) environment.
3
- * Set GJI_NO_TUI=1 to disable all interactive prompts.
4
- * Commands that would otherwise hang waiting for input must fail fast instead.
2
+ * Returns true when no one can answer a prompt: GJI_NO_TUI=1 is set, or stdin
3
+ * or stdout is not a terminal.
4
+ * Commands that would otherwise wait for input must fail fast instead.
5
5
  */
6
6
  export declare function isHeadless(): boolean;
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Returns true when running in a non-interactive (headless) environment.
3
- * Set GJI_NO_TUI=1 to disable all interactive prompts.
4
- * Commands that would otherwise hang waiting for input must fail fast instead.
2
+ * Returns true when no one can answer a prompt: GJI_NO_TUI=1 is set, or stdin
3
+ * or stdout is not a terminal.
4
+ * Commands that would otherwise wait for input must fail fast instead.
5
5
  */
6
6
  export function isHeadless() {
7
- return process.env.GJI_NO_TUI === "1";
7
+ return (process.env.GJI_NO_TUI === "1" ||
8
+ process.stdin.isTTY !== true ||
9
+ process.stdout.isTTY !== true);
8
10
  }