@solaqua/gji 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +83 -426
  2. package/dist/back.d.ts +3 -0
  3. package/dist/back.js +19 -7
  4. package/dist/bootstrap-output.d.ts +3 -0
  5. package/dist/bootstrap-output.js +27 -0
  6. package/dist/bootstrap-preview.d.ts +10 -0
  7. package/dist/bootstrap-preview.js +21 -0
  8. package/dist/clean.js +27 -3
  9. package/dist/cli.d.ts +4 -0
  10. package/dist/cli.js +140 -22
  11. package/dist/clone-failure-store.d.ts +18 -0
  12. package/dist/clone-failure-store.js +230 -0
  13. package/dist/command-runner.d.ts +6 -0
  14. package/dist/command-runner.js +38 -0
  15. package/dist/completion.js +7 -2
  16. package/dist/config-command.d.ts +1 -0
  17. package/dist/config-command.js +7 -1
  18. package/dist/config.d.ts +26 -1
  19. package/dist/config.js +110 -6
  20. package/dist/context-card.d.ts +1 -0
  21. package/dist/context-card.js +25 -0
  22. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  23. package/dist/dependency-bootstrap-prompt.js +103 -0
  24. package/dist/dependency-bootstrap.d.ts +129 -0
  25. package/dist/dependency-bootstrap.js +566 -0
  26. package/dist/dir-clone.d.ts +33 -0
  27. package/dist/dir-clone.js +505 -0
  28. package/dist/doctor.js +21 -0
  29. package/dist/done.d.ts +10 -0
  30. package/dist/done.js +210 -0
  31. package/dist/file-sync.js +71 -22
  32. package/dist/format-bytes.d.ts +1 -0
  33. package/dist/format-bytes.js +14 -0
  34. package/dist/fs-utils.d.ts +4 -0
  35. package/dist/fs-utils.js +26 -0
  36. package/dist/git.d.ts +5 -0
  37. package/dist/git.js +50 -0
  38. package/dist/gji-bundle.mjs +6189 -1313
  39. package/dist/go-resolver.d.ts +35 -0
  40. package/dist/go-resolver.js +185 -0
  41. package/dist/go.d.ts +6 -2
  42. package/dist/go.js +270 -62
  43. package/dist/hooks.d.ts +2 -1
  44. package/dist/hooks.js +20 -8
  45. package/dist/hub.d.ts +31 -0
  46. package/dist/hub.js +287 -0
  47. package/dist/init.js +30 -22
  48. package/dist/install-prompt.d.ts +1 -0
  49. package/dist/install-prompt.js +3 -25
  50. package/dist/ls.js +9 -1
  51. package/dist/navigation-output.d.ts +11 -0
  52. package/dist/navigation-output.js +6 -0
  53. package/dist/new.d.ts +14 -2
  54. package/dist/new.js +422 -38
  55. package/dist/pr-open.js +2 -10
  56. package/dist/pr.d.ts +6 -2
  57. package/dist/pr.js +135 -30
  58. package/dist/pull-requests.d.ts +1 -0
  59. package/dist/pull-requests.js +32 -3
  60. package/dist/remove.js +22 -0
  61. package/dist/repo.d.ts +4 -0
  62. package/dist/repo.js +61 -6
  63. package/dist/run-hook.js +2 -0
  64. package/dist/safe-destination.d.ts +15 -0
  65. package/dist/safe-destination.js +166 -0
  66. package/dist/shell-completion.js +52 -13
  67. package/dist/slots.d.ts +8 -0
  68. package/dist/slots.js +61 -0
  69. package/dist/status.d.ts +2 -0
  70. package/dist/status.js +9 -1
  71. package/dist/sync-directories.d.ts +33 -0
  72. package/dist/sync-directories.js +97 -0
  73. package/dist/sync-plan.d.ts +16 -0
  74. package/dist/sync-plan.js +128 -0
  75. package/dist/sync.js +7 -13
  76. package/dist/task-command.d.ts +9 -0
  77. package/dist/task-command.js +29 -0
  78. package/dist/task.d.ts +9 -0
  79. package/dist/task.js +37 -0
  80. package/dist/terminal-text.d.ts +4 -0
  81. package/dist/terminal-text.js +72 -0
  82. package/dist/undo.d.ts +36 -0
  83. package/dist/undo.js +392 -0
  84. package/dist/warp.d.ts +3 -1
  85. package/dist/warp.js +32 -125
  86. package/dist/worktree-bootstrap.d.ts +35 -0
  87. package/dist/worktree-bootstrap.js +96 -0
  88. package/dist/worktree-info.d.ts +6 -1
  89. package/dist/worktree-info.js +14 -4
  90. package/dist/worktree-management.d.ts +1 -0
  91. package/dist/worktree-management.js +23 -4
  92. package/dist/worktree-picker.d.ts +24 -11
  93. package/dist/worktree-picker.js +176 -95
  94. package/dist/worktree-prompts.js +1 -1
  95. package/dist/worktree-source.d.ts +6 -0
  96. package/dist/worktree-source.js +1 -0
  97. package/dist/worktree-sources.d.ts +4 -0
  98. package/dist/worktree-sources.js +77 -0
  99. package/man/man1/gji-back.1 +1 -1
  100. package/man/man1/gji-clean.1 +1 -1
  101. package/man/man1/gji-completion.1 +1 -1
  102. package/man/man1/gji-config.1 +1 -1
  103. package/man/man1/gji-doctor.1 +1 -1
  104. package/man/man1/gji-done.1 +19 -0
  105. package/man/man1/gji-go.1 +12 -3
  106. package/man/man1/gji-history.1 +1 -1
  107. package/man/man1/gji-init.1 +1 -1
  108. package/man/man1/gji-ls.1 +1 -1
  109. package/man/man1/gji-new.1 +21 -3
  110. package/man/man1/gji-open.1 +1 -1
  111. package/man/man1/gji-pr.1 +7 -1
  112. package/man/man1/gji-remove.1 +3 -3
  113. package/man/man1/gji-root.1 +1 -1
  114. package/man/man1/gji-run-hook.1 +1 -1
  115. package/man/man1/gji-status.1 +1 -1
  116. package/man/man1/gji-sync-files.1 +1 -1
  117. package/man/man1/gji-sync.1 +1 -1
  118. package/man/man1/gji-task.1 +16 -0
  119. package/man/man1/gji-undo.1 +16 -0
  120. package/man/man1/gji-warp.1 +3 -6
  121. package/man/man1/gji.1 +17 -5
  122. package/package.json +3 -1
