@solaqua/gji 0.10.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 (98) hide show
  1. package/README.md +89 -18
  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 +101 -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 +5491 -1253
  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 +13 -2
  47. package/dist/new.js +414 -39
  48. package/dist/pr.d.ts +6 -2
  49. package/dist/pr.js +135 -30
  50. package/dist/remove.js +20 -0
  51. package/dist/repo.d.ts +3 -0
  52. package/dist/repo.js +57 -6
  53. package/dist/safe-destination.d.ts +15 -0
  54. package/dist/safe-destination.js +166 -0
  55. package/dist/shell-completion.js +43 -12
  56. package/dist/sync-directories.d.ts +33 -0
  57. package/dist/sync-directories.js +97 -0
  58. package/dist/sync-plan.d.ts +16 -0
  59. package/dist/sync-plan.js +128 -0
  60. package/dist/sync.js +7 -13
  61. package/dist/undo.d.ts +36 -0
  62. package/dist/undo.js +392 -0
  63. package/dist/warp.d.ts +3 -1
  64. package/dist/warp.js +32 -125
  65. package/dist/worktree-bootstrap.d.ts +35 -0
  66. package/dist/worktree-bootstrap.js +93 -0
  67. package/dist/worktree-management.d.ts +1 -0
  68. package/dist/worktree-management.js +23 -4
  69. package/dist/worktree-picker.d.ts +21 -8
  70. package/dist/worktree-picker.js +107 -15
  71. package/dist/worktree-prompts.js +1 -1
  72. package/dist/worktree-source.d.ts +6 -0
  73. package/dist/worktree-source.js +1 -0
  74. package/dist/worktree-sources.d.ts +3 -0
  75. package/dist/worktree-sources.js +31 -0
  76. package/man/man1/gji-back.1 +1 -1
  77. package/man/man1/gji-clean.1 +1 -1
  78. package/man/man1/gji-completion.1 +1 -1
  79. package/man/man1/gji-config.1 +1 -1
  80. package/man/man1/gji-doctor.1 +1 -1
  81. package/man/man1/gji-done.1 +19 -0
  82. package/man/man1/gji-go.1 +9 -3
  83. package/man/man1/gji-history.1 +1 -1
  84. package/man/man1/gji-init.1 +1 -1
  85. package/man/man1/gji-ls.1 +1 -1
  86. package/man/man1/gji-new.1 +18 -3
  87. package/man/man1/gji-open.1 +1 -1
  88. package/man/man1/gji-pr.1 +7 -1
  89. package/man/man1/gji-remove.1 +3 -3
  90. package/man/man1/gji-root.1 +1 -1
  91. package/man/man1/gji-run-hook.1 +1 -1
  92. package/man/man1/gji-status.1 +1 -1
  93. package/man/man1/gji-sync-files.1 +1 -1
  94. package/man/man1/gji-sync.1 +1 -1
  95. package/man/man1/gji-undo.1 +16 -0
  96. package/man/man1/gji-warp.1 +3 -6
  97. package/man/man1/gji.1 +13 -5
  98. package/package.json +3 -1
