@solaqua/gji 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +93 -20
  2. package/dist/back.d.ts +3 -0
  3. package/dist/back.js +19 -7
  4. package/dist/bootstrap-output.d.ts +3 -0
  5. package/dist/bootstrap-output.js +27 -0
  6. package/dist/bootstrap-preview.d.ts +10 -0
  7. package/dist/bootstrap-preview.js +21 -0
  8. package/dist/clean.js +25 -3
  9. package/dist/cli.js +105 -19
  10. package/dist/clone-failure-store.d.ts +18 -0
  11. package/dist/clone-failure-store.js +230 -0
  12. package/dist/command-runner.d.ts +6 -0
  13. package/dist/command-runner.js +38 -0
  14. package/dist/completion.js +7 -2
  15. package/dist/config-command.d.ts +1 -0
  16. package/dist/config-command.js +7 -1
  17. package/dist/config.d.ts +26 -1
  18. package/dist/config.js +110 -6
  19. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  20. package/dist/dependency-bootstrap-prompt.js +103 -0
  21. package/dist/dependency-bootstrap.d.ts +129 -0
  22. package/dist/dependency-bootstrap.js +566 -0
  23. package/dist/dir-clone.d.ts +33 -0
  24. package/dist/dir-clone.js +505 -0
  25. package/dist/done.d.ts +10 -0
  26. package/dist/done.js +208 -0
  27. package/dist/file-sync.js +71 -22
  28. package/dist/format-bytes.d.ts +1 -0
  29. package/dist/format-bytes.js +14 -0
  30. package/dist/fs-utils.d.ts +4 -0
  31. package/dist/fs-utils.js +26 -0
  32. package/dist/git.d.ts +5 -0
  33. package/dist/git.js +50 -0
  34. package/dist/gji-bundle.mjs +5489 -1206
  35. package/dist/go-resolver.d.ts +35 -0
  36. package/dist/go-resolver.js +185 -0
  37. package/dist/go.d.ts +5 -2
  38. package/dist/go.js +262 -62
  39. package/dist/hooks.d.ts +1 -1
  40. package/dist/hooks.js +13 -7
  41. package/dist/init.js +30 -22
  42. package/dist/install-prompt.d.ts +1 -0
  43. package/dist/install-prompt.js +3 -25
  44. package/dist/navigation-output.d.ts +11 -0
  45. package/dist/navigation-output.js +6 -0
  46. package/dist/new.d.ts +14 -2
  47. package/dist/new.js +434 -36
  48. package/dist/open.d.ts +1 -0
  49. package/dist/open.js +14 -2
  50. package/dist/pr.d.ts +6 -2
  51. package/dist/pr.js +135 -30
  52. package/dist/remove.js +20 -0
  53. package/dist/repo.d.ts +3 -0
  54. package/dist/repo.js +57 -6
  55. package/dist/safe-destination.d.ts +15 -0
  56. package/dist/safe-destination.js +166 -0
  57. package/dist/shell-completion.js +47 -14
  58. package/dist/sync-directories.d.ts +33 -0
  59. package/dist/sync-directories.js +97 -0
  60. package/dist/sync-plan.d.ts +16 -0
  61. package/dist/sync-plan.js +128 -0
  62. package/dist/sync.js +7 -13
  63. package/dist/undo.d.ts +36 -0
  64. package/dist/undo.js +392 -0
  65. package/dist/warp.d.ts +3 -1
  66. package/dist/warp.js +32 -125
  67. package/dist/worktree-bootstrap.d.ts +35 -0
  68. package/dist/worktree-bootstrap.js +93 -0
  69. package/dist/worktree-management.d.ts +1 -0
  70. package/dist/worktree-management.js +23 -4
  71. package/dist/worktree-picker.d.ts +21 -8
  72. package/dist/worktree-picker.js +107 -15
  73. package/dist/worktree-prompts.js +1 -1
  74. package/dist/worktree-source.d.ts +6 -0
  75. package/dist/worktree-source.js +1 -0
  76. package/dist/worktree-sources.d.ts +3 -0
  77. package/dist/worktree-sources.js +31 -0
  78. package/man/man1/gji-back.1 +1 -1
  79. package/man/man1/gji-clean.1 +1 -1
  80. package/man/man1/gji-completion.1 +1 -1
  81. package/man/man1/gji-config.1 +1 -1
  82. package/man/man1/gji-doctor.1 +1 -1
  83. package/man/man1/gji-done.1 +19 -0
  84. package/man/man1/gji-go.1 +9 -3
  85. package/man/man1/gji-history.1 +1 -1
  86. package/man/man1/gji-init.1 +1 -1
  87. package/man/man1/gji-ls.1 +1 -1
  88. package/man/man1/gji-new.1 +21 -3
  89. package/man/man1/gji-open.1 +4 -1
  90. package/man/man1/gji-pr.1 +7 -1
  91. package/man/man1/gji-remove.1 +3 -3
  92. package/man/man1/gji-root.1 +1 -1
  93. package/man/man1/gji-run-hook.1 +1 -1
  94. package/man/man1/gji-status.1 +1 -1
  95. package/man/man1/gji-sync-files.1 +1 -1
  96. package/man/man1/gji-sync.1 +1 -1
  97. package/man/man1/gji-undo.1 +16 -0
  98. package/man/man1/gji-warp.1 +3 -6
  99. package/man/man1/gji.1 +13 -5
  100. package/package.json +3 -1