package/dist/pr.js CHANGED
@@ -1,16 +1,20 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { mkdir } from "node:fs/promises";
3
- import { basename, dirname } from "node:path";
3
+ import { dirname } from "node:path";
4
4
  import { promisify } from "node:util";
5
- import { loadEffectiveConfig, resolveConfigString } from "./config.js";
5
+ import { createBootstrapReporter } from "./bootstrap-output.js";
6
+ import { createDependencyBootstrapPreview, formatDependencyBootstrapPreview, } from "./bootstrap-preview.js";
7
+ import { loadEffectiveConfigResult, resolveConfigString, } from "./config.js";
6
8
  import { pathExists, promptForPathConflict, } from "./conflict.js";
7
- import { syncFiles } from "./file-sync.js";
9
+ import { resolveDependencyBootstrapPolicy, } from "./dependency-bootstrap-prompt.js";
10
+ import { formatBytes } from "./format-bytes.js";
8
11
  import { isHeadless } from "./headless.js";
9
12
  import { recordWorktreeUsage } from "./history.js";
10
- import { extractHooks, runHook } from "./hooks.js";
11
- import { maybeRunInstallPrompt, } from "./install-prompt.js";
13
+ import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js";
12
14
  import { detectRepository, resolveWorktreePath } from "./repo.js";
13
15
  import { writeShellOutput } from "./shell-handoff.js";
16
+ import { estimateSyncDirectories } from "./sync-plan.js";
17
+ import { bootstrapWorktree } from "./worktree-bootstrap.js";
14
18
  const execFileAsync = promisify(execFile);
15
19
  const PR_OUTPUT_FILE_ENV = "GJI_PR_OUTPUT_FILE";
