@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
@@ -3526,7 +3526,7 @@ var require_polyfills = __commonJS({
3526
3526
  }
3527
3527
  if (platform3 === "win32") {
3528
3528
  fs5.rename = typeof fs5.rename !== "function" ? fs5.rename : (function(fs$rename) {
3529
- function rename3(from, to, cb) {
3529
+ function rename2(from, to, cb) {
3530
3530
  var start = Date.now();
3531
3531
  var backoff = 0;
3532
3532
  fs$rename(from, to, function CB(er) {
@@ -3546,8 +3546,8 @@ var require_polyfills = __commonJS({
3546
3546
  if (cb) cb(er);
3547
3547
  });
3548
3548
  }
3549
- if (Object.setPrototypeOf) Object.setPrototypeOf(rename3, fs$rename);
3550
- return rename3;
3549
+ if (Object.setPrototypeOf) Object.setPrototypeOf(rename2, fs$rename);
3550
+ return rename2;
3551
3551
  })(fs5.rename);
3552
3552
  }
3553
3553
  fs5.read = typeof fs5.read !== "function" ? fs5.read : (function(fs$read) {
@@ -3968,8 +3968,8 @@ var require_graceful_fs = __commonJS({
3968
3968
  }
3969
3969
  }
3970
3970
  var fs$writeFile = fs6.writeFile;
3971
- fs6.writeFile = writeFile10;
3972
- function writeFile10(path9, data, options, cb) {
3971
+ fs6.writeFile = writeFile7;
3972
+ function writeFile7(path9, data, options, cb) {
3973
3973
  if (typeof options === "function")
3974
3974
  cb = options, options = null;
3975
3975
  return go$writeFile(path9, data, options, cb);
@@ -5386,7 +5386,7 @@ var require_minimist = __commonJS({
5386
5386
  var require_rc = __commonJS({
5387
5387
  "node_modules/.pnpm/rc@1.2.8/node_modules/rc/index.js"(exports, module) {
5388
5388
  var cc = require_utils();
5389
- var join16 = __require("path").join;
5389
+ var join17 = __require("path").join;
5390
5390
  var deepExtend = require_deep_extend();
5391
5391
  var etc = "/etc";
5392
5392
  var win = process.platform === "win32";
@@ -5411,15 +5411,15 @@ var require_rc = __commonJS({
5411
5411
  }
5412
5412
  if (!win)
5413
5413
  [
5414
- join16(etc, name, "config"),
5415
- join16(etc, name + "rc")
5414
+ join17(etc, name, "config"),
5415
+ join17(etc, name + "rc")
5416
5416
  ].forEach(addConfigFile);
5417
5417
  if (home)
5418
5418
  [
5419
- join16(home, ".config", name, "config"),
5420
- join16(home, ".config", name),
5421
- join16(home, "." + name, "config"),
5422
- join16(home, "." + name + "rc")
5419
+ join17(home, ".config", name, "config"),
5420
+ join17(home, ".config", name),
5421
+ join17(home, "." + name, "config"),
5422
+ join17(home, "." + name + "rc")
5423
5423
  ].forEach(addConfigFile);
5424
5424
  addConfigFile(cc.find("." + name + "rc"));
5425
5425
  if (env5.config) addConfigFile(env5.config);
@@ -5500,7 +5500,7 @@ var require_polyfills2 = __commonJS({
5500
5500
  }
5501
5501
  if (platform3 === "win32") {
5502
5502
  fs5.rename = typeof fs5.rename !== "function" ? fs5.rename : (function(fs$rename) {
5503
- function rename3(from, to, cb) {
5503
+ function rename2(from, to, cb) {
5504
5504
  var start = Date.now();
5505
5505
  var backoff = 0;
5506
5506
  fs$rename(from, to, function CB(er) {
@@ -5520,8 +5520,8 @@ var require_polyfills2 = __commonJS({
5520
5520
  if (cb) cb(er);
5521
5521
  });
5522
5522
  }
5523
- if (Object.setPrototypeOf) Object.setPrototypeOf(rename3, fs$rename);
5524
- return rename3;
5523
+ if (Object.setPrototypeOf) Object.setPrototypeOf(rename2, fs$rename);
5524
+ return rename2;
5525
5525
  })(fs5.rename);
5526
5526
  }
5527
5527
  fs5.read = typeof fs5.read !== "function" ? fs5.read : (function(fs$read) {
@@ -5942,8 +5942,8 @@ var require_graceful_fs2 = __commonJS({
5942
5942
  }
5943
5943
  }
5944
5944
  var fs$writeFile = fs6.writeFile;
5945
- fs6.writeFile = writeFile10;
5946
- function writeFile10(path9, data, options, cb) {
5945
+ fs6.writeFile = writeFile7;
5946
+ function writeFile7(path9, data, options, cb) {
5947
5947
  if (typeof options === "function")
5948
5948
  cb = options, options = null;
5949
5949
  return go$writeFile(path9, data, options, cb);
@@ -6757,11 +6757,11 @@ var require_util = __commonJS({
6757
6757
  if (files.includes("node_modules") || files.includes("package.json") || files.includes("package.json5") || files.includes("package.yaml") || files.includes("pnpm-workspace.yaml")) {
6758
6758
  return name2;
6759
6759
  }
6760
- const dirname14 = path9.dirname(name2);
6761
- if (dirname14 === name2) {
6760
+ const dirname12 = path9.dirname(name2);
6761
+ if (dirname12 === name2) {
6762
6762
  return original;
6763
6763
  }
6764
- return find(dirname14, original);
6764
+ return find(dirname12, original);
6765
6765
  } catch (error) {
6766
6766
  if (name2 === original) {
6767
6767
  if (error.code === "ENOENT") {
@@ -9517,9 +9517,9 @@ var retryifyAsync = (fn, options) => {
9517
9517
  throw error;
9518
9518
  if (Date.now() >= timestamp)
9519
9519
  throw error;
9520
- const delay2 = Math.round(interval * Math.random());
9521
- if (delay2 > 0) {
9522
- const delayPromise = new Promise((resolve12) => setTimeout(resolve12, delay2));
9520
+ const delay3 = Math.round(interval * Math.random());
9521
+ if (delay3 > 0) {
9522
+ const delayPromise = new Promise((resolve13) => setTimeout(resolve13, delay3));
9523
9523
  return delayPromise.then(() => attempt.apply(void 0, args));
9524
9524
  } else {
9525
9525
  return attempt.apply(void 0, args);
@@ -9778,14 +9778,14 @@ var Temp = {
9778
9778
  }
9779
9779
  },
9780
9780
  truncate: (filePath) => {
9781
- const basename13 = path2.basename(filePath);
9782
- if (basename13.length <= LIMIT_BASENAME_LENGTH)
9781
+ const basename14 = path2.basename(filePath);
9782
+ if (basename14.length <= LIMIT_BASENAME_LENGTH)
9783
9783
  return filePath;
9784
- const truncable = /^(\.?)(.*?)((?:\.[^.]+)?(?:\.tmp-\d{10}[a-f0-9]{6})?)$/.exec(basename13);
9784
+ const truncable = /^(\.?)(.*?)((?:\.[^.]+)?(?:\.tmp-\d{10}[a-f0-9]{6})?)$/.exec(basename14);
9785
9785
  if (!truncable)
9786
9786
  return filePath;
9787
- const truncationLength = basename13.length - LIMIT_BASENAME_LENGTH;
9788
- return `${filePath.slice(0, -basename13.length)}${truncable[1]}${truncable[2].slice(0, -truncationLength)}${truncable[3]}`;
9787
+ const truncationLength = basename14.length - LIMIT_BASENAME_LENGTH;
9788
+ return `${filePath.slice(0, -basename14.length)}${truncable[1]}${truncable[2].slice(0, -truncationLength)}${truncable[3]}`;
9789
9789
  }
9790
9790
  };
9791
9791
  node_default(Temp.purgeSyncAll);
@@ -11085,14 +11085,14 @@ var TimeoutError = class extends Error {
11085
11085
 
11086
11086
  // node_modules/.pnpm/ky@1.14.3/node_modules/ky/distribution/utils/timeout.js
11087
11087
  async function timeout(request, init, abortController, options) {
11088
- return new Promise((resolve12, reject) => {
11088
+ return new Promise((resolve13, reject) => {
11089
11089
  const timeoutId = setTimeout(() => {
11090
11090
  if (abortController) {
11091
11091
  abortController.abort();
11092
11092
  }
11093
11093
  reject(new TimeoutError(request));
11094
11094
  }, options.timeout);
11095
- void options.fetch(request, init).then(resolve12).catch(reject).then(() => {
11095
+ void options.fetch(request, init).then(resolve13).catch(reject).then(() => {
11096
11096
  clearTimeout(timeoutId);
11097
11097
  });
11098
11098
  });
@@ -11100,7 +11100,7 @@ async function timeout(request, init, abortController, options) {
11100
11100
 
11101
11101
  // node_modules/.pnpm/ky@1.14.3/node_modules/ky/distribution/utils/delay.js
11102
11102
  async function delay(ms, { signal }) {
11103
- return new Promise((resolve12, reject) => {
11103
+ return new Promise((resolve13, reject) => {
11104
11104
  if (signal) {
11105
11105
  signal.throwIfAborted();
11106
11106
  signal.addEventListener("abort", abortHandler, { once: true });
@@ -11111,7 +11111,7 @@ async function delay(ms, { signal }) {
11111
11111
  }
11112
11112
  const timeoutId = setTimeout(() => {
11113
11113
  signal?.removeEventListener("abort", abortHandler);
11114
- resolve12();
11114
+ resolve13();
11115
11115
  }, ms);
11116
11116
  });
11117
11117
  }
@@ -12810,8 +12810,8 @@ function updateNotifier(options) {
12810
12810
  // package.json
12811
12811
  var package_default = {
12812
12812
  name: "@solaqua/gji",
12813
- version: "0.14.0",
12814
- description: "Git worktree CLI for fast context switching.",
12813
+ version: "0.16.0",
12814
+ description: "Git worktree CLI for switching tasks without disturbing your work.",
12815
12815
  license: "MIT",
12816
12816
  author: "sjquant",
12817
12817
  type: "module",
@@ -12875,6 +12875,7 @@ var package_default = {
12875
12875
  devDependencies: {
12876
12876
  "@biomejs/biome": "^2.4.15",
12877
12877
  "@j178/prek": "^0.3.13",
12878
+ "@resvg/resvg-js": "^2.6.2",
12878
12879
  "@types/node": "^24.6.0",
12879
12880
  "@vitest/coverage-v8": "3.2.4",
12880
12881
  esbuild: "^0.27.0",
@@ -13093,7 +13094,7 @@ async function ensureDestinationDirectory(root, path9) {
13093
13094
  // src/infrastructure/process/command-runner.ts
13094
13095
  import { spawn as spawn2 } from "node:child_process";
13095
13096
  var runCommand = async (command, cwd, stderr, stdout3 = (chunk) => process.stdout.write(chunk), options) => {
13096
- await new Promise((resolve12, reject) => {
13097
+ await new Promise((resolve13, reject) => {
13097
13098
  const shell = options?.shell ?? true;
13098
13099
  const [executable, args] = shell ? [command, []] : splitCommand(command);
13099
13100
  const child = spawn2(executable, args, {
@@ -13112,7 +13113,7 @@ var runCommand = async (command, cwd, stderr, stdout3 = (chunk) => process.stdou
13112
13113
  if (code !== 0) {
13113
13114
  reject(new Error(`exited with code ${code}`));
13114
13115
  } else {
13115
- resolve12();
13116
+ resolve13();
13116
13117
  }
13117
13118
  });
13118
13119
  child.on("error", reject);
@@ -13859,18 +13860,44 @@ import { promisify as promisify5 } from "node:util";
13859
13860
  import { execFile as execFile3 } from "node:child_process";
13860
13861
  import { promisify as promisify4 } from "node:util";
13861
13862
  var execFileAsync3 = promisify4(execFile3);
13863
+ var CONTENTION_RETRY_TIMEOUT_MS = 1e4;
13864
+ var CONTENTION_RETRY_MAX_DELAY_MS = 1e3;
13865
+ var GIT_ENV = { ...process.env, LC_ALL: "C" };
13862
13866
  async function runGit(cwd, args) {
13863
13867
  return (await runGitRaw(cwd, args)).trim();
13864
13868
  }
13865
13869
  async function runGitRaw(cwd, args) {
13870
+ return execGit(cwd, args);
13871
+ }
13872
+ async function runGitRetryingContention(cwd, args) {
13873
+ return (await retryGitContention(() => execGit(cwd, args, GIT_ENV))).trim();
13874
+ }
13875
+ async function retryGitContention(operation, timeoutMs = CONTENTION_RETRY_TIMEOUT_MS) {
13876
+ const deadline = Date.now() + timeoutMs;
13877
+ for (let delayMs = 25; ; delayMs *= 2) {
13878
+ try {
13879
+ return await operation();
13880
+ } catch (error) {
13881
+ if (!isTransientGitError(error) || Date.now() >= deadline) throw error;
13882
+ }
13883
+ const jitteredDelayMs = Math.min(delayMs, CONTENTION_RETRY_MAX_DELAY_MS) * (0.5 + Math.random() / 2);
13884
+ await new Promise((resolve13) => setTimeout(resolve13, jitteredDelayMs));
13885
+ }
13886
+ }
13887
+ async function execGit(cwd, args, env5) {
13866
13888
  try {
13867
- const { stdout: stdout3 } = await execFileAsync3("git", args, { cwd });
13889
+ const { stdout: stdout3 } = await execFileAsync3("git", args, { cwd, env: env5 });
13868
13890
  return stdout3;
13869
13891
  } catch (error) {
13870
13892
  const message = error instanceof Error ? error.message : String(error);
13871
13893
  throw new Error(`Git command failed in '${cwd}': ${message}`);
13872
13894
  }
13873
13895
  }
13896
+ function isTransientGitError(error) {
13897
+ return error instanceof Error && /(could not lock config file .*|Unable to create '.*\.lock'): File exists|cannot lock ref .*: is at .* but expected |failed to read .*\/commondir|Invalid path '.*\/worktrees\//.test(
13898
+ error.message
13899
+ );
13900
+ }
13874
13901
 
13875
13902
  // src/infrastructure/git/refs.ts
13876
13903
  var execFileAsync4 = promisify5(execFile4);
@@ -14019,9 +14046,9 @@ var EDITORS = [
14019
14046
  ];
14020
14047
  async function defaultSpawnEditor(cli, args) {
14021
14048
  const child = spawn3(cli, args, { detached: true, stdio: "ignore" });
14022
- await new Promise((resolve12, reject) => {
14049
+ await new Promise((resolve13, reject) => {
14023
14050
  child.once("error", reject);
14024
- child.once("spawn", resolve12);
14051
+ child.once("spawn", resolve13);
14025
14052
  });
14026
14053
  child.unref();
14027
14054
  }
@@ -14690,9 +14717,72 @@ function levenshtein(a, b3) {
14690
14717
  }
14691
14718
 
14692
14719
  // src/infrastructure/persistence/history.ts
14693
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
14720
+ import { readFile as readFile3 } from "node:fs/promises";
14694
14721
  import { homedir as homedir2 } from "node:os";
14695
- import { dirname as dirname4, join as join6, resolve as resolve6 } from "node:path";
14722
+ import { join as join6, resolve as resolve6 } from "node:path";
14723
+
14724
+ // src/infrastructure/filesystem/locked-file.ts
14725
+ import { randomUUID } from "node:crypto";
14726
+ import {
14727
+ mkdir as mkdir3,
14728
+ open as open3,
14729
+ rename,
14730
+ rm,
14731
+ stat,
14732
+ unlink,
14733
+ writeFile as writeFile3
14734
+ } from "node:fs/promises";
14735
+ import { dirname as dirname4 } from "node:path";
14736
+ import { setTimeout as delay2 } from "node:timers/promises";
14737
+ var LOCK_STALE_AFTER_MS = 1e4;
14738
+ var LOCK_RETRY_DELAY_MS = 5;
14739
+ var LOCK_WAIT_TIMEOUT_MS = LOCK_STALE_AFTER_MS + LOCK_RETRY_DELAY_MS;
14740
+ async function withFileLock(path9, operation) {
14741
+ await mkdir3(dirname4(path9), { recursive: true });
14742
+ const release = await acquireFileLock(path9);
14743
+ try {
14744
+ return await operation();
14745
+ } finally {
14746
+ await release();
14747
+ }
14748
+ }
14749
+ async function writeJsonAtomic(path9, value) {
14750
+ const temporaryPath = `${path9}.tmp-${process.pid}-${randomUUID()}`;
14751
+ try {
14752
+ await writeFile3(
14753
+ temporaryPath,
14754
+ `${JSON.stringify(value, null, 2)}
14755
+ `,
14756
+ "utf8"
14757
+ );
14758
+ await rename(temporaryPath, path9);
14759
+ } catch (error) {
14760
+ await rm(temporaryPath, { force: true }).catch(() => void 0);
14761
+ throw error;
14762
+ }
14763
+ }
14764
+ async function acquireFileLock(path9) {
14765
+ const lockPath = `${path9}.lock`;
14766
+ const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
14767
+ while (Date.now() < deadline) {
14768
+ try {
14769
+ const handle = await open3(lockPath, "wx");
14770
+ return async () => {
14771
+ await handle.close();
14772
+ await unlink(lockPath).catch(() => void 0);
14773
+ };
14774
+ } catch (error) {
14775
+ if (!isAlreadyExistsError(error)) throw error;
14776
+ const lockStat = await stat(lockPath).catch(() => null);
14777
+ if (lockStat && Date.now() - lockStat.mtimeMs > LOCK_STALE_AFTER_MS)
14778
+ await unlink(lockPath).catch(() => void 0);
14779
+ await delay2(LOCK_RETRY_DELAY_MS);
14780
+ }
14781
+ }
14782
+ throw new Error(`timed out waiting for the lock on ${path9}`);
14783
+ }
14784
+
14785
+ // src/infrastructure/persistence/history.ts
14696
14786
  var HISTORY_FILE_NAME = "history.json";
14697
14787
  var MAX_HISTORY_ENTRIES = 50;
14698
14788
  function HISTORY_FILE_PATH(home = homedir2()) {
@@ -14715,15 +14805,15 @@ async function loadHistory(home = homedir2()) {
14715
14805
  }
14716
14806
  async function appendHistory(path9, branch, home = homedir2()) {
14717
14807
  const historyPath = HISTORY_FILE_PATH(home);
14718
- const existing = await loadHistory(home);
14719
- const entry = { branch, path: path9, timestamp: Date.now() };
14720
- const next = [
14721
- entry,
14722
- ...existing.filter((existingEntry) => existingEntry.path !== path9)
14723
- ].slice(0, MAX_HISTORY_ENTRIES);
14724
- await mkdir3(dirname4(historyPath), { recursive: true });
14725
- await writeFile3(historyPath, `${JSON.stringify(next, null, 2)}
14726
- `, "utf8");
14808
+ await withFileLock(historyPath, async () => {
14809
+ const existing = await loadHistory(home);
14810
+ const entry = { branch, path: path9, timestamp: Date.now() };
14811
+ const next = [
14812
+ entry,
14813
+ ...existing.filter((existingEntry) => existingEntry.path !== path9)
14814
+ ].slice(0, MAX_HISTORY_ENTRIES);
14815
+ await writeJsonAtomic(historyPath, next);
14816
+ });
14727
14817
  }
14728
14818
  async function recordWorktreeUsage(path9, branch, home = homedir2()) {
14729
14819
  try {
@@ -14736,9 +14826,9 @@ function isHistoryEntry(value) {
14736
14826
  }
14737
14827
 
14738
14828
  // src/infrastructure/persistence/slots.ts
14739
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises";
14829
+ import { readFile as readFile4 } from "node:fs/promises";
14740
14830
  import { homedir as homedir3 } from "node:os";
14741
- import { dirname as dirname5, join as join7, resolve as resolve7 } from "node:path";
14831
+ import { join as join7, resolve as resolve7 } from "node:path";
14742
14832
  function SLOTS_FILE_PATH(home = homedir3()) {
14743
14833
  const configDir = process.env.GJI_CONFIG_DIR;
14744
14834
  return configDir ? join7(resolve7(configDir), "slots.json") : join7(home, GLOBAL_CONFIG_DIRECTORY, "slots.json");
@@ -14755,32 +14845,33 @@ async function loadSlots(home = homedir3()) {
14755
14845
  }
14756
14846
  }
14757
14847
  async function assignWorktreeSlot(repoRoot, worktreePath, home = homedir3()) {
14758
- const slots = await loadSlots(home);
14759
- const existing = slots[worktreePath];
14760
- if (existing !== void 0) return existing;
14761
- if (worktreePath !== repoRoot && slots[repoRoot] === void 0)
14762
- slots[repoRoot] = 0;
14763
- const used = new Set(Object.values(slots));
14764
- const slot = worktreePath === repoRoot ? 0 : nextAvailableSlot(used);
14765
- slots[worktreePath] = slot;
14766
- await saveSlots(slots, home);
14767
- return slot;
14848
+ return withFileLock(SLOTS_FILE_PATH(home), async () => {
14849
+ const slots = await loadSlots(home);
14850
+ const existing = slots[worktreePath];
14851
+ if (existing !== void 0) return existing;
14852
+ if (worktreePath !== repoRoot && slots[repoRoot] === void 0)
14853
+ slots[repoRoot] = 0;
14854
+ const used = new Set(Object.values(slots));
14855
+ const slot = worktreePath === repoRoot ? 0 : nextAvailableSlot(used);
14856
+ slots[worktreePath] = slot;
14857
+ await saveSlots(slots, home);
14858
+ return slot;
14859
+ });
14768
14860
  }
14769
14861
  async function getWorktreeSlot(worktreePath, home = homedir3()) {
14770
14862
  const slot = (await loadSlots(home))[worktreePath];
14771
14863
  return slot === void 0 ? null : slot;
14772
14864
  }
14773
14865
  async function releaseWorktreeSlot(worktreePath, home = homedir3()) {
14774
- const slots = await loadSlots(home);
14775
- if (!(worktreePath in slots)) return;
14776
- delete slots[worktreePath];
14777
- await saveSlots(slots, home);
14866
+ await withFileLock(SLOTS_FILE_PATH(home), async () => {
14867
+ const slots = await loadSlots(home);
14868
+ if (!(worktreePath in slots)) return;
14869
+ delete slots[worktreePath];
14870
+ await saveSlots(slots, home);
14871
+ });
14778
14872
  }
14779
14873
  async function saveSlots(slots, home) {
14780
- const path9 = SLOTS_FILE_PATH(home);
14781
- await mkdir4(dirname5(path9), { recursive: true });
14782
- await writeFile4(path9, `${JSON.stringify(slots, null, 2)}
14783
- `, "utf8");
14874
+ await writeJsonAtomic(SLOTS_FILE_PATH(home), slots);
14784
14875
  }
14785
14876
  function nextAvailableSlot(used) {
14786
14877
  let slot = 1;
@@ -14792,16 +14883,16 @@ function isSlotStore(value) {
14792
14883
  }
14793
14884
 
14794
14885
  // src/infrastructure/persistence/task.ts
14795
- import { readFile as readFile5, unlink, writeFile as writeFile5 } from "node:fs/promises";
14886
+ import { readFile as readFile5, unlink as unlink2, writeFile as writeFile4 } from "node:fs/promises";
14796
14887
  import { join as join8 } from "node:path";
14797
14888
 
14798
14889
  // src/infrastructure/repository/context.ts
14799
- import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute4, resolve as resolve8 } from "node:path";
14890
+ import { basename as basename3, dirname as dirname5, isAbsolute as isAbsolute4, resolve as resolve8 } from "node:path";
14800
14891
  async function detectRepository(cwd) {
14801
14892
  const currentRoot = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
14802
14893
  const rawCommonDir = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
14803
14894
  const gitCommonDir = isAbsolute4(rawCommonDir) ? rawCommonDir : resolve8(currentRoot, rawCommonDir);
14804
- const repoRoot = dirname6(gitCommonDir);
14895
+ const repoRoot = dirname5(gitCommonDir);
14805
14896
  return {
14806
14897
  currentRoot,
14807
14898
  isWorktree: currentRoot !== repoRoot,
@@ -14828,13 +14919,13 @@ async function readTask(worktreePath) {
14828
14919
  async function writeTask(worktreePath, task, source = "manual") {
14829
14920
  const metadata = { source, task, updatedAt: Date.now() };
14830
14921
  const path9 = await taskPath(worktreePath);
14831
- await writeFile5(path9, `${JSON.stringify(metadata, null, 2)}
14922
+ await writeFile4(path9, `${JSON.stringify(metadata, null, 2)}
14832
14923
  `, "utf8");
14833
14924
  return metadata;
14834
14925
  }
14835
14926
  async function clearTask(worktreePath) {
14836
14927
  try {
14837
- await unlink(await taskPath(worktreePath));
14928
+ await unlink2(await taskPath(worktreePath));
14838
14929
  } catch {
14839
14930
  }
14840
14931
  }
@@ -14863,7 +14954,7 @@ async function runArgvHook(hookCmd, cwd, context, stderr, stdout3) {
14863
14954
  stderr("gji: hook argv command must include a non-empty command\n");
14864
14955
  return;
14865
14956
  }
14866
- await new Promise((resolve12) => {
14957
+ await new Promise((resolve13) => {
14867
14958
  const child = spawn4(command, args, {
14868
14959
  cwd,
14869
14960
  shell: false,
@@ -14883,18 +14974,18 @@ async function runArgvHook(hookCmd, cwd, context, stderr, stdout3) {
14883
14974
  `
14884
14975
  );
14885
14976
  }
14886
- resolve12();
14977
+ resolve13();
14887
14978
  });
14888
14979
  child.on("error", (err) => {
14889
14980
  stderr(`gji: hook failed to start: ${err.message}
14890
14981
  `);
14891
- resolve12();
14982
+ resolve13();
14892
14983
  });
14893
14984
  });
14894
14985
  }
14895
14986
  async function runShellHook(hookCmd, cwd, context, stderr, stdout3) {
14896
14987
  const interpolated = interpolate(hookCmd, context);
14897
- await new Promise((resolve12) => {
14988
+ await new Promise((resolve13) => {
14898
14989
  const child = spawn4(interpolated, {
14899
14990
  cwd,
14900
14991
  shell: true,
@@ -14912,12 +15003,12 @@ async function runShellHook(hookCmd, cwd, context, stderr, stdout3) {
14912
15003
  stderr(`gji: hook exited with code ${code}: ${interpolated}
14913
15004
  `);
14914
15005
  }
14915
- resolve12();
15006
+ resolve13();
14916
15007
  });
14917
15008
  child.on("error", (err) => {
14918
15009
  stderr(`gji: hook failed to start: ${err.message}
14919
15010
  `);
14920
- resolve12();
15011
+ resolve13();
14921
15012
  });
14922
15013
  });
14923
15014
  }
@@ -14946,10 +15037,16 @@ function extractHooks(config) {
14946
15037
  }
14947
15038
  const hooks = raw;
14948
15039
  return {
15040
+ "after-branch-create": parseHookCommand(
15041
+ hooks["after-branch-create"] ?? hooks.afterBranchCreate
15042
+ ),
14949
15043
  "after-create": parseHookCommand(
14950
15044
  hooks["after-create"] ?? hooks.afterCreate
14951
15045
  ),
14952
15046
  "after-enter": parseHookCommand(hooks["after-enter"] ?? hooks.afterEnter),
15047
+ "after-switch": parseHookCommand(
15048
+ hooks["after-switch"] ?? hooks.afterSwitch
15049
+ ),
14953
15050
  "before-remove": parseHookCommand(
14954
15051
  hooks["before-remove"] ?? hooks.beforeRemove
14955
15052
  )
@@ -14999,22 +15096,12 @@ async function getRepositoryRemoteUrl(repoRoot, remote) {
14999
15096
  }
15000
15097
 
15001
15098
  // src/infrastructure/repository/registry.ts
15002
- import { randomUUID } from "node:crypto";
15003
15099
  import { constants as constants4 } from "node:fs";
15004
- import {
15005
- access,
15006
- mkdir as mkdir5,
15007
- readFile as readFile6,
15008
- realpath as realpath4,
15009
- rename,
15010
- rm,
15011
- writeFile as writeFile6
15012
- } from "node:fs/promises";
15100
+ import { access, readFile as readFile6, realpath as realpath4 } from "node:fs/promises";
15013
15101
  import { homedir as homedir4 } from "node:os";
15014
- import { basename as basename4, dirname as dirname7, join as join9, resolve as resolve9 } from "node:path";
15102
+ import { basename as basename4, join as join9, resolve as resolve9 } from "node:path";
15015
15103
  var REGISTRY_FILE_NAME = "repos.json";
15016
15104
  var MAX_REGISTRY_ENTRIES = 100;
15017
- var registryLocks = /* @__PURE__ */ new Map();
15018
15105
  function REGISTRY_FILE_PATH(home = homedir4()) {
15019
15106
  const configDir = process.env.GJI_CONFIG_DIR;
15020
15107
  if (configDir) {
@@ -15042,9 +15129,10 @@ async function canonicalizeRepoPath(repoPath) {
15042
15129
  }
15043
15130
  async function registerRepo(repoPath, home = homedir4()) {
15044
15131
  const registryPath = REGISTRY_FILE_PATH(home);
15045
- await withRegistryLock(registryPath, async () => {
15132
+ const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
15133
+ if ((await loadRegistry(home))[0]?.path === canonicalRepoPath) return;
15134
+ await withFileLock(registryPath, async () => {
15046
15135
  const existing = await normalizeRegistryForWrite(await loadRegistry(home));
15047
- const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
15048
15136
  if (existing.length > 0 && existing[0].path === canonicalRepoPath) return;
15049
15137
  const entry = {
15050
15138
  lastUsed: Date.now(),
@@ -15053,13 +15141,13 @@ async function registerRepo(repoPath, home = homedir4()) {
15053
15141
  };
15054
15142
  const filtered = existing.filter((e2) => e2.path !== canonicalRepoPath);
15055
15143
  const next = [entry, ...filtered].slice(0, MAX_REGISTRY_ENTRIES);
15056
- await writeRegistry(next, registryPath);
15144
+ await writeJsonAtomic(registryPath, next);
15057
15145
  });
15058
15146
  }
15059
15147
  async function removeMissingRegistryEntries(paths, home = homedir4()) {
15060
15148
  if (paths.size === 0) return { removedPaths: [], skippedPaths: [] };
15061
15149
  const registryPath = REGISTRY_FILE_PATH(home);
15062
- return withRegistryLock(registryPath, async () => {
15150
+ return withFileLock(registryPath, async () => {
15063
15151
  const entries = await loadRegistry(home);
15064
15152
  const candidates = entries.filter((entry) => paths.has(entry.path));
15065
15153
  const status = await Promise.all(
@@ -15071,31 +15159,14 @@ async function removeMissingRegistryEntries(paths, home = homedir4()) {
15071
15159
  const removedPaths = status.filter(({ missing }) => missing).map(({ entry }) => entry.path);
15072
15160
  const skippedPaths = status.filter(({ missing }) => !missing).map(({ entry }) => entry.path);
15073
15161
  if (removedPaths.length > 0) {
15074
- await writeRegistry(
15075
- entries.filter((entry) => !removedPaths.includes(entry.path)),
15076
- registryPath
15162
+ await writeJsonAtomic(
15163
+ registryPath,
15164
+ entries.filter((entry) => !removedPaths.includes(entry.path))
15077
15165
  );
15078
15166
  }
15079
15167
  return { removedPaths, skippedPaths };
15080
15168
  });
15081
15169
  }
15082
- async function withRegistryLock(registryPath, operation) {
15083
- const previous = registryLocks.get(registryPath);
15084
- let release;
15085
- const current = new Promise((resolve12) => {
15086
- release = resolve12;
15087
- });
15088
- registryLocks.set(registryPath, current);
15089
- if (previous) await previous;
15090
- try {
15091
- return await operation();
15092
- } finally {
15093
- release();
15094
- if (registryLocks.get(registryPath) === current) {
15095
- registryLocks.delete(registryPath);
15096
- }
15097
- }
15098
- }
15099
15170
  async function isMissingPath(path9) {
15100
15171
  try {
15101
15172
  await access(path9, constants4.F_OK);
@@ -15109,22 +15180,6 @@ function isMissingPathError(error) {
15109
15180
  const code = error.code;
15110
15181
  return code === "ENOENT" || code === "ENOTDIR";
15111
15182
  }
15112
- async function writeRegistry(entries, registryPath) {
15113
- await mkdir5(dirname7(registryPath), { recursive: true });
15114
- const temporaryPath = `${registryPath}.tmp-${process.pid}-${randomUUID()}`;
15115
- try {
15116
- await writeFile6(
15117
- temporaryPath,
15118
- `${JSON.stringify(entries, null, 2)}
15119
- `,
15120
- "utf8"
15121
- );
15122
- await rename(temporaryPath, registryPath);
15123
- } catch (error) {
15124
- await rm(temporaryPath, { force: true }).catch(() => void 0);
15125
- throw error;
15126
- }
15127
- }
15128
15183
  async function normalizeRegistryForWrite(entries) {
15129
15184
  const normalized = [];
15130
15185
  const seenPaths = /* @__PURE__ */ new Set();
@@ -15368,15 +15423,93 @@ function serializeWorktreeInfo(info) {
15368
15423
 
15369
15424
  // src/infrastructure/worktree/lifecycle.ts
15370
15425
  import { execFile as execFile7 } from "node:child_process";
15371
- import { rm as rm2 } from "node:fs/promises";
15426
+ import { mkdir as mkdir4, rm as rm2, rmdir } from "node:fs/promises";
15372
15427
  import { promisify as promisify8 } from "node:util";
15373
15428
  var execFileAsync7 = promisify8(execFile7);
15374
- var GIT_ENV = { ...process.env, LC_ALL: "C" };
15429
+ var BRANCH_CONFIG_CLEANUP_TIMEOUT_MS = 1e3;
15430
+ async function addWorktree(repoRoot, worktreePath, target) {
15431
+ await claimWorktreePath(worktreePath);
15432
+ if ("create" in target) {
15433
+ try {
15434
+ await createBranch(repoRoot, target.branch, target.create);
15435
+ } catch (error) {
15436
+ await rmdir(worktreePath).catch(() => void 0);
15437
+ throw error;
15438
+ }
15439
+ }
15440
+ const args = "branch" in target ? [target.branch] : ["--detach", ...target.detachedAt ? [target.detachedAt] : []];
15441
+ try {
15442
+ await retryGitContention(
15443
+ () => execFileAsync7("git", ["worktree", "add", worktreePath, ...args], {
15444
+ cwd: repoRoot
15445
+ })
15446
+ );
15447
+ } catch (error) {
15448
+ await removeCreatedWorktree(
15449
+ repoRoot,
15450
+ worktreePath,
15451
+ "create" in target ? target.branch : void 0
15452
+ );
15453
+ throw error;
15454
+ }
15455
+ }
15456
+ async function removeCreatedWorktree(repoRoot, worktreePath, createdBranch) {
15457
+ await runLifecycleGit(repoRoot, [
15458
+ "worktree",
15459
+ "remove",
15460
+ "--force",
15461
+ worktreePath
15462
+ ]).catch(() => void 0);
15463
+ await rmdir(worktreePath).catch(() => void 0);
15464
+ if (createdBranch) await deleteCreatedBranch(repoRoot, createdBranch);
15465
+ }
15466
+ async function claimWorktreePath(worktreePath) {
15467
+ try {
15468
+ await mkdir4(worktreePath);
15469
+ } catch (error) {
15470
+ if (!isAlreadyExistsError(error)) throw error;
15471
+ throw new Error(`target worktree path already exists: ${worktreePath}`);
15472
+ }
15473
+ }
15474
+ async function createBranch(repoRoot, branch, options = {}) {
15475
+ const args = [
15476
+ "branch",
15477
+ ...options.track ? ["--track"] : [],
15478
+ branch,
15479
+ ...options.startPoint ? [options.startPoint] : []
15480
+ ];
15481
+ try {
15482
+ await retryGitContention(async () => {
15483
+ try {
15484
+ await execFileAsync7("git", args, { cwd: repoRoot, env: GIT_ENV });
15485
+ } catch (error) {
15486
+ if (isGitConfigLockError(error))
15487
+ await forceDeleteBranch(repoRoot, branch);
15488
+ throw error;
15489
+ }
15490
+ });
15491
+ } catch (error) {
15492
+ if (isGitConfigLockError(error))
15493
+ await removeBranchConfig(repoRoot, branch).catch(() => void 0);
15494
+ throw error;
15495
+ }
15496
+ }
15497
+ async function deleteCreatedBranch(repoRoot, branch) {
15498
+ try {
15499
+ await forceDeleteBranch(repoRoot, branch);
15500
+ await removeBranchConfig(repoRoot, branch);
15501
+ } catch {
15502
+ }
15503
+ }
15504
+ async function removeBranchConfig(repoRoot, branch) {
15505
+ await runLifecycleGit(
15506
+ repoRoot,
15507
+ ["config", "--remove-section", `branch.${branch}`],
15508
+ BRANCH_CONFIG_CLEANUP_TIMEOUT_MS
15509
+ );
15510
+ }
15375
15511
  async function removeWorktree(repoRoot, worktreePath) {
15376
- await execFileAsync7("git", ["worktree", "remove", worktreePath], {
15377
- cwd: repoRoot,
15378
- env: GIT_ENV
15379
- });
15512
+ await runLifecycleGit(repoRoot, ["worktree", "remove", worktreePath]);
15380
15513
  }
15381
15514
  async function forceRemoveWorktree(repoRoot, worktreePath) {
15382
15515
  try {
@@ -15397,10 +15530,12 @@ async function forceRemoveWorktree(repoRoot, worktreePath) {
15397
15530
  }
15398
15531
  }
15399
15532
  async function removeWorktreeWithForce(repoRoot, worktreePath) {
15400
- await execFileAsync7("git", ["worktree", "remove", "--force", worktreePath], {
15401
- cwd: repoRoot,
15402
- env: GIT_ENV
15403
- });
15533
+ await runLifecycleGit(repoRoot, [
15534
+ "worktree",
15535
+ "remove",
15536
+ "--force",
15537
+ worktreePath
15538
+ ]);
15404
15539
  }
15405
15540
  async function deinitializeSubmodules(worktreePath) {
15406
15541
  await execFileAsync7("git", ["submodule", "deinit", "--force", "--all"], {
@@ -15409,16 +15544,10 @@ async function deinitializeSubmodules(worktreePath) {
15409
15544
  });
15410
15545
  }
15411
15546
  async function deleteBranch(repoRoot, branch) {
15412
- await execFileAsync7("git", ["branch", "-d", branch], {
15413
- cwd: repoRoot,
15414
- env: GIT_ENV
15415
- });
15547
+ await runLifecycleGit(repoRoot, ["branch", "-d", branch]);
15416
15548
  }
15417
15549
  async function forceDeleteBranch(repoRoot, branch) {
15418
- await execFileAsync7("git", ["branch", "-D", branch], {
15419
- cwd: repoRoot,
15420
- env: GIT_ENV
15421
- });
15550
+ await runLifecycleGit(repoRoot, ["branch", "-D", branch]);
15422
15551
  }
15423
15552
  function isWorktreeForceRemovalError(error) {
15424
15553
  if (!hasStderr(error)) {
@@ -15440,6 +15569,15 @@ function isBranchUnmergedError(error) {
15440
15569
  function hasStderr(error) {
15441
15570
  return error instanceof Error && "stderr" in error && typeof error.stderr === "string";
15442
15571
  }
15572
+ async function runLifecycleGit(repoRoot, args, timeoutMs) {
15573
+ await retryGitContention(
15574
+ () => execFileAsync7("git", args, { cwd: repoRoot, env: GIT_ENV }),
15575
+ timeoutMs
15576
+ );
15577
+ }
15578
+ function isGitConfigLockError(error) {
15579
+ return error instanceof Error && error.message.includes("could not lock config file");
15580
+ }
15443
15581
 
15444
15582
  // src/presentation/worktree/format.ts
15445
15583
  function formatUpstreamState(upstream) {
@@ -15507,6 +15645,11 @@ function createCliDependencies() {
15507
15645
  git: {
15508
15646
  runGit,
15509
15647
  runGitRaw,
15648
+ runGitRetryingContention,
15649
+ addWorktree,
15650
+ createBranch,
15651
+ deleteCreatedBranch,
15652
+ removeCreatedWorktree,
15510
15653
  isDirtyWorktree,
15511
15654
  readWorktreeHealth,
15512
15655
  isBranchMergedInto,
@@ -15650,7 +15793,8 @@ async function runBackCommand(options) {
15650
15793
  {
15651
15794
  branch: target.branch ?? void 0,
15652
15795
  path: target.path,
15653
- repo: basename6(repository.repoRoot)
15796
+ repo: basename6(repository.repoRoot),
15797
+ slot: await runtime.slots.getWorktreeSlot(target.path)
15654
15798
  },
15655
15799
  options.stderr
15656
15800
  );
@@ -15690,6 +15834,10 @@ function formatAge(timestamp) {
15690
15834
  return `${days}d ago`;
15691
15835
  }
15692
15836
 
15837
+ // src/cli/commands/clean.ts
15838
+ import { realpath as realpath5 } from "node:fs/promises";
15839
+ import { basename as basename8, dirname as dirname7, join as join12, resolve as resolve11 } from "node:path";
15840
+
15693
15841
  // node_modules/.pnpm/@clack+core@0.5.0/node_modules/@clack/core/dist/index.mjs
15694
15842
  var import_sisteransi = __toESM(require_src(), 1);
15695
15843
  var import_picocolors = __toESM(require_picocolors(), 1);
@@ -16480,6 +16628,121 @@ function throwIfAborted2(signal) {
16480
16628
  if (signal?.aborted) throw new Error("Operation cancelled");
16481
16629
  }
16482
16630
 
16631
+ // src/domain/worktree/path-filter.ts
16632
+ function createWorktreePathFilter(patterns) {
16633
+ const matchers = patterns.map(createPathMatcher);
16634
+ if (matchers.length === 0) return () => true;
16635
+ return (worktreePath) => matchers.some((matches) => matches(worktreePath));
16636
+ }
16637
+ function findGlobIndex(pattern) {
16638
+ for (let index = 0; index < pattern.length; index += 1) {
16639
+ const character = pattern[index];
16640
+ if (character === "\\") index += 1;
16641
+ else if (character === "*" || character === "?") return index;
16642
+ }
16643
+ return -1;
16644
+ }
16645
+ function escapePathPattern(path9) {
16646
+ return path9.replace(/[\\*?]/g, "\\$&");
16647
+ }
16648
+ function unescapePathPattern(pattern) {
16649
+ return pattern.replace(/\\(.)/g, "$1");
16650
+ }
16651
+ function createPathMatcher(pattern) {
16652
+ if (findGlobIndex(pattern) !== -1) {
16653
+ const regExp = globToRegExp(pattern);
16654
+ return (path9) => regExp.test(path9);
16655
+ }
16656
+ const literal = unescapePathPattern(pattern);
16657
+ const prefix = literal === "/" ? "/" : `${literal}/`;
16658
+ return (path9) => path9 === literal || path9.startsWith(prefix);
16659
+ }
16660
+ function globToRegExp(pattern) {
16661
+ let source = "";
16662
+ for (let index = 0; index < pattern.length; index += 1) {
16663
+ let character = pattern[index];
16664
+ if (character === "*") {
16665
+ if (pattern[index + 1] === "*") {
16666
+ index += 1;
16667
+ if (pattern[index + 1] === "/") {
16668
+ index += 1;
16669
+ source += "(?:.*/)?";
16670
+ } else {
16671
+ source += ".*";
16672
+ }
16673
+ } else {
16674
+ source += "[^/]*";
16675
+ }
16676
+ } else if (character === "?") {
16677
+ source += "[^/]";
16678
+ } else {
16679
+ if (character === "\\" && index + 1 < pattern.length) {
16680
+ index += 1;
16681
+ character = pattern[index];
16682
+ }
16683
+ source += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16684
+ }
16685
+ }
16686
+ return new RegExp(`^${source}$`);
16687
+ }
16688
+
16689
+ // src/domain/worktree/policy.ts
16690
+ import { homedir as homedir5 } from "node:os";
16691
+ import { basename as basename7, dirname as dirname6, join as join10 } from "node:path";
16692
+ function resolveWorktreePath(repoRoot, branch, basePath) {
16693
+ const segments = branch.split("/").filter(Boolean);
16694
+ if (segments.length === 0) {
16695
+ throw new Error("Branch name must not be empty.");
16696
+ }
16697
+ if (segments.some((segment) => segment === "." || segment === "..")) {
16698
+ throw new Error(
16699
+ `Branch name '${branch}' contains an invalid path segment.`
16700
+ );
16701
+ }
16702
+ const base = basePath ? expandTildeInPath(basePath) : join10(dirname6(repoRoot), "worktrees", basename7(repoRoot));
16703
+ return join10(base, ...segments);
16704
+ }
16705
+ function validateBranchName(name) {
16706
+ if (name.length === 0) {
16707
+ return "Branch name must not be empty.";
16708
+ }
16709
+ if (/[\x00-\x1f\x7f ~^:?*[\\\s]/.test(name)) {
16710
+ return `Branch name '${name}' contains an invalid character.`;
16711
+ }
16712
+ if (name.startsWith("-")) {
16713
+ return `Branch name '${name}' must not start with a dash.`;
16714
+ }
16715
+ if (name.startsWith("/") || name.endsWith("/") || name.includes("//")) {
16716
+ return `Branch name '${name}' has invalid slash placement.`;
16717
+ }
16718
+ if (name.includes("..")) {
16719
+ return `Branch name '${name}' must not contain '..'.`;
16720
+ }
16721
+ if (name.endsWith(".")) {
16722
+ return `Branch name '${name}' must not end with '.'.`;
16723
+ }
16724
+ if (name.includes("@{")) {
16725
+ return `Branch name '${name}' must not contain '@{'.`;
16726
+ }
16727
+ if (name === "@") {
16728
+ return "Branch name cannot be '@'.";
16729
+ }
16730
+ for (const segment of name.split("/")) {
16731
+ if (segment.startsWith(".")) {
16732
+ return `Branch name '${name}' contains a path component starting with '.'.`;
16733
+ }
16734
+ if (segment.endsWith(".lock")) {
16735
+ return `Branch name '${name}' contains a path component ending with '.lock'.`;
16736
+ }
16737
+ }
16738
+ return null;
16739
+ }
16740
+ function expandTildeInPath(path9) {
16741
+ if (path9 === "~") return homedir5();
16742
+ if (path9.startsWith("~/")) return join10(homedir5(), path9.slice(2));
16743
+ return path9;
16744
+ }
16745
+
16483
16746
  // src/presentation/worktree/picker.ts
16484
16747
  import { env as env4, platform as platform2, stdin, stdout } from "node:process";
16485
16748
 
@@ -17475,35 +17738,22 @@ async function defaultConfirmForceDeleteBranch(branch) {
17475
17738
 
17476
17739
  // src/cli/runtime/headless.ts
17477
17740
  function isHeadless() {
17478
- return process.env.GJI_NO_TUI === "1";
17741
+ return process.env.GJI_NO_TUI === "1" || process.stdin.isTTY !== true || process.stdout.isTTY !== true;
17479
17742
  }
17480
17743
 
17481
17744
  // src/cli/commands/undo.ts
17482
- import {
17483
- access as access4,
17484
- mkdir as mkdir6,
17485
- open as open3,
17486
- readFile as readFile7,
17487
- rename as rename2,
17488
- stat,
17489
- unlink as unlink2,
17490
- writeFile as writeFile7
17491
- } from "node:fs/promises";
17492
- import { homedir as homedir5 } from "node:os";
17493
- import { dirname as dirname8, join as join10, resolve as resolve10 } from "node:path";
17745
+ import { access as access4, readFile as readFile7 } from "node:fs/promises";
17746
+ import { homedir as homedir6 } from "node:os";
17747
+ import { join as join11, resolve as resolve10 } from "node:path";
17494
17748
  var MAX_UNDO_RECORDS = 20;
17495
- var UNDO_LOCK_STALE_AFTER_MS = 6e4;
17496
- var UNDO_LOCK_RETRY_DELAY_MS = 5;
17497
- var UNDO_LOCK_WAIT_TIMEOUT_MS = UNDO_LOCK_STALE_AFTER_MS + UNDO_LOCK_RETRY_DELAY_MS;
17498
- var undoLogQueues = /* @__PURE__ */ new Map();
17499
- function undoLogPath(home = homedir5(), globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17749
+ function undoLogPath(home = homedir6(), globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17500
17750
  const configured = process.env.GJI_CONFIG_DIR;
17501
- return join10(
17502
- configured ? resolve10(configured) : join10(home, globalConfigDirectory),
17751
+ return join11(
17752
+ configured ? resolve10(configured) : join11(home, globalConfigDirectory),
17503
17753
  "undo-log.json"
17504
17754
  );
17505
17755
  }
17506
- async function loadUndoRecords(home = homedir5(), globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17756
+ async function loadUndoRecords(home = homedir6(), globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17507
17757
  try {
17508
17758
  const parsed = JSON.parse(
17509
17759
  await readFile7(undoLogPath(home, globalConfigDirectory), "utf8")
@@ -17513,7 +17763,7 @@ async function loadUndoRecords(home = homedir5(), globalConfigDirectory = defaul
17513
17763
  return [];
17514
17764
  }
17515
17765
  }
17516
- async function recordUndoOperation(op, repoRoot, worktrees, home = homedir5(), runtime = defaultCliDependencies) {
17766
+ async function recordUndoOperation(op, repoRoot, worktrees, home = homedir6(), runtime = defaultCliDependencies) {
17517
17767
  const globalConfigDirectory = runtime.configStore.GLOBAL_CONFIG_DIRECTORY;
17518
17768
  const { isDirtyWorktree: isDirtyWorktree2, runGit: runGit2 } = runtime.git;
17519
17769
  const entries = [];
@@ -17549,41 +17799,33 @@ async function recordUndoOperation(op, repoRoot, worktrees, home = homedir5(), r
17549
17799
  timestamp,
17550
17800
  entries
17551
17801
  };
17552
- return enqueueUndoLogWrite(
17553
- home,
17554
- async () => {
17555
- const existing = await loadUndoRecords(home, globalConfigDirectory);
17556
- await writeUndoRecords(
17557
- home,
17558
- [record, ...existing].slice(0, MAX_UNDO_RECORDS),
17559
- globalConfigDirectory
17560
- );
17561
- return record;
17562
- },
17563
- globalConfigDirectory
17564
- );
17802
+ return withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
17803
+ const existing = await loadUndoRecords(home, globalConfigDirectory);
17804
+ await writeUndoRecords(
17805
+ home,
17806
+ [record, ...existing].slice(0, MAX_UNDO_RECORDS),
17807
+ globalConfigDirectory
17808
+ );
17809
+ return record;
17810
+ });
17565
17811
  }
17566
- async function finalizeUndoOperation(record, removedWorktrees, home = homedir5(), globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17812
+ async function finalizeUndoOperation(record, removedWorktrees, home = homedir6(), globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17567
17813
  const removedPaths = new Set(
17568
17814
  removedWorktrees.map((worktree) => worktree.path)
17569
17815
  );
17570
- await enqueueUndoLogWrite(
17571
- home,
17572
- async () => {
17573
- const records = await loadUndoRecords(home, globalConfigDirectory);
17574
- const remaining = records.flatMap((candidate) => {
17575
- if (candidate.id !== record.id) return [candidate];
17576
- const entries = candidate.entries.filter(
17577
- (entry) => removedPaths.has(entry.path)
17578
- );
17579
- return entries.length === 0 ? [] : [{ ...candidate, entries }];
17580
- });
17581
- await writeUndoRecords(home, remaining, globalConfigDirectory);
17582
- },
17583
- globalConfigDirectory
17584
- );
17816
+ await withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
17817
+ const records = await loadUndoRecords(home, globalConfigDirectory);
17818
+ const remaining = records.flatMap((candidate) => {
17819
+ if (candidate.id !== record.id) return [candidate];
17820
+ const entries = candidate.entries.filter(
17821
+ (entry) => removedPaths.has(entry.path)
17822
+ );
17823
+ return entries.length === 0 ? [] : [{ ...candidate, entries }];
17824
+ });
17825
+ await writeUndoRecords(home, remaining, globalConfigDirectory);
17826
+ });
17585
17827
  }
17586
- async function restoreUndoRecord(record, home = homedir5(), runtime = defaultCliDependencies) {
17828
+ async function restoreUndoRecord(record, home = homedir6(), runtime = defaultCliDependencies) {
17587
17829
  const globalConfigDirectory = runtime.configStore.GLOBAL_CONFIG_DIRECTORY;
17588
17830
  const restored = [];
17589
17831
  const failed = [];
@@ -17605,19 +17847,15 @@ async function restoreUndoRecord(record, home = homedir5(), runtime = defaultCli
17605
17847
  unresolved.push(entry);
17606
17848
  }
17607
17849
  }
17608
- await enqueueUndoLogWrite(
17609
- home,
17610
- async () => {
17611
- const records = await loadUndoRecords(home, globalConfigDirectory);
17612
- const remaining = records.flatMap((candidate) => {
17613
- if (candidate.id !== record.id) return [candidate];
17614
- if (unresolved.length === 0) return [];
17615
- return [{ ...candidate, entries: unresolved }];
17616
- });
17617
- await writeUndoRecords(home, remaining, globalConfigDirectory);
17618
- },
17619
- globalConfigDirectory
17620
- );
17850
+ await withFileLock(undoLogPath(home, globalConfigDirectory), async () => {
17851
+ const records = await loadUndoRecords(home, globalConfigDirectory);
17852
+ const remaining = records.flatMap((candidate) => {
17853
+ if (candidate.id !== record.id) return [candidate];
17854
+ if (unresolved.length === 0) return [];
17855
+ return [{ ...candidate, entries: unresolved }];
17856
+ });
17857
+ await writeUndoRecords(home, remaining, globalConfigDirectory);
17858
+ });
17621
17859
  return { restored, failed };
17622
17860
  }
17623
17861
  async function runUndoCommand(options) {
@@ -17628,7 +17866,7 @@ async function runUndoCommand(options) {
17628
17866
  options.stderr(
17629
17867
  "Warning: undo journal is invalid; starting with an empty journal\n"
17630
17868
  );
17631
- const records = await loadUndoRecords(homedir5(), globalConfigDirectory);
17869
+ const records = await loadUndoRecords(homedir6(), globalConfigDirectory);
17632
17870
  if (options.list) {
17633
17871
  if (options.json) options.stdout(`${JSON.stringify(records, null, 2)}
17634
17872
  `);
@@ -17675,7 +17913,7 @@ async function runUndoCommand(options) {
17675
17913
  }
17676
17914
  if (!record)
17677
17915
  return emitUndoError(options, `undo record not found: ${options.id}`);
17678
- const result = await restoreUndoRecord(record, homedir5(), runtime);
17916
+ const result = await restoreUndoRecord(record, homedir6(), runtime);
17679
17917
  if (options.json)
17680
17918
  options.stdout(
17681
17919
  `${JSON.stringify({ restored: result.restored.map((entry) => ({ branch: entry.branch, path: entry.path })), failed: result.failed.map((entry) => ({ branch: entry.branch, path: entry.path, error: entry.message })) }, null, 2)}
@@ -17703,7 +17941,7 @@ async function runUndoCommand(options) {
17703
17941
  async function hasMalformedUndoLog(globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17704
17942
  try {
17705
17943
  const parsed = JSON.parse(
17706
- await readFile7(undoLogPath(homedir5(), globalConfigDirectory), "utf8")
17944
+ await readFile7(undoLogPath(homedir6(), globalConfigDirectory), "utf8")
17707
17945
  );
17708
17946
  return !Array.isArray(parsed) || parsed.some((entry) => !isUndoRecord(entry));
17709
17947
  } catch (error) {
@@ -17793,69 +18031,8 @@ async function assertCommitExists(repoRoot, headSha, runGit2) {
17793
18031
  throw new Error("commit no longer exists (gc'd) \u2014 cannot restore");
17794
18032
  }
17795
18033
  }
17796
- async function enqueueUndoLogWrite(home, operation, globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17797
- const path9 = undoLogPath(home, globalConfigDirectory);
17798
- await mkdir6(dirname8(path9), { recursive: true });
17799
- const previous = undoLogQueues.get(path9) ?? Promise.resolve();
17800
- const runLocked = async () => {
17801
- const release = await acquireUndoLogLock(path9);
17802
- try {
17803
- return await operation();
17804
- } finally {
17805
- await release();
17806
- }
17807
- };
17808
- const current = previous.then(runLocked, runLocked);
17809
- const settled = current.then(
17810
- () => void 0,
17811
- () => void 0
17812
- );
17813
- undoLogQueues.set(path9, settled);
17814
- try {
17815
- return await current;
17816
- } finally {
17817
- if (undoLogQueues.get(path9) === settled) undoLogQueues.delete(path9);
17818
- }
17819
- }
17820
- async function acquireUndoLogLock(path9) {
17821
- const lockPath = `${path9}.lock`;
17822
- const deadline = Date.now() + UNDO_LOCK_WAIT_TIMEOUT_MS;
17823
- while (Date.now() < deadline) {
17824
- try {
17825
- const handle = await open3(lockPath, "wx");
17826
- return async () => {
17827
- await handle.close();
17828
- await unlink2(lockPath).catch(() => void 0);
17829
- };
17830
- } catch (error) {
17831
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
17832
- throw error;
17833
- const lockStat = await stat(lockPath).catch(() => null);
17834
- if (lockStat && Date.now() - lockStat.mtimeMs > UNDO_LOCK_STALE_AFTER_MS)
17835
- await unlink2(lockPath).catch(() => void 0);
17836
- const remainingMs = deadline - Date.now();
17837
- if (remainingMs > 0)
17838
- await new Promise(
17839
- (resolve12) => setTimeout(resolve12, Math.min(UNDO_LOCK_RETRY_DELAY_MS, remainingMs))
17840
- );
17841
- }
17842
- }
17843
- throw new Error("timed out waiting for the undo journal lock");
17844
- }
17845
18034
  async function writeUndoRecords(home, records, globalConfigDirectory = defaultCliDependencies.configStore.GLOBAL_CONFIG_DIRECTORY) {
17846
- const path9 = undoLogPath(home, globalConfigDirectory);
17847
- await mkdir6(dirname8(path9), { recursive: true });
17848
- const temporaryPath = `${path9}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
17849
- try {
17850
- await writeFile7(temporaryPath, `${JSON.stringify(records, null, 2)}
17851
- `, {
17852
- encoding: "utf8",
17853
- flag: "wx"
17854
- });
17855
- await rename2(temporaryPath, path9);
17856
- } finally {
17857
- await unlink2(temporaryPath).catch(() => void 0);
17858
- }
18035
+ await writeJsonAtomic(undoLogPath(home, globalConfigDirectory), records);
17859
18036
  }
17860
18037
  function isUndoRecord(value) {
17861
18038
  if (!value || typeof value !== "object") return false;
@@ -17910,7 +18087,8 @@ function createCleanCommand(dependencies = {}) {
17910
18087
  runGit: runGit2
17911
18088
  } = runtime.git;
17912
18089
  const { loadEffectiveConfig: loadEffectiveConfig2 } = runtime.config;
17913
- const { releaseWorktreeSlot: releaseWorktreeSlot2 } = runtime.slots;
18090
+ const { getWorktreeSlot: getWorktreeSlot2, releaseWorktreeSlot: releaseWorktreeSlot2 } = runtime.slots;
18091
+ const { extractHooks: extractHooks2, runHook: runHook2 } = runtime.hooks;
17914
18092
  const sourceDependencies = {
17915
18093
  ...runtime.repositoryContext,
17916
18094
  ...runtime.repositoryRegistry,
@@ -17936,9 +18114,30 @@ function createCleanCommand(dependencies = {}) {
17936
18114
  options.cwd,
17937
18115
  sourceDependencies
17938
18116
  );
17939
- const currentSources = linkedWorktrees.filter((worktree) => worktree.path !== repository.currentRoot).map((worktree) => ({
17940
- repoName: repository.repoName,
17941
- repoRoot: repository.repoRoot,
18117
+ const pathPatterns = await canonicalizePathPatterns(
18118
+ options.cwd,
18119
+ options.paths ?? []
18120
+ );
18121
+ const matchesPathFilter = createWorktreePathFilter(pathPatterns);
18122
+ const searchAllRepositories = pathPatterns.length > 0;
18123
+ let allSourceCount = 0;
18124
+ const emitNoCandidates = (sourceCount) => {
18125
+ if (pathPatterns.length > 0 && sourceCount === 0) {
18126
+ emitError(options, "No linked worktrees match --path");
18127
+ return 1;
18128
+ }
18129
+ if (options.stale) {
18130
+ emitNoStaleCandidates(options);
18131
+ return 0;
18132
+ }
18133
+ emitError(options, "No linked worktrees to clean");
18134
+ return 1;
18135
+ };
18136
+ const currentSources = linkedWorktrees.filter(
18137
+ (worktree) => worktree.path !== repository.currentRoot && matchesPathFilter(worktree.path)
18138
+ ).map((worktree) => ({
18139
+ repoName: repository.repoName,
18140
+ repoRoot: repository.repoRoot,
17942
18141
  worktree
17943
18142
  }));
17944
18143
  const cleanupDependencies = {
@@ -17977,8 +18176,9 @@ function createCleanCommand(dependencies = {}) {
17977
18176
  ...currentSources,
17978
18177
  ...registered.sources
17979
18178
  ]).filter(
17980
- (source) => source.repoRoot !== void 0 && source.worktree.path !== source.repoRoot && source.worktree.path !== repository.currentRoot
18179
+ (source) => source.repoRoot !== void 0 && source.worktree.path !== source.repoRoot && source.worktree.path !== repository.currentRoot && matchesPathFilter(source.worktree.path)
17981
18180
  );
18181
+ allSourceCount = sources.length;
17982
18182
  const otherRepositorySources = sources.filter(
17983
18183
  (source) => source.repoRoot !== repository.repoRoot
17984
18184
  );
@@ -18012,37 +18212,27 @@ function createCleanCommand(dependencies = {}) {
18012
18212
  };
18013
18213
  }
18014
18214
  };
18015
- if (currentCandidates.length === 0 && interactiveSelection) {
18215
+ if (searchAllRepositories || interactiveSelection && currentCandidates.length === 0) {
18016
18216
  const loadedAllCandidates = await loadAllCandidates();
18017
18217
  if (loadedAllCandidates.length === 0) {
18018
- if (options.stale) {
18019
- emitNoStaleCandidates(options);
18020
- return 0;
18021
- }
18022
- emitError(options, "No linked worktrees to clean");
18023
- return 1;
18218
+ return emitNoCandidates(allSourceCount);
18024
18219
  }
18025
18220
  currentScope = false;
18026
18221
  activeCandidates = loadedAllCandidates;
18027
18222
  scope.label = "all repositories";
18028
18223
  scope.toggleLabel = "current repository";
18029
- }
18030
- if (currentCandidates.length === 0 && !interactiveSelection) {
18031
- if (options.stale) {
18032
- emitNoStaleCandidates(options);
18033
- return 0;
18034
- }
18035
- emitError(options, "No linked worktrees to clean");
18036
- return 1;
18224
+ } else if (currentCandidates.length === 0) {
18225
+ return emitNoCandidates(currentSources.length);
18037
18226
  }
18038
18227
  if (!options.dryRun && !options.force && (options.json || isHeadless())) {
18039
18228
  const message = "--force is required";
18040
18229
  if (options.json) {
18041
18230
  emitError(options, message);
18042
18231
  } else {
18232
+ options.stderr(`gji clean: ${message} in non-interactive mode
18233
+ `);
18043
18234
  options.stderr(
18044
- `gji clean: ${message} in non-interactive mode (GJI_NO_TUI=1)
18045
- `
18235
+ "Hint: --force removes every candidate; use --dry-run to preview them first\n"
18046
18236
  );
18047
18237
  }
18048
18238
  return 1;
@@ -18147,6 +18337,19 @@ function createCleanCommand(dependencies = {}) {
18147
18337
  );
18148
18338
  continue;
18149
18339
  }
18340
+ const hooks = extractHooks2(await loadEffectiveConfig2(repoRoot));
18341
+ await runHook2(
18342
+ hooks["before-remove"],
18343
+ worktree.path,
18344
+ {
18345
+ branch: worktree.branch ?? void 0,
18346
+ path: worktree.path,
18347
+ repo: basename8(repoRoot),
18348
+ slot: await getWorktreeSlot2(worktree.path)
18349
+ },
18350
+ options.stderr,
18351
+ options.json ? () => void 0 : options.stdout
18352
+ );
18150
18353
  try {
18151
18354
  await removeWorktree2(repoRoot, worktree.path);
18152
18355
  } catch (error) {
@@ -18260,6 +18463,31 @@ function createCleanCommand(dependencies = {}) {
18260
18463
  };
18261
18464
  }
18262
18465
  var runCleanCommand = createCleanCommand();
18466
+ async function canonicalizePathPatterns(cwd, patterns) {
18467
+ return Promise.all(
18468
+ patterns.map(
18469
+ (pattern) => canonicalizeLiteralPrefix(resolve11(cwd, expandTildeInPath(pattern)))
18470
+ )
18471
+ );
18472
+ }
18473
+ async function canonicalizeLiteralPrefix(pattern) {
18474
+ const globIndex = findGlobIndex(pattern);
18475
+ let prefix = globIndex === -1 ? pattern : dirname7(`${pattern.slice(0, globIndex)}x`);
18476
+ let suffix = pattern.slice(prefix.length);
18477
+ for (; ; ) {
18478
+ try {
18479
+ const canonicalPrefix = escapePathPattern(
18480
+ await realpath5(unescapePathPattern(prefix))
18481
+ );
18482
+ return `${canonicalPrefix}${suffix}`.replace(/^\/\//, "/");
18483
+ } catch {
18484
+ const parent = dirname7(prefix);
18485
+ if (parent === prefix) return pattern;
18486
+ suffix = join12("/", basename8(prefix), suffix);
18487
+ prefix = parent;
18488
+ }
18489
+ }
18490
+ }
18263
18491
  async function resolveCleanupCandidates(options, dependencies) {
18264
18492
  const { sources, stale, stderr, signal } = options;
18265
18493
  const grouped = /* @__PURE__ */ new Map();
@@ -18572,23 +18800,20 @@ var TOP_LEVEL_COMMANDS = [
18572
18800
  },
18573
18801
  { name: "ls", description: "list active worktrees" },
18574
18802
  { name: "clean", description: "interactively prune linked worktrees" },
18575
- {
18576
- name: "remove",
18577
- description: "deprecated: use done for one worktree or clean for bulk cleanup"
18578
- },
18579
- { name: "rm", description: "deprecated alias of remove" },
18580
18803
  {
18581
18804
  name: "run-hook",
18582
18805
  description: "run a named hook in the current worktree"
18583
18806
  },
18584
- {
18585
- name: "warp",
18586
- description: "deprecated: use go (press Tab for all known repos)"
18587
- },
18588
18807
  { name: "config", description: "manage global config defaults" }
18589
18808
  ];
18590
18809
  var SHELL_NAMES = ["bash", "fish", "zsh"];
18591
- var HOOK_NAMES = ["after-create", "after-enter", "before-remove"];
18810
+ var HOOK_NAMES = [
18811
+ "after-branch-create",
18812
+ "after-create",
18813
+ "after-enter",
18814
+ "after-switch",
18815
+ "before-remove"
18816
+ ];
18592
18817
  var CONFIG_KEYS = Array.from(KNOWN_GLOBAL_CONFIG_KEYS);
18593
18818
  function renderShellCompletion(shell) {
18594
18819
  switch (shell) {
@@ -18761,17 +18986,15 @@ _gji_completion() {
18761
18986
  COMPREPLY=( $(compgen -W "--compact --json --help" -- "$cur") )
18762
18987
  ;;
18763
18988
  clean)
18764
- COMPREPLY=( $(compgen -W "-f --force --stale --dry-run --json --help" -- "$cur") )
18765
- ;;
18766
- remove|rm)
18767
- COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) -f --force --dry-run --json --help" -- "$cur") )
18989
+ if [ "\${COMP_WORDS[COMP_CWORD-1]}" = "--path" ]; then
18990
+ COMPREPLY=( $(compgen -d -- "$cur") )
18991
+ else
18992
+ COMPREPLY=( $(compgen -W "-f --force --stale --path --dry-run --json --help" -- "$cur") )
18993
+ fi
18768
18994
  ;;
18769
18995
  run-hook)
18770
18996
  COMPREPLY=( $(compgen -W "${hooks} --help" -- "$cur") )
18771
18997
  ;;
18772
- warp)
18773
- COMPREPLY=( $(compgen -W "--print --json --help" -- "$cur") )
18774
- ;;
18775
18998
  config)
18776
18999
  if [ "$COMP_CWORD" -eq 2 ]; then
18777
19000
  COMPREPLY=( $(compgen -W "get set unset" -- "$cur") )
@@ -19004,21 +19227,14 @@ complete -c gji -n '__fish_seen_subcommand_from ls' -l json -d 'print active wor
19004
19227
 
19005
19228
  complete -c gji -n '__fish_seen_subcommand_from clean' -s f -l force -d 'bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches'
19006
19229
  complete -c gji -n '__fish_seen_subcommand_from clean' -l stale -d 'only target clean worktrees whose upstream is gone and branch is merged into the default branch'
19230
+ complete -c gji -n '__fish_seen_subcommand_from clean' -l path -r -F -d 'only target worktrees under a directory or matching a path glob (repeatable)'
19007
19231
  complete -c gji -n '__fish_seen_subcommand_from clean' -l dry-run -d 'show what would be deleted without removing anything'
19008
19232
  complete -c gji -n '__fish_seen_subcommand_from clean' -l json -d 'emit JSON on success or error instead of human-readable output'
19009
19233
 
19010
- complete -c gji -n '__fish_seen_subcommand_from remove rm' -s f -l force -d 'bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch'
19011
- complete -c gji -n '__fish_seen_subcommand_from remove rm' -l dry-run -d 'show what would be deleted without removing anything'
19012
- complete -c gji -n '__fish_seen_subcommand_from remove rm' -l json -d 'emit JSON on success or error instead of human-readable output'
19013
- complete -c gji -n '__fish_seen_subcommand_from remove rm' -a '(__gji_worktree_branches)' -d 'worktree branch'
19014
-
19015
19234
  ${hookLines}
19016
19235
 
19017
19236
  complete -c gji -n '__fish_use_subcommand' -l json -d 'emit the repository hub as JSON'
19018
19237
 
19019
- complete -c gji -n '__fish_seen_subcommand_from warp' -l print -d 'print the resolved worktree path without changing directory'
19020
- complete -c gji -n '__fish_seen_subcommand_from warp' -l json -d 'emit JSON on success or error instead of human-readable output'
19021
-
19022
19238
  complete -c gji -n '__fish_seen_subcommand_from config; and __gji_should_complete_config_action' -a 'get set unset' -d 'config action'
19023
19239
  ${configKeyLines}`;
19024
19240
  }
@@ -19164,17 +19380,11 @@ case "\${words[2]}" in
19164
19380
  _arguments '--compact[show only branch and path columns]' '--json[print active worktrees as JSON]'
19165
19381
  ;;
19166
19382
  clean)
19167
- _arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches]' '--stale[only target clean worktrees whose upstream is gone and branch is merged into the default branch]' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]'
19168
- ;;
19169
- remove|rm)
19170
- _arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch]' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch:->worktrees'
19383
+ _arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches]' '--stale[only target clean worktrees whose upstream is gone and branch is merged into the default branch]' '*--path[only target worktrees under a directory or matching a path glob]:pattern:_files' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]'
19171
19384
  ;;
19172
19385
  run-hook)
19173
19386
  _arguments "2:hook:(${hooks})"
19174
19387
  ;;
19175
- warp)
19176
- _arguments '--print[print the resolved worktree path without changing directory]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
19177
- ;;
19178
19388
  config)
19179
19389
  if (( CURRENT == 3 )); then
19180
19390
  _values 'config action' get set unset
@@ -19301,20 +19511,20 @@ function writeJson(stdout3, value) {
19301
19511
  import { execFile as execFile8 } from "node:child_process";
19302
19512
  import { constants as constants6 } from "node:fs";
19303
19513
  import { access as access6, readFile as readFile8 } from "node:fs/promises";
19304
- import { homedir as homedir6 } from "node:os";
19305
- import { basename as basename7, dirname as dirname9, join as join12 } from "node:path";
19514
+ import { homedir as homedir7 } from "node:os";
19515
+ import { basename as basename9, dirname as dirname8, join as join14 } from "node:path";
19306
19516
  import { promisify as promisify9 } from "node:util";
19307
19517
 
19308
19518
  // src/presentation/shell/setup.ts
19309
19519
  import { constants as constants5 } from "node:fs";
19310
19520
  import { access as access5 } from "node:fs/promises";
19311
- import { delimiter, join as join11 } from "node:path";
19521
+ import { delimiter, join as join13 } from "node:path";
19312
19522
  var SHELL_INTEGRATION_START_MARKER = "# >>> gji init >>>";
19313
19523
  var SHELL_INTEGRATION_END_MARKER = "# <<< gji init <<<";
19314
19524
  var ONBOARDING_SHELL_INTEGRATION_START_MARKER = "# >>> gji shell integration >>>";
19315
19525
  var ONBOARDING_SHELL_INTEGRATION_END_MARKER = "# <<< gji shell integration <<<";
19316
19526
  async function executableExists(command) {
19317
- const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((directory) => join11(directory, command));
19527
+ const candidates = command.includes("/") ? [command] : (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((directory) => join13(directory, command));
19318
19528
  for (const path9 of candidates) {
19319
19529
  try {
19320
19530
  await access5(path9, constants5.X_OK);
@@ -19327,17 +19537,17 @@ async function executableExists(command) {
19327
19537
  function resolveShellConfigPath(shell, home) {
19328
19538
  switch (shell) {
19329
19539
  case "bash":
19330
- return join11(home, ".bashrc");
19540
+ return join13(home, ".bashrc");
19331
19541
  case "fish":
19332
- return join11(home, ".config", "fish", "config.fish");
19542
+ return join13(home, ".config", "fish", "config.fish");
19333
19543
  case "zsh":
19334
- return join11(home, ".zshrc");
19544
+ return join13(home, ".zshrc");
19335
19545
  }
19336
19546
  }
19337
19547
  function resolveCompletionPath(shell, home) {
19338
19548
  switch (shell) {
19339
19549
  case "bash":
19340
- return join11(
19550
+ return join13(
19341
19551
  home,
19342
19552
  ".local",
19343
19553
  "share",
@@ -19346,9 +19556,9 @@ function resolveCompletionPath(shell, home) {
19346
19556
  "gji"
19347
19557
  );
19348
19558
  case "fish":
19349
- return join11(home, ".config", "fish", "completions", "gji.fish");
19559
+ return join13(home, ".config", "fish", "completions", "gji.fish");
19350
19560
  case "zsh":
19351
- return join11(home, ".zsh", "completions", "_gji");
19561
+ return join13(home, ".zsh", "completions", "_gji");
19352
19562
  }
19353
19563
  }
19354
19564
  function hasShellIntegration(config, shell) {
@@ -19380,7 +19590,7 @@ async function runDoctorCommand(options) {
19380
19590
  }
19381
19591
  return 1;
19382
19592
  }
19383
- const home = options.home ?? homedir6();
19593
+ const home = options.home ?? homedir7();
19384
19594
  const shell = resolveSupportedShell(
19385
19595
  void 0,
19386
19596
  options.shell ?? process.env.SHELL
@@ -19445,7 +19655,7 @@ async function collectDoctorInspection(cwd, home, shell, runtime) {
19445
19655
  KNOWN_GLOBAL_CONFIG_KEYS2
19446
19656
  );
19447
19657
  const localConfig = repository ? await inspectConfig(
19448
- join12(repository.repoRoot, CONFIG_FILE_NAME2),
19658
+ join14(repository.repoRoot, CONFIG_FILE_NAME2),
19449
19659
  "local",
19450
19660
  KNOWN_CONFIG_KEYS2
19451
19661
  ) : null;
@@ -19624,7 +19834,7 @@ function resolvePerRepoConfig(repos, repoRoot, home) {
19624
19834
  }
19625
19835
  function expandTilde2(path9, home) {
19626
19836
  if (path9 === "~") return home;
19627
- if (path9.startsWith("~/")) return join12(home, path9.slice(2));
19837
+ if (path9.startsWith("~/")) return join14(home, path9.slice(2));
19628
19838
  return path9;
19629
19839
  }
19630
19840
  async function checkGitVersion() {
@@ -19751,7 +19961,7 @@ function resolveWorktreeBase(repoRoot, config, home) {
19751
19961
  if (typeof configuredPath === "string" && (configuredPath.startsWith("/") || configuredPath.startsWith("~"))) {
19752
19962
  return expandTilde2(configuredPath, home);
19753
19963
  }
19754
- return join12(dirname9(repoRoot), "worktrees", basename7(repoRoot));
19964
+ return join14(dirname8(repoRoot), "worktrees", basename9(repoRoot));
19755
19965
  }
19756
19966
  async function findNearestExistingPath(path9) {
19757
19967
  let candidate = path9;
@@ -19760,7 +19970,7 @@ async function findNearestExistingPath(path9) {
19760
19970
  await access6(candidate, constants6.F_OK);
19761
19971
  return candidate;
19762
19972
  } catch {
19763
- const parent = dirname9(candidate);
19973
+ const parent = dirname8(candidate);
19764
19974
  if (parent === candidate) return null;
19765
19975
  candidate = parent;
19766
19976
  }
@@ -19911,7 +20121,7 @@ function statusSymbol(status) {
19911
20121
  }
19912
20122
 
19913
20123
  // src/cli/commands/done.ts
19914
- import { basename as basename8 } from "node:path";
20124
+ import { basename as basename10 } from "node:path";
19915
20125
  var DONE_OUTPUT_FILE_ENV = "GJI_DONE_OUTPUT_FILE";
19916
20126
  async function runDoneCommand(options) {
19917
20127
  const runtime = options.runtime ?? defaultCliDependencies;
@@ -19923,7 +20133,7 @@ async function runDoneCommand(options) {
19923
20133
  } = runtime.git;
19924
20134
  const { loadEffectiveConfig: loadEffectiveConfig2, resolveConfigString: resolveConfigString2 } = runtime.config;
19925
20135
  const { loadHistory: loadHistory2 } = runtime.historyStore;
19926
- const { releaseWorktreeSlot: releaseWorktreeSlot2 } = runtime.slots;
20136
+ const { getWorktreeSlot: getWorktreeSlot2, releaseWorktreeSlot: releaseWorktreeSlot2 } = runtime.slots;
19927
20137
  const { extractHooks: extractHooks2, runHook: runHook2 } = runtime.hooks;
19928
20138
  const { detectRepository: detectRepository2 } = runtime.repositoryContext;
19929
20139
  const { listWorktrees: listWorktrees2 } = runtime.worktrees;
@@ -20019,9 +20229,11 @@ async function runDoneCommand(options) {
20019
20229
  {
20020
20230
  branch: target.branch ?? void 0,
20021
20231
  path: target.path,
20022
- repo: basename8(repository.repoRoot)
20232
+ repo: basename10(repository.repoRoot),
20233
+ slot: await getWorktreeSlot2(target.path)
20023
20234
  },
20024
- options.stderr
20235
+ options.stderr,
20236
+ options.json ? () => void 0 : options.stdout
20025
20237
  );
20026
20238
  try {
20027
20239
  await removeWorktree2(repository.repoRoot, target.path);
@@ -20210,7 +20422,7 @@ function toMessage3(error) {
20210
20422
  }
20211
20423
 
20212
20424
  // src/cli/commands/go.ts
20213
- import { basename as basename10 } from "node:path";
20425
+ import { basename as basename12 } from "node:path";
20214
20426
 
20215
20427
  // src/application/worktree/context-card.ts
20216
20428
  async function loadContextCardModel(worktreePath, dependencies) {
@@ -20536,69 +20748,12 @@ function formatState(info) {
20536
20748
  return `${info.status}${info.upstream.kind === "tracked" ? ` \xB7 \u2191${info.upstream.ahead} \u2193${info.upstream.behind}` : ""}`;
20537
20749
  }
20538
20750
  function formatLastCommit2(info) {
20539
- return new Date(info.lastCommitTimestamp).toISOString();
20751
+ return new Date(info.lastCommitTimestamp * 1e3).toISOString();
20540
20752
  }
20541
20753
 
20542
20754
  // src/cli/commands/new.ts
20543
- import { access as access8, mkdir as mkdir7 } from "node:fs/promises";
20544
- import { dirname as dirname11, resolve as resolve11 } from "node:path";
20545
-
20546
- // src/domain/worktree/policy.ts
20547
- import { homedir as homedir7 } from "node:os";
20548
- import { basename as basename9, dirname as dirname10, join as join13 } from "node:path";
20549
- function resolveWorktreePath(repoRoot, branch, basePath) {
20550
- const segments = branch.split("/").filter(Boolean);
20551
- if (segments.length === 0) {
20552
- throw new Error("Branch name must not be empty.");
20553
- }
20554
- if (segments.some((segment) => segment === "." || segment === "..")) {
20555
- throw new Error(
20556
- `Branch name '${branch}' contains an invalid path segment.`
20557
- );
20558
- }
20559
- const base = basePath ? expandTildeInPath(basePath) : join13(dirname10(repoRoot), "worktrees", basename9(repoRoot));
20560
- return join13(base, ...segments);
20561
- }
20562
- function validateBranchName(name) {
20563
- if (name.length === 0) {
20564
- return "Branch name must not be empty.";
20565
- }
20566
- if (/[\x00-\x1f\x7f ~^:?*[\\\s]/.test(name)) {
20567
- return `Branch name '${name}' contains an invalid character.`;
20568
- }
20569
- if (name.startsWith("-")) {
20570
- return `Branch name '${name}' must not start with a dash.`;
20571
- }
20572
- if (name.startsWith("/") || name.endsWith("/") || name.includes("//")) {
20573
- return `Branch name '${name}' has invalid slash placement.`;
20574
- }
20575
- if (name.includes("..")) {
20576
- return `Branch name '${name}' must not contain '..'.`;
20577
- }
20578
- if (name.endsWith(".")) {
20579
- return `Branch name '${name}' must not end with '.'.`;
20580
- }
20581
- if (name.includes("@{")) {
20582
- return `Branch name '${name}' must not contain '@{'.`;
20583
- }
20584
- if (name === "@") {
20585
- return "Branch name cannot be '@'.";
20586
- }
20587
- for (const segment of name.split("/")) {
20588
- if (segment.startsWith(".")) {
20589
- return `Branch name '${name}' contains a path component starting with '.'.`;
20590
- }
20591
- if (segment.endsWith(".lock")) {
20592
- return `Branch name '${name}' contains a path component ending with '.lock'.`;
20593
- }
20594
- }
20595
- return null;
20596
- }
20597
- function expandTildeInPath(path9) {
20598
- if (path9 === "~") return homedir7();
20599
- if (path9.startsWith("~/")) return join13(homedir7(), path9.slice(2));
20600
- return path9;
20601
- }
20755
+ import { access as access8, mkdir as mkdir5, rmdir as rmdir2 } from "node:fs/promises";
20756
+ import { basename as basename11, dirname as dirname9, resolve as resolve12 } from "node:path";
20602
20757
 
20603
20758
  // src/presentation/bootstrap/output.ts
20604
20759
  function createBootstrapReporter(write, json) {
@@ -20713,10 +20868,8 @@ function createNewCommand(dependencies = {}) {
20713
20868
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}
20714
20869
  `);
20715
20870
  } else {
20716
- options.stderr(
20717
- `gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)
20718
- `
20719
- );
20871
+ options.stderr(`gji new: ${message} in non-interactive mode
20872
+ `);
20720
20873
  }
20721
20874
  return 1;
20722
20875
  }
@@ -20767,13 +20920,14 @@ function createNewCommand(dependencies = {}) {
20767
20920
  if (options.force) {
20768
20921
  if (!options.dryRun) {
20769
20922
  try {
20770
- await runtime.git.runGit(repository.repoRoot, [
20923
+ await runtime.git.runGitRetryingContention(repository.repoRoot, [
20771
20924
  "worktree",
20772
20925
  "remove",
20773
20926
  "--force",
20774
20927
  worktreePath
20775
20928
  ]);
20776
20929
  } catch (err) {
20930
+ await rmdir2(worktreePath).catch(() => void 0);
20777
20931
  if (!isNotRegisteredWorktreeError(err)) {
20778
20932
  const msg = `could not remove existing worktree at ${worktreePath}: ${toExecMessage(err)}`;
20779
20933
  if (options.json) {
@@ -20804,10 +20958,8 @@ function createNewCommand(dependencies = {}) {
20804
20958
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}
20805
20959
  `);
20806
20960
  } else {
20807
- options.stderr(
20808
- `gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)
20809
- `
20810
- );
20961
+ options.stderr(`gji new: ${message} in non-interactive mode
20962
+ `);
20811
20963
  options.stderr(
20812
20964
  `Hint: Use 'gji done ${worktreeName}' or 'gji clean' to remove the existing worktree
20813
20965
  `
@@ -20908,7 +21060,7 @@ ${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`
20908
21060
  }
20909
21061
  return 0;
20910
21062
  }
20911
- await mkdir7(dirname11(worktreePath), { recursive: true });
21063
+ await mkdir5(dirname9(worktreePath), { recursive: true });
20912
21064
  const startPoint = options.fromCurrent && !options.detached ? await resolveCurrentWorktreeHead(options.cwd, runtime.git.runGit) : void 0;
20913
21065
  const takeStartPoint = options.take ? await resolveCurrentWorktreeHead(options.cwd, runtime.git.runGit) : void 0;
20914
21066
  const changedTakeFiles = options.take ? await listTakeFiles(options.cwd, runtime.git.runGitRaw) : [];
@@ -20936,7 +21088,8 @@ ${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`
20936
21088
  worktreeName,
20937
21089
  runtime
20938
21090
  );
20939
- const shouldRefreshBase = !options.noFetch && !options.detached && !options.fromCurrent && !options.take && options.mode !== "track" && options.mode !== "checkout" && !existingLocalBranch;
21091
+ const createsBranch = !options.detached && options.mode !== "track" && options.mode !== "checkout" && !existingLocalBranch;
21092
+ const shouldRefreshBase = createsBranch && !options.noFetch && !options.fromCurrent && !options.take;
20940
21093
  const freshBaseRef = shouldRefreshBase ? await resolveFreshBaseRef(
20941
21094
  repository.repoRoot,
20942
21095
  config,
@@ -20969,30 +21122,19 @@ ${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`
20969
21122
  );
20970
21123
  }
20971
21124
  }
20972
- const gitArgs = options.detached ? [
20973
- "worktree",
20974
- "add",
20975
- "--detach",
20976
- worktreePath,
20977
- ...takeStartPoint ? [takeStartPoint] : []
20978
- ] : options.mode === "track" ? [
20979
- "worktree",
20980
- "add",
20981
- "--track",
20982
- "-b",
20983
- worktreeName,
20984
- worktreePath,
20985
- `${options.remote ?? "origin"}/${worktreeName}`
20986
- ] : options.mode === "checkout" || existingLocalBranch ? ["worktree", "add", worktreePath, worktreeName] : [
20987
- "worktree",
20988
- "add",
20989
- "-b",
20990
- worktreeName,
20991
- worktreePath,
20992
- ...takeStartPoint ? [takeStartPoint] : startPoint ? [startPoint] : freshBaseRef ? [freshBaseRef] : []
20993
- ];
21125
+ const addsNewBranch = createsBranch || !options.detached && options.mode === "track";
20994
21126
  try {
20995
- await runtime.git.runGit(repository.repoRoot, gitArgs);
21127
+ await runtime.git.addWorktree(
21128
+ repository.repoRoot,
21129
+ worktreePath,
21130
+ options.detached ? { detachedAt: takeStartPoint } : addsNewBranch ? {
21131
+ branch: worktreeName,
21132
+ create: {
21133
+ startPoint: options.mode === "track" ? `${options.remote ?? "origin"}/${worktreeName}` : takeStartPoint ?? startPoint ?? freshBaseRef,
21134
+ track: options.mode === "track"
21135
+ }
21136
+ } : { branch: worktreeName }
21137
+ );
20996
21138
  } catch (error) {
20997
21139
  if (stashSha)
20998
21140
  await restoreTakeStash(
@@ -21014,15 +21156,11 @@ ${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`
21014
21156
  options.stderr,
21015
21157
  runtime.git.runGit
21016
21158
  )) {
21017
- try {
21018
- await runtime.git.runGit(repository.repoRoot, [
21019
- "worktree",
21020
- "remove",
21021
- "--force",
21022
- worktreePath
21023
- ]);
21024
- } catch {
21025
- }
21159
+ await runtime.git.removeCreatedWorktree(
21160
+ repository.repoRoot,
21161
+ worktreePath,
21162
+ addsNewBranch ? worktreeName : void 0
21163
+ );
21026
21164
  return emitNewError(
21027
21165
  options,
21028
21166
  `could not apply taken changes; your changes are safe in stash: ${stashSha}`
@@ -21058,6 +21196,17 @@ ${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`
21058
21196
  syncFiles: bootstrap.syncFileFailures
21059
21197
  });
21060
21198
  }
21199
+ if (createsBranch) {
21200
+ await runNewHooks({
21201
+ branch: worktreeName,
21202
+ config,
21203
+ hookNames: ["after-branch-create"],
21204
+ options,
21205
+ path: worktreePath,
21206
+ repository,
21207
+ runtime
21208
+ });
21209
+ }
21061
21210
  if (options.json) {
21062
21211
  const navigation = createNavigationTarget(
21063
21212
  createNavigationRepository(repository.repoName, repository.repoRoot),
@@ -21190,7 +21339,7 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
21190
21339
  try {
21191
21340
  let startPoint = baseBranch;
21192
21341
  if (!options.noFetch) {
21193
- await runtime.git.runGit(repository.repoRoot, [
21342
+ await runtime.git.runGitRetryingContention(repository.repoRoot, [
21194
21343
  "fetch",
21195
21344
  "--prune",
21196
21345
  remote,
@@ -21198,18 +21347,30 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
21198
21347
  ]);
21199
21348
  startPoint = `${remote}/${baseBranch}`;
21200
21349
  }
21201
- await runtime.git.runGit(options.cwd, [
21202
- "switch",
21203
- "-c",
21204
- branchName,
21350
+ await runtime.git.createBranch(repository.repoRoot, branchName, {
21205
21351
  startPoint
21206
- ]);
21352
+ });
21353
+ try {
21354
+ await runtime.git.runGit(options.cwd, ["switch", branchName]);
21355
+ } catch (error) {
21356
+ await runtime.git.deleteCreatedBranch(repository.repoRoot, branchName);
21357
+ throw error;
21358
+ }
21207
21359
  } catch (error) {
21208
21360
  return emitNewError(
21209
21361
  options,
21210
21362
  `failed to create branch in the current worktree: ${toExecMessage(error)}`
21211
21363
  );
21212
21364
  }
21365
+ await runNewHooks({
21366
+ branch: branchName,
21367
+ config,
21368
+ hookNames: ["after-branch-create", "after-switch"],
21369
+ options,
21370
+ path: repository.currentRoot,
21371
+ repository,
21372
+ runtime
21373
+ });
21213
21374
  if (options.json) {
21214
21375
  const navigation = createNavigationTarget(
21215
21376
  createNavigationRepository(repository.repoName, repository.repoRoot),
@@ -21237,6 +21398,32 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
21237
21398
  }
21238
21399
  return 0;
21239
21400
  }
21401
+ async function runNewHooks({
21402
+ branch,
21403
+ config,
21404
+ hookNames,
21405
+ options,
21406
+ path: path9,
21407
+ repository,
21408
+ runtime
21409
+ }) {
21410
+ const hooks = runtime.hooks.extractHooks(config);
21411
+ const context = {
21412
+ branch,
21413
+ path: path9,
21414
+ repo: basename11(repository.repoRoot),
21415
+ slot: await runtime.slots.getWorktreeSlot(path9)
21416
+ };
21417
+ for (const hookName of hookNames) {
21418
+ await runtime.hooks.runHook(
21419
+ hooks[hookName],
21420
+ path9,
21421
+ context,
21422
+ options.json ? () => void 0 : options.stderr,
21423
+ options.json ? () => void 0 : options.stdout
21424
+ );
21425
+ }
21426
+ }
21240
21427
  var runNewCommand = createNewCommand();
21241
21428
  async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFailure, runtime) {
21242
21429
  const remote = runtime.config.resolveConfigString(config, "syncRemote") ?? "origin";
@@ -21259,7 +21446,7 @@ async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFail
21259
21446
  throw new Error("the remote default branch is unknown");
21260
21447
  }
21261
21448
  baseBranch = remoteBase.branch;
21262
- await runtime.git.runGit(repoRoot, [
21449
+ await runtime.git.runGitRetryingContention(repoRoot, [
21263
21450
  "fetch",
21264
21451
  "--prune",
21265
21452
  remote,
@@ -21500,7 +21687,7 @@ async function detectInProgressGitState(cwd, runGit2) {
21500
21687
  for (const [marker, name] of markers) {
21501
21688
  try {
21502
21689
  const gitPath = await runGit2(cwd, ["rev-parse", "--git-path", marker]);
21503
- await access8(resolve11(cwd, gitPath));
21690
+ await access8(resolve12(cwd, gitPath));
21504
21691
  return name;
21505
21692
  } catch {
21506
21693
  }
@@ -21651,8 +21838,8 @@ function emitNewError(options, message, details) {
21651
21838
  }
21652
21839
 
21653
21840
  // src/cli/commands/pr.ts
21654
- import { mkdir as mkdir8 } from "node:fs/promises";
21655
- import { dirname as dirname12 } from "node:path";
21841
+ import { mkdir as mkdir6 } from "node:fs/promises";
21842
+ import { dirname as dirname10 } from "node:path";
21656
21843
  var PR_OUTPUT_FILE_ENV = "GJI_PR_OUTPUT_FILE";
21657
21844
  function createPrCommand(dependencies = {}) {
21658
21845
  const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
@@ -21710,10 +21897,8 @@ function createPrCommand(dependencies = {}) {
21710
21897
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}
21711
21898
  `);
21712
21899
  } else {
21713
- options.stderr(
21714
- `gji pr: ${message} in non-interactive mode (GJI_NO_TUI=1)
21715
- `
21716
- );
21900
+ options.stderr(`gji pr: ${message} in non-interactive mode
21901
+ `);
21717
21902
  options.stderr(
21718
21903
  `Hint: Use 'gji done pr/${prNumber}' or 'gji clean' to remove the existing worktree
21719
21904
  `
@@ -21791,14 +21976,29 @@ ${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`
21791
21976
  }
21792
21977
  return 1;
21793
21978
  }
21794
- await mkdir8(dirname12(worktreePath), { recursive: true });
21979
+ await mkdir6(dirname10(worktreePath), { recursive: true });
21795
21980
  const branchAlreadyExists = await localBranchExists2(
21796
21981
  repository.repoRoot,
21797
21982
  branchName,
21798
21983
  runtime
21799
21984
  );
21800
- const worktreeArgs = branchAlreadyExists ? ["worktree", "add", worktreePath, branchName] : ["worktree", "add", "-b", branchName, worktreePath, remoteRef];
21801
- await runtime.git.runGit(repository.repoRoot, worktreeArgs);
21985
+ try {
21986
+ await runtime.git.addWorktree(
21987
+ repository.repoRoot,
21988
+ worktreePath,
21989
+ branchAlreadyExists ? { branch: branchName } : { branch: branchName, create: { startPoint: remoteRef } }
21990
+ );
21991
+ } catch (error) {
21992
+ const message = `failed to create worktree: ${error instanceof Error ? error.message : String(error)}`;
21993
+ if (options.json) {
21994
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}
21995
+ `);
21996
+ } else {
21997
+ options.stderr(`gji pr: ${message}
21998
+ `);
21999
+ }
22000
+ return 1;
22001
+ }
21802
22002
  const bootstrap = await runtime.bootstrap.bootstrapWorktree({
21803
22003
  branch: branchName,
21804
22004
  config,
@@ -21871,7 +22071,7 @@ var runPrCommand = createPrCommand();
21871
22071
  async function fetchPullRequestRef(repoRoot, input, prNumber, remoteRef, runtime) {
21872
22072
  for (const sourceRef of listPullRequestSourceRefs(input, prNumber)) {
21873
22073
  try {
21874
- await runtime.git.runGit(repoRoot, [
22074
+ await runtime.git.runGitRetryingContention(repoRoot, [
21875
22075
  "fetch",
21876
22076
  "origin",
21877
22077
  `${sourceRef}:${remoteRef}`
@@ -21959,7 +22159,7 @@ function createGoCommand(dependencies = {}) {
21959
22159
  if (options.json || isHeadless()) {
21960
22160
  return emitError2(
21961
22161
  options,
21962
- "branch argument is required in non-interactive mode (GJI_NO_TUI=1)"
22162
+ "branch argument is required in non-interactive mode"
21963
22163
  );
21964
22164
  }
21965
22165
  let skippedRegisteredRepos = 0;
@@ -22103,10 +22303,10 @@ async function handleGoBranchResolution(options, resolution, confirmBranchCreati
22103
22303
  runtime
22104
22304
  );
22105
22305
  case "pull-request":
22106
- if (options.json || isHeadless() || options.print) {
22306
+ if (options.json || options.print) {
22107
22307
  return emitError2(
22108
22308
  options,
22109
- "PR navigation creates a worktree; use `gji pr <ref>` in an interactive shell"
22309
+ "PR navigation creates a worktree; use `gji pr <ref>`"
22110
22310
  );
22111
22311
  }
22112
22312
  return runPrCommand({
@@ -22151,9 +22351,16 @@ function deduplicateSources(sources) {
22151
22351
  }
22152
22352
  async function createExistingBranchWorktree(options, confirmBranchCreation, repository, branch, mode, remote, runtime) {
22153
22353
  if (options.json || isHeadless() || options.print) {
22354
+ const command = await resolveNonInteractiveCreateCommand(
22355
+ repository,
22356
+ branch,
22357
+ mode,
22358
+ remote,
22359
+ runtime ?? defaultCliDependencies
22360
+ );
22154
22361
  return emitError2(
22155
22362
  options,
22156
- `branch "${branch}" exists but has no worktree; use interactive gji go ${branch} to create one`
22363
+ `branch "${branch}" exists but has no worktree; run ${command} to create one`
22157
22364
  );
22158
22365
  }
22159
22366
  if (!await confirmBranchCreation(branch)) {
@@ -22171,6 +22378,13 @@ async function createExistingBranchWorktree(options, confirmBranchCreation, repo
22171
22378
  runtime
22172
22379
  });
22173
22380
  }
22381
+ async function resolveNonInteractiveCreateCommand(repository, branch, mode, remote, runtime) {
22382
+ if (mode === "track") {
22383
+ return `git worktree add --track -b ${branch} <path> ${remote ?? "origin"}/${branch}`;
22384
+ }
22385
+ const config = await runtime.config.loadEffectiveConfig(repository.repoRoot).catch(() => null);
22386
+ return applyConfiguredBranchPrefix(branch, config?.branchPrefix) === branch ? `gji new ${branch}` : `git worktree add <path> ${branch}`;
22387
+ }
22174
22388
  async function navigateToExistingWorktree(options, path9, worktree, runtime) {
22175
22389
  const repository = await runtime.repositoryContext.detectRepository(path9);
22176
22390
  if (options.json) {
@@ -22200,7 +22414,7 @@ async function navigateToExistingWorktree(options, path9, worktree, runtime) {
22200
22414
  {
22201
22415
  branch: worktree?.branch ?? void 0,
22202
22416
  path: path9,
22203
- repo: basename10(repository.repoRoot),
22417
+ repo: basename12(repository.repoRoot),
22204
22418
  slot: await runtime.slots.getWorktreeSlot(path9)
22205
22419
  },
22206
22420
  options.stderr
@@ -22340,7 +22554,7 @@ async function runHistoryCommand(options) {
22340
22554
  }
22341
22555
 
22342
22556
  // src/cli/commands/hub.ts
22343
- import { basename as basename11 } from "node:path";
22557
+ import { basename as basename13 } from "node:path";
22344
22558
  import { stdin as stdin2, stdout as stdout2 } from "node:process";
22345
22559
  var MAX_HUB_REPOSITORY_CONCURRENCY = 4;
22346
22560
  async function buildHubData(cwd, dependencies = {}, runtime = defaultCliDependencies) {
@@ -22365,7 +22579,7 @@ async function buildHubData(cwd, dependencies = {}, runtime = defaultCliDependen
22365
22579
  existing.sources.push(source);
22366
22580
  } else {
22367
22581
  groups.set(repoRoot, {
22368
- name: source.repoName || basename11(repoRoot),
22582
+ name: source.repoName || basename13(repoRoot),
22369
22583
  sources: [source]
22370
22584
  });
22371
22585
  }
@@ -22614,9 +22828,9 @@ function formatHumanText(value, maxLength) {
22614
22828
 
22615
22829
  // src/cli/commands/init.ts
22616
22830
  import { constants as constants8 } from "node:fs";
22617
- import { access as access9, mkdir as mkdir9, readFile as readFile9, writeFile as writeFile8 } from "node:fs/promises";
22831
+ import { access as access9, mkdir as mkdir7, readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
22618
22832
  import { homedir as homedir8 } from "node:os";
22619
- import { dirname as dirname13 } from "node:path";
22833
+ import { dirname as dirname11 } from "node:path";
22620
22834
  var ZSH_COMPLETION_PATH_LINE = "fpath=(~/.zsh/completions $fpath)";
22621
22835
  var SHELL_WRAPPED_COMMANDS = [
22622
22836
  {
@@ -22654,26 +22868,12 @@ var SHELL_WRAPPED_COMMANDS = [
22654
22868
  names: ["root"],
22655
22869
  tempPrefix: "gji-root"
22656
22870
  },
22657
- {
22658
- bypassOptions: ["--help", "-h", "--json"],
22659
- commandName: "remove",
22660
- envVar: "GJI_REMOVE_OUTPUT_FILE",
22661
- names: ["remove", "rm"],
22662
- tempPrefix: "gji-remove"
22663
- },
22664
22871
  {
22665
22872
  bypassOptions: ["--help", "-h", "--json"],
22666
22873
  commandName: "done",
22667
22874
  envVar: "GJI_DONE_OUTPUT_FILE",
22668
22875
  names: ["done"],
22669
22876
  tempPrefix: "gji-done"
22670
- },
22671
- {
22672
- bypassOptions: ["--print", "--json", "--help", "-h"],
22673
- commandName: "warp",
22674
- envVar: "GJI_WARP_OUTPUT_FILE",
22675
- names: ["warp"],
22676
- tempPrefix: "gji-warp"
22677
22877
  }
22678
22878
  ];
22679
22879
  async function runInitCommand(options) {
@@ -22741,8 +22941,8 @@ async function runDefaultOnboarding(context, runtime) {
22741
22941
  const installCompletion = await promptForCompletion(shell, completionPath);
22742
22942
  if (installCompletion === null) return abortDefaultOnboarding();
22743
22943
  if (installCompletion) {
22744
- await mkdir9(dirname13(completionPath), { recursive: true });
22745
- await writeFile8(completionPath, renderShellCompletion(shell), "utf8");
22944
+ await mkdir7(dirname11(completionPath), { recursive: true });
22945
+ await writeFile5(completionPath, renderShellCompletion(shell), "utf8");
22746
22946
  }
22747
22947
  if (shell === "zsh" && (completionAlreadyInstalled || installCompletion)) {
22748
22948
  await ensureZshCompletionPath(rcPath);
@@ -22847,8 +23047,8 @@ async function applyOnboardingResult(result, home, runtime) {
22847
23047
  runtime
22848
23048
  );
22849
23049
  if (result.installCompletion) {
22850
- await mkdir9(dirname13(completionPath), { recursive: true });
22851
- await writeFile8(
23050
+ await mkdir7(dirname11(completionPath), { recursive: true });
23051
+ await writeFile5(
22852
23052
  completionPath,
22853
23053
  renderShellCompletion(result.shell),
22854
23054
  "utf8"
@@ -22867,17 +23067,17 @@ async function applyOnboardingResult(result, home, runtime) {
22867
23067
  }
22868
23068
  async function installOnboardingShellIntegration(shell, home) {
22869
23069
  const rcPath = resolveShellConfigPath(shell, home);
22870
- await mkdir9(dirname13(rcPath), { recursive: true });
23070
+ await mkdir7(dirname11(rcPath), { recursive: true });
22871
23071
  const current = await readExistingConfig(rcPath);
22872
23072
  if (shell === "fish") {
22873
- await writeFile8(
23073
+ await writeFile5(
22874
23074
  rcPath,
22875
23075
  upsertShellIntegration(current, renderShellIntegration(shell)),
22876
23076
  "utf8"
22877
23077
  );
22878
23078
  return;
22879
23079
  }
22880
- await writeFile8(
23080
+ await writeFile5(
22881
23081
  rcPath,
22882
23082
  upsertOnboardingShellIntegration(current, shell),
22883
23083
  "utf8"
@@ -22911,7 +23111,7 @@ async function ensureZshCompletionPath(rcPath) {
22911
23111
  const current = await readExistingConfig(rcPath);
22912
23112
  const next = upsertZshCompletionPath(current);
22913
23113
  if (next !== current) {
22914
- await writeFile8(rcPath, next, "utf8");
23114
+ await writeFile5(rcPath, next, "utf8");
22915
23115
  }
22916
23116
  }
22917
23117
  function upsertZshCompletionPath(existingConfig) {
@@ -22955,10 +23155,10 @@ async function runLegacyInitCommand(options, runtime) {
22955
23155
  return 0;
22956
23156
  }
22957
23157
  const rcPath = resolveShellConfigPath(shell, home);
22958
- await mkdir9(dirname13(rcPath), { recursive: true });
23158
+ await mkdir7(dirname11(rcPath), { recursive: true });
22959
23159
  const current = await readExistingConfig(rcPath);
22960
23160
  const next = upsertShellIntegration(current, script);
22961
- await writeFile8(rcPath, next, "utf8");
23161
+ await writeFile5(rcPath, next, "utf8");
22962
23162
  options.stdout(`${rcPath}
22963
23163
  `);
22964
23164
  const { config: globalConfig } = await runtime.configStore.loadGlobalConfig(home);
@@ -23312,8 +23512,8 @@ function sortWorktrees(worktrees) {
23312
23512
 
23313
23513
  // src/cli/commands/open.ts
23314
23514
  import { execFile as execFile9 } from "node:child_process";
23315
- import { access as access10, writeFile as writeFile9 } from "node:fs/promises";
23316
- import { join as join14 } from "node:path";
23515
+ import { access as access10, writeFile as writeFile6 } from "node:fs/promises";
23516
+ import { join as join15 } from "node:path";
23317
23517
  import { promisify as promisify10 } from "node:util";
23318
23518
  var execFileAsync9 = promisify10(execFile9);
23319
23519
  function createOpenCommand(dependencies = {}) {
@@ -23335,7 +23535,7 @@ function createOpenCommand(dependencies = {}) {
23335
23535
  }
23336
23536
  if (options.select && isHeadless()) {
23337
23537
  options.stderr(
23338
- "gji open --select: selector is unavailable in non-interactive mode (GJI_NO_TUI=1)\n"
23538
+ "gji open --select: selector is unavailable in non-interactive mode; pass a branch instead\n"
23339
23539
  );
23340
23540
  return 1;
23341
23541
  }
@@ -23492,14 +23692,14 @@ async function defaultPromptForEditor(editors) {
23492
23692
  return choice;
23493
23693
  }
23494
23694
  async function ensureWorkspaceFile(worktreePath, repoName) {
23495
- const workspacePath = join14(worktreePath, `${repoName}.code-workspace`);
23695
+ const workspacePath = join15(worktreePath, `${repoName}.code-workspace`);
23496
23696
  try {
23497
23697
  await access10(workspacePath);
23498
23698
  return workspacePath;
23499
23699
  } catch {
23500
23700
  }
23501
23701
  const workspace = { folders: [{ path: "." }], settings: {} };
23502
- await writeFile9(
23702
+ await writeFile6(
23503
23703
  workspacePath,
23504
23704
  `${JSON.stringify(workspace, null, 2)}
23505
23705
  `,
@@ -23527,7 +23727,7 @@ function createPrOpenCommand(dependencies = {}) {
23527
23727
  }
23528
23728
  if (options.select && isHeadless()) {
23529
23729
  options.stderr(
23530
- "gji pr open --select: selector is unavailable in non-interactive mode (GJI_NO_TUI=1)\n"
23730
+ "gji pr open --select: selector is unavailable in non-interactive mode; pass a branch or PR number instead\n"
23531
23731
  );
23532
23732
  return 1;
23533
23733
  }
@@ -23827,7 +24027,7 @@ async function openPullRequests(pullRequests, options, promptForPullRequest, ope
23827
24027
  }
23828
24028
  if (pullRequests.length > 1 && isHeadless()) {
23829
24029
  options.stderr(
23830
- "gji pr open: multiple open PRs found; pass an explicit PR number in non-interactive mode (GJI_NO_TUI=1)\n"
24030
+ "gji pr open: multiple open PRs found; pass an explicit PR number in non-interactive mode\n"
23831
24031
  );
23832
24032
  return 1;
23833
24033
  }
@@ -23878,250 +24078,6 @@ function formatError(error) {
23878
24078
  return error instanceof Error ? error.message : String(error);
23879
24079
  }
23880
24080
 
23881
- // src/cli/commands/remove.ts
23882
- import { basename as basename12 } from "node:path";
23883
- var REMOVE_OUTPUT_FILE_ENV = "GJI_REMOVE_OUTPUT_FILE";
23884
- function createRemoveCommand(dependencies = {}) {
23885
- const promptForWorktree2 = dependencies.promptForWorktree ?? defaultPromptForWorktree3;
23886
- const confirmRemoval = dependencies.confirmRemoval ?? defaultConfirmRemoval2;
23887
- const confirmForceRemoveWorktree = dependencies.confirmForceRemoveWorktree ?? defaultConfirmForceRemoveWorktree;
23888
- const confirmForceDeleteBranch = dependencies.confirmForceDeleteBranch ?? defaultConfirmForceDeleteBranch;
23889
- return async function runRemoveCommand2(options) {
23890
- const runtime = options.runtime ?? defaultCliDependencies;
23891
- const { loadEffectiveConfig: loadEffectiveConfig2 } = runtime.config;
23892
- const { releaseWorktreeSlot: releaseWorktreeSlot2 } = runtime.slots;
23893
- const { extractHooks: extractHooks2, runHook: runHook2 } = runtime.hooks;
23894
- const sourceDependencies = {
23895
- ...runtime.repositoryContext,
23896
- ...runtime.repositoryRegistry,
23897
- ...runtime.worktrees
23898
- };
23899
- const {
23900
- deleteBranch: deleteBranch2,
23901
- forceDeleteBranch: forceDeleteBranch2,
23902
- forceRemoveWorktree: forceRemoveWorktree2,
23903
- isBranchUnmergedError: isBranchUnmergedError2,
23904
- isWorktreeForceRemovalError: isWorktreeForceRemovalError2,
23905
- removeWorktree: removeWorktree2
23906
- } = runtime.worktreeLifecycle;
23907
- const { linkedWorktrees, repository } = await loadLinkedWorktrees(
23908
- options.cwd,
23909
- sourceDependencies
23910
- );
23911
- if (linkedWorktrees.length === 0) {
23912
- emitError3(options, "No linked worktrees to finish");
23913
- return 1;
23914
- }
23915
- if (!options.branch && (options.json || isHeadless())) {
23916
- const message = "branch argument is required";
23917
- if (options.json) {
23918
- emitError3(options, message);
23919
- } else {
23920
- options.stderr(
23921
- `gji remove: ${message} in non-interactive mode (GJI_NO_TUI=1)
23922
- `
23923
- );
23924
- }
23925
- return 1;
23926
- }
23927
- const selection = options.branch ?? await promptForWorktree2(
23928
- await buildWorktreePromptEntries(
23929
- linkedWorktrees.map((worktree2) => ({
23930
- repoName: repository.repoName,
23931
- worktree: worktree2
23932
- })),
23933
- { catalog: runtime.worktreeCatalog }
23934
- )
23935
- );
23936
- if (!selection) {
23937
- options.stderr("Aborted\n");
23938
- return 1;
23939
- }
23940
- const worktree = linkedWorktrees.find(
23941
- (entry) => entry.branch === selection || entry.path === selection
23942
- );
23943
- if (!worktree) {
23944
- emitError3(options, `No linked worktree found for branch: ${selection}`);
23945
- return 1;
23946
- }
23947
- if (!options.dryRun && !options.force && (options.json || isHeadless())) {
23948
- const message = "--force is required";
23949
- if (options.json) {
23950
- emitError3(options, message);
23951
- } else {
23952
- options.stderr(
23953
- `gji remove: ${message} in non-interactive mode (GJI_NO_TUI=1)
23954
- `
23955
- );
23956
- }
23957
- return 1;
23958
- }
23959
- if (!options.dryRun && !options.force && !await confirmRemoval(worktree)) {
23960
- options.stderr("Aborted\n");
23961
- return 1;
23962
- }
23963
- if (options.dryRun) {
23964
- if (options.json) {
23965
- options.stdout(
23966
- `${JSON.stringify({ branch: worktree.branch, path: worktree.path, dryRun: true }, null, 2)}
23967
- `
23968
- );
23969
- } else {
23970
- const desc = worktree.branch ? `branch: ${worktree.branch}` : "detached";
23971
- options.stdout(`Would remove worktree at ${worktree.path} (${desc})
23972
- `);
23973
- }
23974
- return 0;
23975
- }
23976
- const config = await loadEffectiveConfig2(
23977
- repository.repoRoot,
23978
- void 0,
23979
- options.stderr
23980
- );
23981
- const hooks = extractHooks2(config);
23982
- let journal;
23983
- try {
23984
- journal = await recordUndoOperation(
23985
- "remove",
23986
- repository.repoRoot,
23987
- [worktree],
23988
- void 0,
23989
- runtime
23990
- );
23991
- } catch (error) {
23992
- emitError3(
23993
- options,
23994
- `could not write undo journal; no worktree was removed: ${toMessage4(error)}`
23995
- );
23996
- return 1;
23997
- }
23998
- if (!journal) {
23999
- emitError3(
24000
- options,
24001
- "could not capture undo state; no worktree was removed"
24002
- );
24003
- return 1;
24004
- }
24005
- await runHook2(
24006
- hooks["before-remove"],
24007
- worktree.path,
24008
- {
24009
- branch: worktree.branch ?? void 0,
24010
- path: worktree.path,
24011
- repo: basename12(repository.repoRoot)
24012
- },
24013
- options.stderr
24014
- );
24015
- try {
24016
- await removeWorktree2(repository.repoRoot, worktree.path);
24017
- } catch (error) {
24018
- if (!isWorktreeForceRemovalError2(error)) {
24019
- await finalizeUndoOperation(
24020
- journal,
24021
- [],
24022
- void 0,
24023
- runtime.configStore.GLOBAL_CONFIG_DIRECTORY
24024
- );
24025
- throw error;
24026
- }
24027
- if (!options.force && !await confirmForceRemoveWorktree(worktree.path)) {
24028
- await finalizeUndoOperation(
24029
- journal,
24030
- [],
24031
- void 0,
24032
- runtime.configStore.GLOBAL_CONFIG_DIRECTORY
24033
- );
24034
- options.stderr("Aborted\n");
24035
- return 1;
24036
- }
24037
- try {
24038
- await forceRemoveWorktree2(repository.repoRoot, worktree.path);
24039
- } catch (forceError) {
24040
- await finalizeUndoOperation(
24041
- journal,
24042
- [],
24043
- void 0,
24044
- runtime.configStore.GLOBAL_CONFIG_DIRECTORY
24045
- );
24046
- emitError3(
24047
- options,
24048
- `Failed to remove worktree at ${worktree.path}: ${toMessage4(forceError)}`
24049
- );
24050
- return 1;
24051
- }
24052
- }
24053
- await finalizeUndoOperation(
24054
- journal,
24055
- [worktree],
24056
- void 0,
24057
- runtime.configStore.GLOBAL_CONFIG_DIRECTORY
24058
- );
24059
- await releaseWorktreeSlot2(worktree.path);
24060
- if (worktree.branch) {
24061
- try {
24062
- await deleteBranch2(repository.repoRoot, worktree.branch);
24063
- } catch (error) {
24064
- if (!isBranchUnmergedError2(error)) {
24065
- throw error;
24066
- }
24067
- if (options.force || await confirmForceDeleteBranch(worktree.branch)) {
24068
- try {
24069
- await forceDeleteBranch2(repository.repoRoot, worktree.branch);
24070
- } catch (forceError) {
24071
- options.stderr(
24072
- `Failed to delete branch ${worktree.branch}: ${toMessage4(forceError)}
24073
- `
24074
- );
24075
- }
24076
- } else {
24077
- options.stderr(
24078
- `Branch ${worktree.branch} was not deleted (has unmerged commits)
24079
- `
24080
- );
24081
- }
24082
- }
24083
- }
24084
- if (options.json) {
24085
- options.stdout(
24086
- `${JSON.stringify({ branch: worktree.branch, path: worktree.path, deleted: true }, null, 2)}
24087
- `
24088
- );
24089
- } else {
24090
- options.stderr("undo: gji undo\n");
24091
- await writeOutput3(repository.repoRoot, options.stdout);
24092
- }
24093
- return 0;
24094
- };
24095
- }
24096
- var runRemoveCommand = createRemoveCommand();
24097
- async function defaultPromptForWorktree3(worktrees) {
24098
- return promptForSingleWorktree("Choose a worktree to finish", worktrees);
24099
- }
24100
- async function defaultConfirmRemoval2(worktree) {
24101
- const choice = await ye({
24102
- message: worktree.branch ? `Remove worktree and delete branch ${worktree.branch}?` : `Remove detached worktree ${worktree.path}?`,
24103
- active: "Yes",
24104
- inactive: "No",
24105
- initialValue: true
24106
- });
24107
- return !pD(choice) && choice;
24108
- }
24109
- async function writeOutput3(repoRoot, stdout3) {
24110
- await writeShellOutput(REMOVE_OUTPUT_FILE_ENV, repoRoot, stdout3);
24111
- }
24112
- function emitError3(options, message) {
24113
- if (options.json) {
24114
- options.stderr(`${JSON.stringify({ error: message }, null, 2)}
24115
- `);
24116
- } else {
24117
- options.stderr(`${message}
24118
- `);
24119
- }
24120
- }
24121
- function toMessage4(error) {
24122
- return error instanceof Error ? error.message : String(error);
24123
- }
24124
-
24125
24081
  // src/cli/commands/root.ts
24126
24082
  var ROOT_OUTPUT_FILE_ENV = "GJI_ROOT_OUTPUT_FILE";
24127
24083
  async function runRootCommand(options) {
@@ -24142,13 +24098,17 @@ async function runRootCommand(options) {
24142
24098
 
24143
24099
  // src/cli/commands/run-hook.ts
24144
24100
  var VALID_HOOKS = [
24101
+ "after-branch-create",
24145
24102
  "after-create",
24146
24103
  "after-enter",
24104
+ "after-switch",
24147
24105
  "before-remove"
24148
24106
  ];
24149
24107
  var CAMEL_ALIASES = {
24108
+ afterBranchCreate: "after-branch-create",
24150
24109
  afterCreate: "after-create",
24151
24110
  afterEnter: "after-enter",
24111
+ afterSwitch: "after-switch",
24152
24112
  beforeRemove: "before-remove"
24153
24113
  };
24154
24114
  function isValidHook(hook) {
@@ -24345,7 +24305,7 @@ async function runSyncCommand(options) {
24345
24305
  resolveConfiguredString2(config.syncDefaultBranch) ?? void 0
24346
24306
  );
24347
24307
  } catch {
24348
- emitError4(options, `Unable to reach remote '${remote}'`);
24308
+ emitError3(options, `Unable to reach remote '${remote}'`);
24349
24309
  if (!options.json) {
24350
24310
  options.stderr(
24351
24311
  `Hint: Add the remote with: git remote add ${remote} <url>
@@ -24355,7 +24315,7 @@ async function runSyncCommand(options) {
24355
24315
  return 1;
24356
24316
  }
24357
24317
  if (!remoteBase) {
24358
- emitError4(options, "Unable to determine the default branch for sync.");
24318
+ emitError3(options, "Unable to determine the default branch for sync.");
24359
24319
  if (!options.json) {
24360
24320
  options.stderr(
24361
24321
  `Hint: Add the remote with: git remote add ${remote} <url>
@@ -24370,7 +24330,7 @@ async function runSyncCommand(options) {
24370
24330
  options.all
24371
24331
  );
24372
24332
  if (targetWorktrees === "detached") {
24373
- emitError4(
24333
+ emitError3(
24374
24334
  options,
24375
24335
  `Cannot sync detached worktree: ${repository.currentRoot}`
24376
24336
  );
@@ -24378,14 +24338,14 @@ async function runSyncCommand(options) {
24378
24338
  }
24379
24339
  for (const worktree of targetWorktrees) {
24380
24340
  if (await isDirtyWorktree2(worktree.path)) {
24381
- emitError4(options, `Cannot sync dirty worktree: ${worktree.path}`);
24341
+ emitError3(options, `Cannot sync dirty worktree: ${worktree.path}`);
24382
24342
  return 1;
24383
24343
  }
24384
24344
  }
24385
24345
  try {
24386
24346
  await runGit2(repository.repoRoot, ["fetch", "--prune", remote]);
24387
24347
  } catch {
24388
- emitError4(options, `Failed to fetch from remote '${remote}'`);
24348
+ emitError3(options, `Failed to fetch from remote '${remote}'`);
24389
24349
  if (!options.json) {
24390
24350
  options.stderr(
24391
24351
  `Hint: Add the remote with: git remote add ${remote} <url>
@@ -24417,7 +24377,7 @@ async function runSyncCommand(options) {
24417
24377
  }
24418
24378
  return 0;
24419
24379
  }
24420
- function emitError4(options, message) {
24380
+ function emitError3(options, message) {
24421
24381
  if (options.json) {
24422
24382
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}
24423
24383
  `);
@@ -24447,7 +24407,7 @@ function resolveConfiguredString2(value) {
24447
24407
 
24448
24408
  // src/cli/commands/sync-files-command.ts
24449
24409
  import { homedir as homedir9 } from "node:os";
24450
- import { join as join15 } from "node:path";
24410
+ import { join as join16 } from "node:path";
24451
24411
  async function runSyncFilesCommand(options) {
24452
24412
  const runtime = options.runtime ?? defaultCliDependencies;
24453
24413
  const { validateSyncFilePattern: validateSyncFilePattern2 } = runtime.filesystem;
@@ -24601,7 +24561,7 @@ function writeError(options, message) {
24601
24561
  }
24602
24562
  function expandTilde3(value, home) {
24603
24563
  if (value === "~") return home;
24604
- if (value.startsWith("~/")) return join15(home, value.slice(2));
24564
+ if (value.startsWith("~/")) return join16(home, value.slice(2));
24605
24565
  return value;
24606
24566
  }
24607
24567
 
@@ -24638,144 +24598,6 @@ function emitTaskError(options, message) {
24638
24598
  return 1;
24639
24599
  }
24640
24600
 
24641
- // src/cli/commands/warp.ts
24642
- var WARP_OUTPUT_FILE_ENV = "GJI_WARP_OUTPUT_FILE";
24643
- async function runWarpCommand(options) {
24644
- return runWarpNavigate(options);
24645
- }
24646
- async function runWarpNavigate(options) {
24647
- const runtime = options.runtime ?? defaultCliDependencies;
24648
- const { recordWorktreeUsage: recordWorktreeUsage2 } = runtime.history;
24649
- if ((isHeadless() || options.json) && !options.branch) {
24650
- const message = "branch argument is required";
24651
- if (options.json) {
24652
- options.stderr(`${JSON.stringify({ error: message }, null, 2)}
24653
- `);
24654
- } else {
24655
- options.stderr(
24656
- "gji warp: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n"
24657
- );
24658
- }
24659
- return 1;
24660
- }
24661
- const target = await resolveWarpTarget({
24662
- ...options,
24663
- commandName: "gji warp",
24664
- json: options.json,
24665
- queryPullRequests: options.queryPullRequests,
24666
- runtime
24667
- });
24668
- if (!target) return 1;
24669
- if (options.json) {
24670
- options.stdout(
24671
- `${JSON.stringify(
24672
- createNavigationTarget(target.repository, target.path, target.branch),
24673
- null,
24674
- 2
24675
- )}
24676
- `
24677
- );
24678
- return 0;
24679
- }
24680
- await recordWorktreeUsage2(target.path, target.branch);
24681
- await writeShellOutput(WARP_OUTPUT_FILE_ENV, target.path, options.stdout);
24682
- return 0;
24683
- }
24684
- async function resolveWarpTarget(options) {
24685
- const cmd = options.commandName ?? "gji";
24686
- const runtime = options.runtime ?? defaultCliDependencies;
24687
- const { detectRepository: detectRepository2 } = runtime.repositoryContext;
24688
- const { loadRegistry: loadRegistry2 } = runtime.repositoryRegistry;
24689
- const sourceDependencies = {
24690
- ...runtime.repositoryContext,
24691
- ...runtime.repositoryRegistry,
24692
- ...runtime.worktrees
24693
- };
24694
- const emitError5 = (message, hint) => {
24695
- if (options.json) {
24696
- options.stderr(`${JSON.stringify({ error: message }, null, 2)}
24697
- `);
24698
- } else {
24699
- options.stderr(`${cmd}: ${message}
24700
- `);
24701
- if (hint) options.stderr(hint);
24702
- }
24703
- };
24704
- const registry = await loadRegistry2();
24705
- if (registry.length === 0) {
24706
- emitError5(
24707
- "not in a git repository and no repos registered yet.",
24708
- "Use any gji command inside a repository to register it.\n"
24709
- );
24710
- return null;
24711
- }
24712
- const registered = await listRegisteredWorktreeSources({
24713
- cwd: options.cwd,
24714
- repositoryPort: sourceDependencies
24715
- });
24716
- const skippedRegisteredRepos = registered.skipped.length;
24717
- const allItems = registered.sources.filter(
24718
- (item) => item.repoRoot !== options.excludeRepoRoot
24719
- );
24720
- if (allItems.length === 0) {
24721
- emitError5(
24722
- "no accessible worktrees found in any registered repo.",
24723
- skippedRegisteredRepos > 0 ? "Hint: Run 'gji doctor' to inspect stale repository entries.\n" : void 0
24724
- );
24725
- return null;
24726
- }
24727
- const promptSources = allItems.map((item) => ({
24728
- repoRoot: item.repoRoot,
24729
- repoName: item.repoName,
24730
- worktree: item.worktree
24731
- }));
24732
- if (options.branch) {
24733
- const match = resolveWorktreeQuery(promptSources, options.branch);
24734
- if (!match) {
24735
- emitError5(
24736
- `no worktree found matching: ${options.branch}`,
24737
- skippedRegisteredRepos > 0 ? "Hint: Run 'gji doctor' to inspect stale repository entries.\n" : void 0
24738
- );
24739
- return null;
24740
- }
24741
- return {
24742
- branch: match.worktree.branch,
24743
- path: match.worktree.path,
24744
- repository: createNavigationRepository(
24745
- match.repoName,
24746
- match.repoRoot ?? (await detectRepository2(match.worktree.path)).repoRoot
24747
- )
24748
- };
24749
- }
24750
- const promptEntries = await buildWorktreePromptEntries(promptSources, {
24751
- metadata: "fast",
24752
- catalog: withPullRequestQueries(runtime, options.queryPullRequests)
24753
- });
24754
- const path9 = await promptForWarpTarget(promptEntries);
24755
- if (!path9) {
24756
- options.stderr("Aborted\n");
24757
- return null;
24758
- }
24759
- const chosenSource = promptSources.find(
24760
- (item) => item.worktree.path === path9
24761
- );
24762
- if (chosenSource === void 0) {
24763
- emitError5("selected worktree is no longer registered");
24764
- return null;
24765
- }
24766
- return {
24767
- branch: chosenSource.worktree.branch,
24768
- path: path9,
24769
- repository: createNavigationRepository(
24770
- chosenSource.repoName,
24771
- chosenSource.repoRoot ?? (await detectRepository2(chosenSource.worktree.path)).repoRoot
24772
- )
24773
- };
24774
- }
24775
- async function promptForWarpTarget(items) {
24776
- return promptForSingleWorktree("Warp to a worktree", items);
24777
- }
24778
-
24779
24601
  // src/cli/program.ts
24780
24602
  function createProgram() {
24781
24603
  const program2 = new Command();
@@ -24969,29 +24791,17 @@ function registerCommands(program2) {
24969
24791
  ).option(
24970
24792
  "--stale",
24971
24793
  "only target clean worktrees whose upstream is gone and branch is merged into the default branch"
24972
- ).option("--dry-run", "show what would be deleted without removing anything").option(
24973
- "--json",
24974
- "emit JSON on success or error instead of human-readable output"
24975
- ).action(notImplemented("clean"));
24976
- program2.command("remove [branch]").alias("rm").description(
24977
- "deprecated: use gji done for one worktree or gji clean for bulk cleanup"
24978
24794
  ).option(
24979
- "-f, --force",
24980
- "bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch"
24795
+ "--path <pattern>",
24796
+ "only target worktrees under a directory or matching a path glob (repeatable)",
24797
+ collectOption
24981
24798
  ).option("--dry-run", "show what would be deleted without removing anything").option(
24982
24799
  "--json",
24983
24800
  "emit JSON on success or error instead of human-readable output"
24984
- ).action(notImplemented("remove"));
24801
+ ).action(notImplemented("clean"));
24985
24802
  program2.command("run-hook <hook>").alias("trigger-hook").description(
24986
- "run a named hook (after-create, after-enter, before-remove) in the current worktree"
24803
+ "run a named hook (after-branch-create, after-create, after-enter, after-switch, before-remove) in the current worktree"
24987
24804
  ).action(notImplemented("run-hook"));
24988
- program2.command("warp [branch]").description("deprecated: use gji go (press Tab for all known repos)").option(
24989
- "--print",
24990
- "print the resolved worktree path without changing directory"
24991
- ).option(
24992
- "--json",
24993
- "emit JSON on success or error instead of human-readable output"
24994
- ).action(notImplemented("warp"));
24995
24805
  const configCommand = program2.command("config").description("manage global config defaults").action(notImplemented("config"));
24996
24806
  configCommand.command("get [key]").description("print the global config or a single key").action(notImplemented("config get"));
24997
24807
  configCommand.command("set <key> <value>").description("set a global config value").action(notImplemented("config set"));
@@ -25336,6 +25146,7 @@ function attachCommandActions(program2, options) {
25336
25146
  dryRun: commandOptions2.dryRun,
25337
25147
  force: commandOptions2.force,
25338
25148
  json: commandOptions2.json,
25149
+ paths: commandOptions2.path,
25339
25150
  stale: commandOptions2.stale,
25340
25151
  runtime: options.dependencies,
25341
25152
  stderr: options.stderr,
@@ -25346,31 +25157,6 @@ function attachCommandActions(program2, options) {
25346
25157
  }
25347
25158
  }
25348
25159
  );
25349
- const runRemovalCommand = async (branch, commandOptions2 = {}) => {
25350
- const exitCode = await runRemoveCommand({
25351
- branch,
25352
- cwd: options.cwd,
25353
- dryRun: commandOptions2.dryRun,
25354
- force: commandOptions2.force,
25355
- json: commandOptions2.json,
25356
- runtime: options.dependencies,
25357
- stderr: options.stderr,
25358
- stdout: options.stdout
25359
- });
25360
- if (exitCode !== 0) {
25361
- throw commanderExit(exitCode);
25362
- }
25363
- };
25364
- requireCommand(program2, "remove").action(
25365
- async (branch, commandOptions2) => {
25366
- if (!commandOptions2.json) {
25367
- options.stderr(
25368
- "gji remove is deprecated; use 'gji done' for one worktree or 'gji clean' for bulk cleanup.\n"
25369
- );
25370
- }
25371
- return runRemovalCommand(branch, commandOptions2);
25372
- }
25373
- );
25374
25160
  requireCommand(program2, "run-hook").action(async (hook) => {
25375
25161
  const exitCode = await runHookCommand({
25376
25162
  cwd: options.cwd,
@@ -25382,26 +25168,6 @@ function attachCommandActions(program2, options) {
25382
25168
  throw commanderExit(exitCode);
25383
25169
  }
25384
25170
  });
25385
- requireCommand(program2, "warp").action(
25386
- async (branch, commandOptions2) => {
25387
- if (!commandOptions2.json) {
25388
- options.stderr(
25389
- "gji warp is deprecated; use 'gji go' (press Tab for all repositories).\n"
25390
- );
25391
- }
25392
- const exitCode = await runWarpCommand({
25393
- branch,
25394
- cwd: options.cwd,
25395
- json: commandOptions2.json,
25396
- runtime: options.dependencies,
25397
- stderr: options.stderr,
25398
- stdout: options.stdout
25399
- });
25400
- if (exitCode !== 0) {
25401
- throw commanderExit(exitCode);
25402
- }
25403
- }
25404
- );
25405
25171
  const configCommand = requireCommand(program2, "config");
25406
25172
  configCommand.action(async () => {
25407
25173
  const exitCode = await runConfigCommand({
@@ -25463,6 +25229,9 @@ function requireCommand(parent, name) {
25463
25229
  }
25464
25230
  return command;
25465
25231
  }
25232
+ function collectOption(value, previous = []) {
25233
+ return [...previous, value];
25234
+ }
25466
25235
  function notImplemented(commandName) {
25467
25236
  return () => {
25468
25237
  throw new Error(`'${commandName}' is not implemented yet.`);