package/dist/new.js CHANGED
@@ -1,28 +1,57 @@
1
1
  import { execFile } from "node:child_process";
2
- import { mkdir } from "node:fs/promises";
3
- import { basename, dirname } from "node:path";
2
+ import { access, mkdir } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
- import { isCancel, text } from "@clack/prompts";
6
- import { loadEffectiveConfig, resolveConfigString } from "./config.js";
5
+ import { confirm, isCancel, text } from "@clack/prompts";
6
+ import { createBootstrapReporter } from "./bootstrap-output.js";
7
+ import { createDependencyBootstrapPreview, formatDependencyBootstrapPreview, } from "./bootstrap-preview.js";
8
+ import { loadEffectiveConfigResult, resolveConfigString, } from "./config.js";
7
9
  import { pathExists, promptForPathConflict, } from "./conflict.js";
10
+ import { resolveDependencyBootstrapPolicy, } from "./dependency-bootstrap-prompt.js";
11
+ import { cloneDir } from "./dir-clone.js";
8
12
  import { defaultSpawnEditor, EDITORS } from "./editor.js";
9
- import { syncFiles } from "./file-sync.js";
13
+ import { formatBytes } from "./format-bytes.js";
14
+ import { resolveRemoteBase, runGit } from "./git.js";
10
15
  import { isHeadless } from "./headless.js";
11
16
  import { recordWorktreeUsage } from "./history.js";
12
- import { extractHooks, runHook } from "./hooks.js";
13
- import { maybeRunInstallPrompt, } from "./install-prompt.js";
17
+ import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js";
14
18
  import { detectRepository, resolveWorktreePath, validateBranchName, } from "./repo.js";
15
19
  import { writeShellOutput } from "./shell-handoff.js";
20
+ import { estimateSyncDirectories } from "./sync-plan.js";
21
+ import { bootstrapWorktree } from "./worktree-bootstrap.js";
16
22
  const execFileAsync = promisify(execFile);
17
23
  const NEW_OUTPUT_FILE_ENV = "GJI_NEW_OUTPUT_FILE";