16
20
  export function parsePrInput(input) {
@@ -39,7 +43,23 @@ export function createPrCommand(dependencies = {}) {
39
43
  return 1;
40
44
  }
41
45
  const repository = await detectRepository(options.cwd);
42
- const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
46
+ let config;
47
+ let dependencyBootstrapExplicit;
48
+ try {
49
+ const loaded = await loadEffectiveConfigResult(repository.repoRoot, undefined, options.json ? undefined : options.stderr);
50
+ config = loaded.config;
51
+ dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit;
52
+ }
53
+ catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ if (options.json) {
56
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
57
+ }
58
+ else {
59
+ options.stderr(`gji pr: ${message}\n`);
60
+ }
61
+ return 1;
62
+ }
43
63
  const branchName = `pr/${prNumber}`;
44
64
  const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`;
45
65
  const rawBasePath = resolveConfigString(config, "worktreePath");
@@ -55,25 +75,66 @@ export function createPrCommand(dependencies = {}) {
55
75
  }
56
76
  else {
57
77
  options.stderr(`gji pr: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
58
- options.stderr(`Hint: Use 'gji remove pr/${prNumber}' or 'gji clean' to remove the existing worktree\n`);
78
+ options.stderr(`Hint: Use 'gji done pr/${prNumber}' or 'gji clean' to remove the existing worktree\n`);
59
79
  }
60
80
  return 1;
61
81
  }
62
82
  const choice = await prompt(worktreePath);
63
83
  if (choice === "reuse") {
64
84
  await recordWorktreeUsage(worktreePath, branchName);
65
- await writeOutput(worktreePath, options.stdout);
85
+ await writeOutput(worktreePath, options.stdout, options.outputEnv);
66
86
  return 0;
67
87
  }
68
88
  options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
69
89
  return 1;
70
90
  }