package/dist/new.js CHANGED
@@ -1,26 +1,36 @@
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");
24
34
  if (options.detached && options.fromCurrent) {
25
35
  const message = "--from-current cannot be used with --detached";
26
36
  if (options.json) {
@@ -32,7 +42,16 @@ export function createNewCommand(dependencies = {}) {
32
42
  return 1;
33
43
  }
34
44
  const repository = await detectRepository(options.cwd);
35
- 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
+ }
36
55
  const usesGeneratedDetachedName = options.detached && options.branch === undefined;
37
56
  if (options.editor && !options.open) {
38
57
  options.stderr("gji new: --editor has no effect without --open\n");
@@ -77,9 +96,11 @@ export function createNewCommand(dependencies = {}) {
77
96
  const configuredBasePath = rawBasePath?.startsWith("/") || rawBasePath?.startsWith("~")
78
97
  ? rawBasePath
79
98
  : undefined;
80
- const worktreeName = options.detached
99
+ const worktreeName = options.mode
81
100
  ? rawBranch
82
- : applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
101
+ : options.detached
102
+ ? rawBranch
103
+ : applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
83
104
  const worktreePath = usesGeneratedDetachedName
84
105
  ? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName, configuredBasePath)
85
106
  : resolveWorktreePath(repository.repoRoot, worktreeName, configuredBasePath);
@@ -119,7 +140,7 @@ export function createNewCommand(dependencies = {}) {
119
140
  }
120
141
  else {
121
142
  options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
122
- 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`);
123
144
  options.stderr(`Hint: Use 'gji run-hook after-create' inside the worktree to re-run setup hooks\n`);
124
145
  }
125
146
  return 1;
@@ -128,16 +149,64 @@ export function createNewCommand(dependencies = {}) {
128
149
  const choice = await prompt(worktreePath);
129
150
  if (choice === "reuse") {
130
151
  await recordWorktreeUsage(worktreePath, worktreeName);
131
- await writeOutput(worktreePath, options.stdout);
152
+ await writeOutput(worktreePath, options.stdout, options.outputEnv);
132
153
  return 0;
133
154
  }
134
155
  options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
135
156
  return 1;
136
157
  }
137
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
+ };
138
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
+ });
139
200
  if (options.json) {
140
- 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`);
141
210
  }
142
211
  else {
143
212
  const resolvedEditor = options.open
@@ -146,7 +215,7 @@ export function createNewCommand(dependencies = {}) {
146
215
  const openNote = resolvedEditor
147
216
  ? `, then open in ${resolvedEditor}`
148
217
  : "";
149
- 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)}`);
150
219
  }
151
220
  return 0;
152
221
  }
@@ -154,44 +223,155 @@ export function createNewCommand(dependencies = {}) {
154
223
  const startPoint = options.fromCurrent && !options.detached
155
224
  ? await resolveCurrentWorktreeHead(options.cwd)
156
225
  : undefined;
226
+ const takeStartPoint = options.take
227
+ ? await resolveCurrentWorktreeHead(options.cwd)
228
+ : undefined;
229
+ const changedTakeFiles = options.take
230
+ ? await listTakeFiles(options.cwd)
231
+ : [];
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) {
260
+ try {
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);
265
+ }
266
+ catch (error) {
267
+ return emitNewError(options, `could not stash changes safely: ${toExecMessage(error)}`);
268
+ }
269
+ }
157
270
  const gitArgs = options.detached
158
- ? ["worktree", "add", "--detach", worktreePath]
159
- : (await localBranchExists(repository.repoRoot, worktreeName))
160
- ? ["worktree", "add", worktreePath, worktreeName]
161
- : [
271
+ ? [
272
+ "worktree",
273
+ "add",
274
+ "--detach",
275
+ worktreePath,
276
+ ...(takeStartPoint ? [takeStartPoint] : []),
277
+ ]
278
+ : options.mode === "track"
279
+ ? [
162
280
  "worktree",
163
281
  "add",
282
+ "--track",
164
283
  "-b",
165
284
  worktreeName,
166
285
  worktreePath,
167
- ...(startPoint ? [startPoint] : []),
168
- ];
169
- await execFileAsync("git", gitArgs, { cwd: repository.repoRoot });
170
- // Sync files from main worktree before afterCreate so synced files are available to install scripts.
171
- const syncPatterns = Array.isArray(config.syncFiles)
172
- ? config.syncFiles.filter((p) => typeof p === "string")
173
- : [];
174
- for (const pattern of syncPatterns) {
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))) {
175
314
  try {
176
- await syncFiles(repository.repoRoot, worktreePath, [pattern]);
315
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repository.repoRoot });
177
316
  }
178
- catch (error) {
179
- options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
317
+ catch {
318
+ /* rollback best effort */
180
319
  }
320
+ return emitNewError(options, `could not apply taken changes; your changes are safe in stash: ${stashSha}`);
181
321
  }
182
- await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
183
- const hooks = extractHooks(config);
184
- await runHook(hooks["after-create"], worktreePath, {
322
+ const bootstrap = await bootstrapWorktree({
185
323
  branch: worktreeName,
186
- path: worktreePath,
187
- repo: basename(repository.repoRoot),
188
- }, 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
+ }
189
346
  if (options.json) {
190
- 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`);
191
369
  }
192
370
  else {
371
+ if (options.take)
372
+ options.stderr(`✓ took ${takeFiles.length} changed file${takeFiles.length === 1 ? "" : "s"} (${takeUntracked} untracked) → ${worktreeName}\n`);
193
373
  await recordWorktreeUsage(worktreePath, worktreeName);
194
- await writeOutput(worktreePath, options.stdout);
374
+ await writeOutput(worktreePath, options.stdout, options.outputEnv);
195
375
  }
196
376
  if (options.open) {
197
377
  const resolvedEditor = options.editor ?? resolveConfigString(config, "editor");
@@ -201,6 +381,60 @@ export function createNewCommand(dependencies = {}) {
201
381
  };
202
382
  }
203
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
+ }
204
438
  export function generateBranchPlaceholder(random = Math.random) {
205
439
  const roots = [
206
440
  "socrates",
@@ -356,8 +590,41 @@ async function localBranchExists(repoRoot, branchName) {
356
590
  async function resolveCurrentWorktreeHead(cwd) {
357
591
  return execFileAsync("git", ["rev-parse", "--verify", "HEAD"], { cwd }).then(({ stdout }) => stdout.trim());
358
592
  }
359
- async function writeOutput(worktreePath, stdout) {
360
- await writeShellOutput(NEW_OUTPUT_FILE_ENV, worktreePath, stdout);
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);
361
628
  }
362
629
  function isNotRegisteredWorktreeError(error) {
363
630
  const stderr = hasExecStderr(error) ? error.stderr : String(error);
@@ -391,3 +658,111 @@ async function openWorktree(worktreePath, editorCli, spawnFn, stderr) {
391
658
  stderr(`gji new: failed to open editor: ${message}\n`);
392
659
  }
393
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/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;