18
24
  export function createNewCommand(dependencies = {}) {
19
25
  const createBranchPlaceholder = dependencies.createBranchPlaceholder ?? generateBranchPlaceholder;
26
+ const cloneDirectory = dependencies.cloneDir ?? cloneDir;
20
27
  const promptForBranch = dependencies.promptForBranch ?? defaultPromptForBranch;
28
+ const promptForFetchFailure = dependencies.promptForFetchFailure ?? defaultPromptForFetchFailure;
21
29
  const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
22
30
  const spawnEditor = dependencies.spawnEditor ?? defaultSpawnEditor;
23
31
  return async function runNewCommand(options) {
32
+ if (options.copy && !options.take)
33
+ return emitNewError(options, "--copy requires --take");
34
+ if (options.detached && options.fromCurrent) {
35
+ const message = "--from-current cannot be used with --detached";
36
+ if (options.json) {
37
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
38
+ }
39
+ else {
40
+ options.stderr(`gji new: ${message}\n`);
41
+ }
42
+ return 1;
43
+ }
24
44
  const repository = await detectRepository(options.cwd);
25
- const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
45
+ let config;
46
+ let dependencyBootstrapExplicit;
47
+ try {
48
+ const loaded = await loadEffectiveConfigResult(repository.repoRoot, undefined, options.json ? undefined : options.stderr);
49
+ config = loaded.config;
50
+ dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit;
51
+ }
52
+ catch (error) {
53
+ return emitNewError(options, error instanceof Error ? error.message : String(error));
54
+ }
26
55
  const usesGeneratedDetachedName = options.detached && options.branch === undefined;
27
56
  if (options.editor && !options.open) {
28
57
  options.stderr("gji new: --editor has no effect without --open\n");
@@ -67,9 +96,11 @@ export function createNewCommand(dependencies = {}) {
67
96
  const configuredBasePath = rawBasePath?.startsWith("/") || rawBasePath?.startsWith("~")
68
97
  ? rawBasePath
69
98
  : undefined;
70
- const worktreeName = options.detached
99
+ const worktreeName = options.mode
71
100
  ? rawBranch
72
- : applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
101
+ : options.detached
102
+ ? rawBranch
103
+ : applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
73
104
  const worktreePath = usesGeneratedDetachedName
74
105
  ? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName, configuredBasePath)
75
106
  : resolveWorktreePath(repository.repoRoot, worktreeName, configuredBasePath);
@@ -109,7 +140,7 @@ export function createNewCommand(dependencies = {}) {
109
140
  }
110
141
  else {
111
142
  options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
112
- options.stderr(`Hint: Use 'gji remove ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
143
+ options.stderr(`Hint: Use 'gji done ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
113
144
  options.stderr(`Hint: Use 'gji run-hook after-create' inside the worktree to re-run setup hooks\n`);
114
145
  }
115
146
  return 1;
@@ -118,16 +149,64 @@ export function createNewCommand(dependencies = {}) {
118
149
  const choice = await prompt(worktreePath);
119
150
  if (choice === "reuse") {
120
151
  await recordWorktreeUsage(worktreePath, worktreeName);
121
- await writeOutput(worktreePath, options.stdout);
152
+ await writeOutput(worktreePath, options.stdout, options.outputEnv);
122
153
  return 0;
123
154
  }
124
155
  options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
125
156
  return 1;
126
157
  }
127
158
  }
159
+ const dependencyPolicy = await resolveDependencyBootstrapPolicy({
160
+ currentRoot: repository.currentRoot,
161
+ repoRoot: repository.repoRoot,
162
+ worktreePath,
163
+ }, config, dependencyBootstrapExplicit, {
164
+ dependencies,
165
+ dryRun: options.dryRun,
166
+ legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
167
+ nonInteractive: !!options.json,
168
+ stderr: options.stderr,
169
+ });
170
+ config = {
171
+ ...config,
172
+ dependencyBootstrap: dependencyPolicy.mode,
173
+ };
128
174
  if (options.dryRun) {
175
+ if (options.take) {
176
+ const changedFiles = await listTakeFiles(options.cwd);
177
+ const submoduleFiles = await listSubmoduleFiles(options.cwd, changedFiles);
178
+ const transferableFiles = changedFiles.filter((file) => !submoduleFiles.includes(file));
179
+ const ignoredFiles = await listIgnoredFiles(options.cwd);
180
+ if (transferableFiles.length === 0)
181
+ return emitNewError(options, submoduleFiles.length > 0
182
+ ? "nothing to take: submodule changes are not supported"
183
+ : ignoredFiles.length > 0
184
+ ? "nothing to take: ignored files are not moved"
185
+ : "nothing to take: working tree is clean");
186
+ if (options.json)
187
+ options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath, dryRun: true, take: transferableFiles, ignored: ignoredFiles, submodules: submoduleFiles }, null, 2)}\n`);
188
+ else {
189
+ options.stdout(`Would take ${transferableFiles.length} changed file${transferableFiles.length === 1 ? "" : "s"} into ${worktreeName}${ignoredFiles.length > 0 ? ` (ignored files not moved: ${ignoredFiles.length})` : ""}${submoduleFiles.length > 0 ? ` (submodules not moved: ${submoduleFiles.length})` : ""}\n${transferableFiles.map((file) => ` ${file}\n`).join("")}`);
190
+ }
191
+ return 0;
192
+ }
193
+ const dryRunSyncDirs = await estimateSyncDirectories(repository.repoRoot, worktreePath, config.syncDirs ?? []);
194
+ const dryRunDependencyBootstrap = await createDependencyBootstrapPreview(config.dependencyBootstrap ?? "off", {
195
+ currentRoot: repository.currentRoot,
196
+ repoRoot: repository.repoRoot,
197
+ cargoBuildCommand: config.dependencyBuildCommand,
198
+ worktreePath,
199
+ });
129
200
  if (options.json) {
130
- options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath, dryRun: true }, null, 2)}\n`);
201
+ const output = {
202
+ ...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName),
203
+ dryRun: true,
204
+ };
205
+ if (dryRunSyncDirs.length > 0)
206
+ output.syncDirs = dryRunSyncDirs;
207
+ if (dryRunDependencyBootstrap.targets.length > 0)
208
+ output.dependencyBootstrap = dryRunDependencyBootstrap;
209
+ options.stdout(`${JSON.stringify(output, null, 2)}\n`);
131
210
  }