91
+ let dependencyPolicy = {
92
+ mode: config.dependencyBootstrap ?? "off",
93
+ prompted: false,
94
+ source: dependencyBootstrapExplicit ? "explicit" : "default",
95
+ };
96
+ if (options.dryRun) {
97
+ dependencyPolicy = await resolveDependencyBootstrapPolicy({
98
+ currentRoot: repository.currentRoot,
99
+ repoRoot: repository.repoRoot,
100
+ worktreePath,
101
+ }, config, dependencyBootstrapExplicit, {
102
+ dependencies,
103
+ dryRun: true,
104
+ legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
105
+ nonInteractive: !!options.json,
106
+ stderr: options.stderr,
107
+ });
108
+ config = {
109
+ ...config,
110
+ dependencyBootstrap: dependencyPolicy.mode,
111
+ };
112
+ }
113
+ const dryRunSyncDirs = options.dryRun
114
+ ? await estimateSyncDirectories(repository.repoRoot, worktreePath, config.syncDirs ?? [])
115
+ : [];
116
+ const dryRunDependencyBootstrap = options.dryRun
117
+ ? await createDependencyBootstrapPreview(config.dependencyBootstrap ?? "off", {
118
+ currentRoot: repository.currentRoot,
119
+ repoRoot: repository.repoRoot,
120
+ cargoBuildCommand: config.dependencyBuildCommand,
121
+ worktreePath,
122
+ })
123
+ : undefined;
71
124
  if (options.dryRun) {
72
125
  if (options.json) {
73
- options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath, dryRun: true }, null, 2)}\n`);
126
+ const output = {
127
+ ...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, branchName),
128
+ dryRun: true,
129
+ };
130
+ if (dryRunSyncDirs.length > 0)
131
+ output.syncDirs = dryRunSyncDirs;
132
+ if (dryRunDependencyBootstrap?.targets.length)
133
+ output.dependencyBootstrap = dryRunDependencyBootstrap;
134
+ options.stdout(`${JSON.stringify(output, null, 2)}\n`);
74
135
  }
75
136
  else {
76
- options.stdout(`Would create worktree at ${worktreePath} (branch: ${branchName})\n`);
137
+ options.stdout(`Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`);
77
138
  }
78
139
  return 0;
79
140
  }
@@ -97,31 +158,75 @@ export function createPrCommand(dependencies = {}) {
97
158
  ? ["worktree", "add", worktreePath, branchName]
98
159
  : ["worktree", "add", "-b", branchName, worktreePath, remoteRef];
99
160
  await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot });
100
- // Sync files from main worktree before afterCreate so synced files are available to install scripts.
101
- const syncPatterns = Array.isArray(config.syncFiles)
102
- ? config.syncFiles.filter((p) => typeof p === "string")
103
- : [];
104
- for (const pattern of syncPatterns) {
105
- try {
106
- await syncFiles(repository.repoRoot, worktreePath, [pattern]);
161
+ if (!dependencyBootstrapExplicit) {
162
+ dependencyPolicy = await resolveDependencyBootstrapPolicy({
163
+ currentRoot: repository.currentRoot,
164
+ detectionRoot: worktreePath,
165
+ repoRoot: repository.repoRoot,
166
+ worktreePath,
167
+ }, config, false, {
168
+ dependencies,
169
+ legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
170
+ nonInteractive: !!options.json,
171
+ stderr: options.stderr,
172
+ });
173
+ config = {
174
+ ...config,
175
+ dependencyBootstrap: dependencyPolicy.mode,
176
+ };
177
+ }
178
+ const bootstrap = await bootstrapWorktree({
179
+ branch: branchName,
180
+ cloneDirectory: dependencies.cloneDir,
181
+ config,
182
+ currentRoot: repository.currentRoot,
183
+ dependencyDetectionRoot: worktreePath,
184
+ nonInteractive: !!options.json,
185
+ repoRoot: repository.repoRoot,
186
+ reporter: createBootstrapReporter(options.stderr, !!options.json),
187
+ runCommand: dependencies.runInstallCommand,
188
+ commandStdout: options.json ? () => undefined : options.stdout,
189
+ commandStderr: options.json ? () => undefined : options.stderr,
190
+ json: options.json,
191
+ worktreePath,
192
+ installDependencies: dependencies,
193
+ dependencyBootstrapPolicy: dependencyPolicy,
194
+ });
195
+ if (!bootstrap.ready) {
196
+ const details = {
197
+ dependencyBootstrap: bootstrap.dependencyBootstrap,
198
+ path: worktreePath,
199
+ skipped: bootstrap.skippedDirs,
200
+ syncFiles: bootstrap.syncFileFailures,
201
+ };
202
+ if (options.json) {
203
+ options.stderr(`${JSON.stringify({ error: "worktree bootstrap failed", ...details }, null, 2)}\n`);
107
204
  }
108
- catch (error) {
109
- options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
205
+ else {
206
+ options.stderr(`gji pr: worktree bootstrap failed at ${worktreePath}\n`);
207
+ options.stderr(`Hint: inspect the worktree or remove it with 'gji done ${worktreePath}' before retrying\n`);
110
208
  }
209
+ return 1;
111
210
  }
112
- await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
113
- const hooks = extractHooks(config);
114
- await runHook(hooks["after-create"], worktreePath, {
115
- branch: branchName,
116
- path: worktreePath,
117
- repo: basename(repository.repoRoot),
118
- }, options.stderr);
119
211
  if (options.json) {
120
- options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath }, null, 2)}\n`);
212
+ const output = {
213
+ ...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, branchName),
214
+ };
215
+ if (bootstrap.clonedDirs.length > 0) {
216
+ output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({
217
+ dir,
218
+ ms,
219
+ }));
220
+ }
221
+ if (bootstrap.skippedDirs.length > 0)
222
+ output.skipped = bootstrap.skippedDirs;
223
+ if (bootstrap.dependencyBootstrap.mode !== "off")
224
+ output.dependencyBootstrap = bootstrap.dependencyBootstrap;
225
+ options.stdout(`${JSON.stringify(output, null, 2)}\n`);
121
226
  }
122
227
  else {
123
228
  await recordWorktreeUsage(worktreePath, branchName);
124
- await writeOutput(worktreePath, options.stdout);
229
+ await writeOutput(worktreePath, options.stdout, options.outputEnv);
125
230
  }
126
231
  return 0;
127
232
  };
@@ -185,6 +290,6 @@ function sourceRefForForge(forge, prNumber) {
185
290
  return `refs/merge-requests/${prNumber}/head`;
186
291
  }
187
292
  }