132
211
  else {
133
212
  const resolvedEditor = options.open
@@ -136,42 +215,163 @@ export function createNewCommand(dependencies = {}) {
136
215
  const openNote = resolvedEditor
137
216
  ? `, then open in ${resolvedEditor}`
138
217
  : "";
139
- options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n`);
218
+ options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`);
140
219
  }
141
220
  return 0;
142
221
  }
143
222
  await mkdir(dirname(worktreePath), { recursive: true });
144
- const gitArgs = options.detached
145
- ? ["worktree", "add", "--detach", worktreePath]
146
- : (await localBranchExists(repository.repoRoot, worktreeName))
147
- ? ["worktree", "add", worktreePath, worktreeName]
148
- : ["worktree", "add", "-b", worktreeName, worktreePath];
149
- await execFileAsync("git", gitArgs, { cwd: repository.repoRoot });
150
- // Sync files from main worktree before afterCreate so synced files are available to install scripts.
151
- const syncPatterns = Array.isArray(config.syncFiles)
152
- ? config.syncFiles.filter((p) => typeof p === "string")
223
+ const startPoint = options.fromCurrent && !options.detached
224
+ ? await resolveCurrentWorktreeHead(options.cwd)
225
+ : undefined;
226
+ const takeStartPoint = options.take
227
+ ? await resolveCurrentWorktreeHead(options.cwd)
228
+ : undefined;
229
+ const changedTakeFiles = options.take
230
+ ? await listTakeFiles(options.cwd)
153
231
  : [];