188
- async function writeOutput(worktreePath, stdout) {
189
- await writeShellOutput(PR_OUTPUT_FILE_ENV, worktreePath, stdout);
293
+ async function writeOutput(worktreePath, stdout, outputEnv) {
294
+ await writeShellOutput(outputEnv ?? PR_OUTPUT_FILE_ENV, worktreePath, stdout);
190
295
  }
@@ -2,6 +2,7 @@ export type PullRequestForge = "bitbucket" | "github" | "gitlab";
2
2
  export interface PullRequestInfo {
3
3
  number: number;
4
4
  sourceBranch: string;
5
+ title?: string;
5
6
  url: string;
6
7
  }
7
8
  export interface PullRequestRemote {
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
4
  const PULL_REQUEST_QUERY_TIMEOUT_MS = 2500;
5
+ const PULL_REQUEST_CACHE_TTL_MS = 30_000;
5
6
  export function parsePullRequestRemote(remoteUrl) {
6
7
  const parsed = parseRemoteUrl(remoteUrl.trim());
7
8
  if (parsed === null)
@@ -46,10 +47,36 @@ export function parsePullRequestRemote(remoteUrl) {
46
47
  export function createPullRequestQuery(dependencies = {}) {
47
48
  const runCommand = dependencies.runCommand ?? defaultRunCommand;
48
49
  const fetcher = dependencies.fetch ?? fetch;
50
+ const repositoryCache = new Map();
51
+ const repositoryInFlight = new Map();
52
+ const listRepositoryPullRequests = (repoRoot) => {
53
+ const cached = repositoryCache.get(repoRoot);
54
+ if (cached !== undefined && cached.expiresAt > Date.now()) {
55
+ return Promise.resolve(cached.value);
56
+ }
57
+ const inFlight = repositoryInFlight.get(repoRoot);
58
+ if (inFlight !== undefined)
59
+ return inFlight;
60
+ const request = listOpenPullRequestsForRepository(repoRoot, runCommand, fetcher)
61
+ .then((value) => {
62
+ repositoryCache.set(repoRoot, {
63
+ expiresAt: Date.now() + PULL_REQUEST_CACHE_TTL_MS,
64
+ value,
65
+ });
66
+ repositoryInFlight.delete(repoRoot);
67
+ return value;
68
+ })
69
+ .catch((error) => {
70
+ repositoryInFlight.delete(repoRoot);
71
+ throw error;
72
+ });
73
+ repositoryInFlight.set(repoRoot, request);
74
+ return request;
75
+ };
49
76
  return {
50
77
  findOpenPullRequest: (repoRoot, number) => findOpenPullRequest(repoRoot, number, runCommand, fetcher),
51
78
  listOpenPullRequests: (repoRoot, sourceBranch) => listOpenPullRequests(repoRoot, sourceBranch, runCommand, fetcher),
52
- listOpenPullRequestsForRepository: (repoRoot) => listOpenPullRequestsForRepository(repoRoot, runCommand, fetcher),
79
+ listOpenPullRequestsForRepository: listRepositoryPullRequests,
53
80
  };
54
81
  }
55
82
  async function listOpenPullRequests(repoRoot, sourceBranch, runCommand, fetcher) {
@@ -153,7 +180,7 @@ function providerCliCommand(remote, query) {
153
180
  "view",
154
181
  String(query.number),
155
182
  "--json",
156
- "number,url,headRefName,state",
183
+ "number,url,headRefName,title,state",
157
184
  "--repo",
158
185
  coordinate,
159
186
  ],
@@ -168,7 +195,7 @@ function providerCliCommand(remote, query) {
168
195
  "open",
169
196
  ...(query.kind === "branch" ? ["--head", sourceBranch] : []),
170
197
  "--json",
171
- "number,url,headRefName,state",
198
+ "number,url,headRefName,title,state",
172
199
  "--limit",
173
200
  "100",
174
201
  "--repo",
@@ -302,6 +329,7 @@ function normalizePullRequest(value, query) {
302
329
  (isRecord(value.links) && isRecord(value.links.html)
303
330
  ? value.links.html.href
304
331
  : undefined));
332
+ const title = stringValue(value.title);
305
333
  const state = stringValue(value.state)?.toLowerCase();
306
334
  if (number === null || url === null)
307
335
  return null;
@@ -314,6 +342,7 @@ function normalizePullRequest(value, query) {
314
342
  return {
315
343
  number,
316
344
  sourceBranch: sourceBranch ?? "",
345
+ ...(title === null ? {} : { title }),
317
346
  url,
318
347
  };
319
348
  }
package/dist/remove.js CHANGED
@@ -4,6 +4,8 @@ import { loadEffectiveConfig } from "./config.js";
4
4
  import { isHeadless } from "./headless.js";
5
5
  import { extractHooks, runHook } from "./hooks.js";
6
6
  import { writeShellOutput } from "./shell-handoff.js";
7
+ import { releaseWorktreeSlot } from "./slots.js";
8
+ import { finalizeUndoOperation, recordUndoOperation } from "./undo.js";
7
9
  import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeForceRemovalError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
8
10
  import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
9
11
  import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
@@ -74,6 +76,20 @@ export function createRemoveCommand(dependencies = {}) {
74
76
  }
75
77
  const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
76
78
  const hooks = extractHooks(config);
79
+ let journal;
80
+ try {
81
+ journal = await recordUndoOperation("remove", repository.repoRoot, [
82
+ worktree,
83
+ ]);
84
+ }
85
+ catch (error) {
86
+ emitError(options, `could not write undo journal; no worktree was removed: ${toMessage(error)}`);
87
+ return 1;
88
+ }
89
+ if (!journal) {
90
+ emitError(options, "could not capture undo state; no worktree was removed");
91
+ return 1;
92
+ }
77
93
  await runHook(hooks["before-remove"], worktree.path, {
78
94
  branch: worktree.branch ?? undefined,
79
95
  path: worktree.path,
@@ -84,10 +100,12 @@ export function createRemoveCommand(dependencies = {}) {
84
100
  }
85
101
  catch (error) {
86
102
  if (!isWorktreeForceRemovalError(error)) {
103
+ await finalizeUndoOperation(journal, []);
87
104
  throw error;
88
105
  }
89
106
  if (!options.force &&
90
107
  !(await confirmForceRemoveWorktree(worktree.path))) {
108
+ await finalizeUndoOperation(journal, []);
91
109
  options.stderr("Aborted\n");
92
110
  return 1;
93
111
  }
@@ -95,10 +113,13 @@ export function createRemoveCommand(dependencies = {}) {
95
113
  await forceRemoveWorktree(repository.repoRoot, worktree.path);
96
114
  }
97
115
  catch (forceError) {
116
+ await finalizeUndoOperation(journal, []);
98
117
  emitError(options, `Failed to remove worktree at ${worktree.path}: ${toMessage(forceError)}`);
99
118
  return 1;
100
119
  }
101
120
  }
121
+ await finalizeUndoOperation(journal, [worktree]);
122
+ await releaseWorktreeSlot(worktree.path);
102
123
  if (worktree.branch) {
103
124
  try {
104
125
  await deleteBranch(repository.repoRoot, worktree.branch);
@@ -125,6 +146,7 @@ export function createRemoveCommand(dependencies = {}) {
125
146
  options.stdout(`${JSON.stringify({ branch: worktree.branch, path: worktree.path, deleted: true }, null, 2)}\n`);
126
147
  }
127
148
  else {
149
+ options.stderr("undo: gji undo\n");
128
150
  await writeOutput(repository.repoRoot, options.stdout);
129
151
  }
130
152
  return 0;
package/dist/repo.d.ts CHANGED
@@ -11,6 +11,10 @@ export interface WorktreeEntry {
11
11
  path: string;
12
12
  }
13
13
  export declare function detectRepository(cwd: string): Promise<RepositoryContext>;
14
+ export declare function worktreeGitDir(worktreePath: string): Promise<string>;
14
15
  export declare function resolveWorktreePath(repoRoot: string, branch: string, basePath?: string): string;
15
16
  export declare function validateBranchName(name: string): string | null;
17
+ export declare function hasLocalBranch(repoRoot: string, branch: string): Promise<boolean>;
18
+ export declare function hasRemoteBranch(repoRoot: string, remote: string, branch: string): Promise<boolean>;
19
+ export declare function getRepositoryRemoteUrl(repoRoot: string, remote: string): Promise<string | null>;
16
20
  export declare function listWorktrees(cwd: string): Promise<WorktreeEntry[]>;
package/dist/repo.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { access } from "node:fs/promises";
1
2
  import { homedir } from "node:os";
2
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
4
  import { runGit } from "./git.js";
@@ -16,6 +17,10 @@ export async function detectRepository(cwd) {
16
17
  repoRoot,
17
18
  };
18
19
  }
20
+ export async function worktreeGitDir(worktreePath) {
21
+ const rawGitDir = await runGit(worktreePath, ["rev-parse", "--git-dir"]);
22
+ return isAbsolute(rawGitDir) ? rawGitDir : resolve(worktreePath, rawGitDir);
23
+ }
19
24
  export function resolveWorktreePath(repoRoot, branch, basePath) {
20
25
  const segments = branch.split("/").filter(Boolean);
21
26
  if (segments.length === 0) {
@@ -65,6 +70,42 @@ export function validateBranchName(name) {
65
70
  }
66
71
  return null;
67
72
  }
73
+ export async function hasLocalBranch(repoRoot, branch) {
74
+ try {
75
+ await runGit(repoRoot, [
76
+ "show-ref",
77
+ "--verify",
78
+ "--quiet",
79
+ `refs/heads/${branch}`,
80
+ ]);
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ export async function hasRemoteBranch(repoRoot, remote, branch) {
88
+ try {
89
+ await runGit(repoRoot, [
90
+ "show-ref",
91
+ "--verify",
92
+ "--quiet",
93
+ `refs/remotes/${remote}/${branch}`,
94
+ ]);
95
+ return true;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ export async function getRepositoryRemoteUrl(repoRoot, remote) {
102
+ try {
103
+ return await runGit(repoRoot, ["remote", "get-url", remote]);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
68
109
  function expandTildeInPath(p) {
69
110
  if (p === "~")
70
111
  return homedir();
@@ -78,15 +119,29 @@ export async function listWorktrees(cwd) {
78
119
  runGit(cwd, ["rev-parse", "--show-toplevel"]),
79
120
  ]);
80
121
  const entries = output.split("\n\n").filter(Boolean);
81
- return entries.map((entry) => {
122
+ const worktrees = entries.flatMap((entry) => {
123
+ if (findOptionalPorcelainValue(entry, "prunable") !== null)
124
+ return [];
82
125
  const path = findPorcelainValue(entry, "worktree");
83
126
  const branchRef = findOptionalPorcelainValue(entry, "branch");
84
- return {
85
- branch: branchRef ? branchRef.replace("refs/heads/", "") : null,
86
- isCurrent: path === currentRoot,
87
- path,
88
- };
127
+ return [
128
+ {
129
+ branch: branchRef ? branchRef.replace("refs/heads/", "") : null,
130
+ isCurrent: path === currentRoot,
131
+ path,
132
+ },
133
+ ];
89
134
  });
135
+ const accessible = await Promise.all(worktrees.map(async (worktree) => {
136
+ try {
137
+ await access(worktree.path);
138
+ return worktree;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }));
144
+ return accessible.filter((worktree) => worktree !== null);
90
145
  }
91
146
  function findPorcelainValue(block, key) {
92
147
  const value = findOptionalPorcelainValue(block, key);
package/dist/run-hook.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { loadEffectiveConfig } from "./config.js";
2
2
  import { extractHooks, runHook } from "./hooks.js";
3
3
  import { detectRepository, listWorktrees } from "./repo.js";
4
+ import { getWorktreeSlot } from "./slots.js";
4
5
  const VALID_HOOKS = [
5
6
  "after-create",
6
7
  "after-enter",
@@ -31,6 +32,7 @@ export async function runHookCommand(options) {
31
32
  branch: currentWorktree?.branch ?? undefined,
32
33
  path: repository.currentRoot,
33
34
  repo: repository.repoName,
35
+ slot: await getWorktreeSlot(repository.currentRoot),
34
36
  }, options.stderr);
35
37
  return 0;
36
38
  }
@@ -0,0 +1,15 @@
1
+ export type DestinationInspection = {
2
+ kind: "missing";
3
+ } | {
4
+ kind: "exists";
5
+ } | {
6
+ kind: "unsafe";
7
+ reason: string;
8
+ };
9
+ export interface OpenDestinationDirectory {
10
+ path: string;
11
+ close(): Promise<void>;
12
+ }
13
+ export declare function openDestinationDirectory(root: string, path: string): Promise<OpenDestinationDirectory>;
14
+ export declare function inspectDestination(root: string, path: string): Promise<DestinationInspection>;
15
+ export declare function ensureDestinationDirectory(root: string, path: string): Promise<void>;