154
- for (const pattern of syncPatterns) {
232
+ const submoduleFiles = options.take
233
+ ? await listSubmoduleFiles(options.cwd, changedTakeFiles)
234
+ : [];
235
+ const takeFiles = changedTakeFiles.filter((file) => !submoduleFiles.includes(file));
236
+ const takeUntracked = options.take
237
+ ? await countUntrackedFiles(options.cwd)
238
+ : 0;
239
+ if (options.take && takeFiles.length === 0)
240
+ return emitNewError(options, submoduleFiles.length > 0
241
+ ? "nothing to take: submodule changes are not supported"
242
+ : "nothing to take: working tree is clean");
243
+ if (options.take && submoduleFiles.length > 0)
244
+ options.stderr(`Warning: submodule changes are not transferred: ${submoduleFiles.join(", ")}\n`);
245
+ const existingLocalBranch = await localBranchExists(repository.repoRoot, worktreeName);
246
+ const shouldRefreshBase = !options.noFetch &&
247
+ !options.detached &&
248
+ !options.fromCurrent &&
249
+ !options.take &&
250
+ options.mode !== "track" &&
251
+ options.mode !== "checkout" &&
252
+ !existingLocalBranch;
253
+ const freshBaseRef = shouldRefreshBase
254
+ ? await resolveFreshBaseRef(repository.repoRoot, config, options, promptForFetchFailure)
255
+ : undefined;
256
+ if (freshBaseRef === null)
257
+ return 1;
258
+ let stashSha = null;
259
+ if (options.take) {
155
260
  try {
156
- await syncFiles(repository.repoRoot, worktreePath, [pattern]);
261
+ const inProgressState = await detectInProgressGitState(options.cwd);
262
+ if (inProgressState)
263
+ return emitNewError(options, `cannot take changes while Git is in progress (${inProgressState})`);
264
+ stashSha = await createTakeStash(options.cwd, worktreeName);
157
265
  }
158
266
  catch (error) {
159
- options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
267
+ return emitNewError(options, `could not stash changes safely: ${toExecMessage(error)}`);
268
+ }
269
+ }
270
+ const gitArgs = options.detached
271
+ ? [
272
+ "worktree",
273
+ "add",
274
+ "--detach",
275
+ worktreePath,
276
+ ...(takeStartPoint ? [takeStartPoint] : []),
277
+ ]
278
+ : options.mode === "track"
279
+ ? [
280
+ "worktree",
281
+ "add",
282
+ "--track",
283
+ "-b",
284
+ worktreeName,
285
+ worktreePath,
286
+ `${options.remote ?? "origin"}/${worktreeName}`,
287
+ ]
288
+ : options.mode === "checkout" || existingLocalBranch
289
+ ? ["worktree", "add", worktreePath, worktreeName]
290
+ : [
291
+ "worktree",
292
+ "add",
293
+ "-b",
294
+ worktreeName,
295
+ worktreePath,
296
+ ...(takeStartPoint
297
+ ? [takeStartPoint]
298
+ : startPoint
299
+ ? [startPoint]
300
+ : freshBaseRef
301
+ ? [freshBaseRef]
302
+ : []),
303
+ ];
304
+ try {
305
+ await execFileAsync("git", gitArgs, { cwd: repository.repoRoot });
306
+ }
307
+ catch (error) {
308
+ if (stashSha)
309
+ await restoreTakeStash(options.cwd, stashSha, options.stderr);
310
+ return emitNewError(options, `failed to create worktree: ${toExecMessage(error)}`);
311
+ }
312
+ if (stashSha &&
313
+ !(await applyTakeStash(worktreePath, options.cwd, stashSha, !!options.copy, options.stderr))) {
314
+ try {
315
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repository.repoRoot });
160
316
  }
317
+ catch {
318
+ /* rollback best effort */
319
+ }
320
+ return emitNewError(options, `could not apply taken changes; your changes are safe in stash: ${stashSha}`);
161
321
  }
162
- await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
163
- const hooks = extractHooks(config);
164
- await runHook(hooks["after-create"], worktreePath, {
322
+ const bootstrap = await bootstrapWorktree({
165
323
  branch: worktreeName,
166
- path: worktreePath,
167
- repo: basename(repository.repoRoot),
168
- }, options.stderr);
324
+ cloneDirectory,
325
+ config,
326
+ currentRoot: repository.currentRoot,
327
+ nonInteractive: !!options.json,
328
+ repoRoot: repository.repoRoot,
329
+ reporter: createBootstrapReporter(options.stderr, !!options.json),
330
+ runCommand: dependencies.runInstallCommand,
331
+ commandStdout: options.json ? () => undefined : options.stdout,
332
+ commandStderr: options.json ? () => undefined : options.stderr,
333
+ json: options.json,
334
+ worktreePath,
335
+ installDependencies: dependencies,
336
+ dependencyBootstrapPolicy: dependencyPolicy,
337
+ });
338
+ if (!bootstrap.ready) {
339
+ return emitNewError(options, "worktree bootstrap failed", {
340
+ dependencyBootstrap: bootstrap.dependencyBootstrap,
341
+ path: worktreePath,
342
+ skipped: bootstrap.skippedDirs,
343
+ syncFiles: bootstrap.syncFileFailures,
344
+ });
345
+ }
169
346
  if (options.json) {
170
- options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath }, null, 2)}\n`);
347
+ const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName);
348
+ const output = options.take
349
+ ? {
350
+ ...navigation,
351
+ taken: {
352
+ files: takeFiles.length,
353
+ untracked: takeUntracked,
354
+ submodules: submoduleFiles,
355
+ },
356
+ }
357
+ : { ...navigation };
358
+ if (bootstrap.clonedDirs.length > 0) {
359
+ output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({
360
+ dir,
361
+ ms,
362
+ }));
363
+ }
364
+ if (bootstrap.skippedDirs.length > 0)
365
+ output.skipped = bootstrap.skippedDirs;
366
+ if (bootstrap.dependencyBootstrap.mode !== "off")
367
+ output.dependencyBootstrap = bootstrap.dependencyBootstrap;
368
+ options.stdout(`${JSON.stringify(output, null, 2)}\n`);
171
369
  }
172
370
  else {
371
+ if (options.take)
372
+ options.stderr(`✓ took ${takeFiles.length} changed file${takeFiles.length === 1 ? "" : "s"} (${takeUntracked} untracked) → ${worktreeName}\n`);
173
373
  await recordWorktreeUsage(worktreePath, worktreeName);
174
- await writeOutput(worktreePath, options.stdout);
374
+ await writeOutput(worktreePath, options.stdout, options.outputEnv);
175
375
  }
176
376
  if (options.open) {
177
377
  const resolvedEditor = options.editor ?? resolveConfigString(config, "editor");
@@ -181,6 +381,60 @@ export function createNewCommand(dependencies = {}) {
181
381
  };
182
382
  }
183
383
  export const runNewCommand = createNewCommand();
384
+ async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFailure) {
385
+ const remote = resolveConfigString(config, "syncRemote") ?? "origin";
386
+ const configuredRemote = resolveConfigString(config, "syncRemote");
387
+ let baseBranch = resolveConfigString(config, "syncDefaultBranch");
388
+ try {
389
+ const remoteBase = await resolveRemoteBase(repoRoot, remote, baseBranch);
390
+ if (!remoteBase) {
391
+ if (!configuredRemote)
392
+ return "HEAD";
393
+ throw new Error("the remote default branch is unknown");
394
+ }
395
+ baseBranch = remoteBase.branch;
396
+ await runGit(repoRoot, ["fetch", "--prune", remote, remoteBase.branch]);
397
+ return remoteBase.ref;
398
+ }
399
+ catch (error) {
400
+ const detail = error instanceof Error ? error.message : String(error);
401
+ const message = `Could not refresh ${remote}: ${detail}`;
402
+ if (options.json || isHeadless()) {
403
+ emitNewError(options, `${message}. Use --no-fetch to create from the local repository HEAD.`);
404
+ return null;
405
+ }
406
+ const continueWithoutRefresh = await promptForFetchFailure(message);
407
+ if (!continueWithoutRefresh) {
408
+ options.stderr("Aborted\n");
409
+ return null;
410
+ }
411
+ if (baseBranch &&
412
+ (await localRefExists(repoRoot, `refs/remotes/${remote}/${baseBranch}`))) {
413
+ options.stderr(`Continuing with the cached ${remote}/${baseBranch}.\n`);
414
+ return `${remote}/${baseBranch}`;
415
+ }
416
+ options.stderr("Continuing from the local repository HEAD.\n");
417
+ return "HEAD";
418
+ }
419
+ }
420
+ async function defaultPromptForFetchFailure(message) {
421
+ const choice = await confirm({
422
+ active: "Continue",
423
+ inactive: "Abort",
424
+ initialValue: false,
425
+ message: `${message}. Continue without refreshing the remote base?`,
426
+ });
427
+ return !isCancel(choice) && choice;
428
+ }
429
+ async function localRefExists(repoRoot, ref) {
430
+ try {
431
+ await runGit(repoRoot, ["rev-parse", "--verify", "--quiet", ref]);
432
+ return true;
433
+ }
434
+ catch {
435
+ return false;
436
+ }
437
+ }
184
438
  export function generateBranchPlaceholder(random = Math.random) {
185
439
  const roots = [
186
440
  "socrates",
@@ -333,8 +587,44 @@ async function localBranchExists(repoRoot, branchName) {
333
587
  return false;
334
588
  }
335
589
  }
336
- async function writeOutput(worktreePath, stdout) {
337
- await writeShellOutput(NEW_OUTPUT_FILE_ENV, worktreePath, stdout);
590
+ async function resolveCurrentWorktreeHead(cwd) {
591
+ return execFileAsync("git", ["rev-parse", "--verify", "HEAD"], { cwd }).then(({ stdout }) => stdout.trim());
592
+ }
593
+ async function createTakeStash(cwd, worktreeName) {
594
+ const message = `gji-take: ${worktreeName}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
595
+ await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
596
+ const { stdout } = await execFileAsync("git", ["stash", "list", "--format=%H%x00%s"], { cwd });
597
+ const line = stdout
598
+ .split("\n")
599
+ .map((candidate) => candidate.split("\0"))
600
+ .find(([, subject]) => subject?.endsWith(message));
601
+ if (!line?.[0])
602
+ throw new Error("could not identify the stash created for --take");
603
+ return line[0];
604
+ }
605
+ async function detectInProgressGitState(cwd) {
606
+ const markers = [
607
+ ["MERGE_HEAD", "merge"],
608
+ ["rebase-merge", "rebase"],
609
+ ["rebase-apply", "rebase"],
610
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
611
+ ["REVERT_HEAD", "revert"],
612
+ ["BISECT_LOG", "bisect"],
613
+ ];
614
+ for (const [marker, name] of markers) {
615
+ try {
616
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", marker], { cwd });
617
+ await access(resolve(cwd, stdout.trim()));
618
+ return name;
619
+ }
620
+ catch {
621
+ /* marker is absent */
622
+ }
623
+ }
624
+ return null;
625
+ }
626
+ async function writeOutput(worktreePath, stdout, outputEnv) {
627
+ await writeShellOutput(outputEnv ?? NEW_OUTPUT_FILE_ENV, worktreePath, stdout);
338
628
  }
339
629
  function isNotRegisteredWorktreeError(error) {
340
630
  const stderr = hasExecStderr(error) ? error.stderr : String(error);
@@ -368,3 +658,111 @@ async function openWorktree(worktreePath, editorCli, spawnFn, stderr) {
368
658
  stderr(`gji new: failed to open editor: ${message}\n`);
369
659
  }
370
660
  }
661
+ async function listTakeFiles(cwd) {
662
+ try {
663
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1"], { cwd });
664
+ return stdout
665
+ .split("\n")
666
+ .filter((line) => line.length > 3)
667
+ .map((line) => line.slice(3).replace(/^"|"$/g, ""));
668
+ }
669
+ catch {
670
+ return [];
671
+ }
672
+ }
673
+ async function listIgnoredFiles(cwd) {
674
+ try {
675
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1", "--ignored", "--untracked-files=all"], { cwd });
676
+ return stdout
677
+ .split("\n")
678
+ .filter((line) => line.startsWith("!! "))
679
+ .map((line) => line.slice(3).replace(/^"|"$/g, ""));
680
+ }
681
+ catch {
682
+ return [];
683
+ }
684
+ }
685
+ async function listSubmoduleFiles(cwd, changedFiles) {
686
+ if (changedFiles.length === 0)
687
+ return [];
688
+ try {
689
+ const { stdout } = await execFileAsync("git", ["ls-files", "--stage"], {
690
+ cwd,
691
+ });
692
+ const submodules = new Set(stdout
693
+ .split("\n")
694
+ .filter((line) => line.startsWith("160000 "))
695
+ .map((line) => line.slice(line.indexOf("\t") + 1)));
696
+ return changedFiles.filter((file) => submodules.has(file));
697
+ }
698
+ catch {
699
+ return [];
700
+ }
701
+ }
702
+ async function countUntrackedFiles(cwd) {
703
+ try {
704
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd });
705
+ return stdout.split("\n").filter((line) => line.startsWith("?? ")).length;
706
+ }
707
+ catch {
708
+ return 0;
709
+ }
710
+ }
711
+ async function applyTakeStash(worktreePath, sourcePath, stashSha, copy, stderr) {
712
+ try {
713
+ await execFileAsync("git", ["stash", "apply", stashSha], {
714
+ cwd: worktreePath,
715
+ });
716
+ }
717
+ catch (error) {
718
+ stderr(`Warning: stash apply failed: ${toExecMessage(error)}\n`);
719
+ await restoreTakeStash(sourcePath, stashSha, stderr);
720
+ return false;
721
+ }
722
+ if (copy) {
723
+ try {
724
+ await execFileAsync("git", ["stash", "apply", stashSha], {
725
+ cwd: sourcePath,
726
+ });
727
+ }
728
+ catch (error) {
729
+ stderr(`Warning: could not restore copied changes to the source: ${toExecMessage(error)}\n`);
730
+ return false;
731
+ }
732
+ }
733
+ try {
734
+ const { stdout } = await execFileAsync("git", ["stash", "list", "--format=%H %gd"], { cwd: sourcePath });
735
+ const line = stdout
736
+ .split("\n")
737
+ .find((candidate) => candidate.startsWith(stashSha));
738
+ const ref = line?.trim().split(/\s+/).at(1);
739
+ if (ref)
740
+ await execFileAsync("git", ["stash", "drop", ref], { cwd: sourcePath });
741
+ }
742
+ catch {
743
+ /* preserving the stash is safer */
744
+ }
745
+ return true;
746
+ }
747
+ async function restoreTakeStash(sourcePath, stashSha, stderr) {
748
+ try {
749
+ await execFileAsync("git", ["stash", "apply", stashSha], {
750
+ cwd: sourcePath,
751
+ });
752
+ }
753
+ catch {
754
+ stderr(`Your changes are safe in stash: ${stashSha} — run "git stash apply ${stashSha}" to restore\n`);
755
+ }
756
+ }
757
+ function emitNewError(options, message, details) {
758
+ if (options.json)
759
+ options.stderr(`${JSON.stringify({ error: message, ...details }, null, 2)}\n`);
760
+ else {
761
+ const path = typeof details?.path === "string" ? details.path : undefined;
762
+ options.stderr(`gji new: ${message}${path ? ` at ${path}` : ""}\n`);
763
+ if (path) {
764
+ options.stderr(`Hint: inspect the worktree or remove it with 'gji done ${path}' before retrying\n`);
765
+ }
766
+ }
767
+ return 1;
768
+ }
package/dist/open.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface OpenCommandOptions {
6
6
  cwd: string;
7
7
  editor?: string;
8
8
  save?: boolean;
9
+ select?: boolean;
9
10
  stderr: (chunk: string) => void;
10
11
  stdout: (chunk: string) => void;
11
12
  workspace?: boolean;
package/dist/open.js CHANGED
@@ -16,6 +16,14 @@ export function createOpenCommand(dependencies = {}) {
16
16
  const promptForWorktree = dependencies.promptForWorktree ?? defaultPromptForWorktree;
17
17
  const spawnEditor = dependencies.spawnEditor ?? defaultSpawnEditor;
18
18
  return async function runOpenCommand(options) {
19
+ if (options.select && options.branch !== undefined) {
20
+ options.stderr("gji open: --select cannot be used with a branch\n");
21
+ return 1;
22
+ }
23
+ if (options.select && isHeadless()) {
24
+ options.stderr("gji open --select: selector is unavailable in non-interactive mode (GJI_NO_TUI=1)\n");
25
+ return 1;
26
+ }
19
27
  const [worktrees, repository] = await Promise.all([
20
28
  listWorktrees(options.cwd),
21
29
  detectRepository(options.cwd),
@@ -36,9 +44,13 @@ export function createOpenCommand(dependencies = {}) {
36
44
  targetPath = match.worktree.path;
37
45
  targetWorktree = match.worktree;
38
46
  }
39
- else if (isHeadless()) {
47
+ else if (!options.select) {
40
48
  targetWorktree = worktrees.find((w) => w.isCurrent);
41
- targetPath = targetWorktree?.path ?? options.cwd;
49
+ if (!targetWorktree) {
50
+ options.stderr("gji open: unable to identify the current worktree\n");
51
+ return 1;
52
+ }
53
+ targetPath = targetWorktree.path;
42
54
  }
43
55
  else {
44
56
  const entries = await buildWorktreePromptEntries(worktrees.map((worktree) => ({
package/dist/pr.d.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  import { type PathConflictChoice } from "./conflict.js";
2
- import { type InstallPromptDependencies } from "./install-prompt.js";
2
+ import { type DependencyBootstrapPromptDependencies } from "./dependency-bootstrap-prompt.js";
3
+ import type { CloneDirectory } from "./dir-clone.js";
4
+ import type { InstallPromptDependencies } from "./install-prompt.js";
3
5
  export type { PathConflictChoice };
4
6
  export interface PrCommandOptions {
5
7
  cwd: string;
6
8
  dryRun?: boolean;
7
9
  json?: boolean;
8
10
  number: string;
11
+ outputEnv?: string;
9
12
  stderr: (chunk: string) => void;
10
13
  stdout: (chunk: string) => void;
11
14
  }
12
- export interface PrCommandDependencies extends InstallPromptDependencies {
15
+ export interface PrCommandDependencies extends InstallPromptDependencies, DependencyBootstrapPromptDependencies {
16
+ cloneDir?: CloneDirectory;
13
17
  promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
14
18
  }
15
19
  export declare function parsePrInput(input: string): string | null;