@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/new.js CHANGED
@@ -1,26 +1,37 @@
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 { writeTask } from "./task.js";
22
+ import { bootstrapWorktree } from "./worktree-bootstrap.js";
16
23
  const execFileAsync = promisify(execFile);
17
24
  const NEW_OUTPUT_FILE_ENV = "GJI_NEW_OUTPUT_FILE";
18
25
  export function createNewCommand(dependencies = {}) {
19
26
  const createBranchPlaceholder = dependencies.createBranchPlaceholder ?? generateBranchPlaceholder;
27
+ const cloneDirectory = dependencies.cloneDir ?? cloneDir;
20
28
  const promptForBranch = dependencies.promptForBranch ?? defaultPromptForBranch;
29
+ const promptForFetchFailure = dependencies.promptForFetchFailure ?? defaultPromptForFetchFailure;
21
30
  const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
22
31
  const spawnEditor = dependencies.spawnEditor ?? defaultSpawnEditor;
23
32
  return async function runNewCommand(options) {
33
+ if (options.copy && !options.take)
34
+ return emitNewError(options, "--copy requires --take");
24
35
  if (options.detached && options.fromCurrent) {
25
36
  const message = "--from-current cannot be used with --detached";
26
37
  if (options.json) {
@@ -32,7 +43,16 @@ export function createNewCommand(dependencies = {}) {
32
43
  return 1;
33
44
  }
34
45
  const repository = await detectRepository(options.cwd);
35
- 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
+ return emitNewError(options, error instanceof Error ? error.message : String(error));
55
+ }
36
56
  const usesGeneratedDetachedName = options.detached && options.branch === undefined;
37
57
  if (options.editor && !options.open) {
38
58
  options.stderr("gji new: --editor has no effect without --open\n");
@@ -77,9 +97,11 @@ export function createNewCommand(dependencies = {}) {
77
97
  const configuredBasePath = rawBasePath?.startsWith("/") || rawBasePath?.startsWith("~")
78
98
  ? rawBasePath
79
99
  : undefined;
80
- const worktreeName = options.detached
100
+ const worktreeName = options.mode
81
101
  ? rawBranch
82
- : applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
102
+ : options.detached
103
+ ? rawBranch
104
+ : applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
83
105
  const worktreePath = usesGeneratedDetachedName
84
106
  ? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName, configuredBasePath)
85
107
  : resolveWorktreePath(repository.repoRoot, worktreeName, configuredBasePath);
@@ -119,7 +141,7 @@ export function createNewCommand(dependencies = {}) {
119
141
  }
120
142
  else {
121
143
  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`);
144
+ options.stderr(`Hint: Use 'gji done ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
123
145
  options.stderr(`Hint: Use 'gji run-hook after-create' inside the worktree to re-run setup hooks\n`);
124
146
  }
125
147
  return 1;
@@ -128,16 +150,64 @@ export function createNewCommand(dependencies = {}) {
128
150
  const choice = await prompt(worktreePath);
129
151
  if (choice === "reuse") {
130
152
  await recordWorktreeUsage(worktreePath, worktreeName);
131
- await writeOutput(worktreePath, options.stdout);
153
+ await writeOutput(worktreePath, options.stdout, options.outputEnv);
132
154
  return 0;
133
155
  }
134
156
  options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
135
157
  return 1;
136
158
  }
137
159
  }
160
+ const dependencyPolicy = await resolveDependencyBootstrapPolicy({
161
+ currentRoot: repository.currentRoot,
162
+ repoRoot: repository.repoRoot,
163
+ worktreePath,
164
+ }, config, dependencyBootstrapExplicit, {
165
+ dependencies,
166
+ dryRun: options.dryRun,
167
+ legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
168
+ nonInteractive: !!options.json,
169
+ stderr: options.stderr,
170
+ });
171
+ config = {
172
+ ...config,
173
+ dependencyBootstrap: dependencyPolicy.mode,
174
+ };
138
175
  if (options.dryRun) {
176
+ if (options.take) {
177
+ const changedFiles = await listTakeFiles(options.cwd);
178
+ const submoduleFiles = await listSubmoduleFiles(options.cwd, changedFiles);
179
+ const transferableFiles = changedFiles.filter((file) => !submoduleFiles.includes(file));
180
+ const ignoredFiles = await listIgnoredFiles(options.cwd);
181
+ if (transferableFiles.length === 0)
182
+ return emitNewError(options, submoduleFiles.length > 0
183
+ ? "nothing to take: submodule changes are not supported"
184
+ : ignoredFiles.length > 0
185
+ ? "nothing to take: ignored files are not moved"
186
+ : "nothing to take: working tree is clean");
187
+ if (options.json)
188
+ options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath, dryRun: true, take: transferableFiles, ignored: ignoredFiles, submodules: submoduleFiles }, null, 2)}\n`);
189
+ else {
190
+ 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("")}`);
191
+ }
192
+ return 0;
193
+ }
194
+ const dryRunSyncDirs = await estimateSyncDirectories(repository.repoRoot, worktreePath, config.syncDirs ?? []);
195
+ const dryRunDependencyBootstrap = await createDependencyBootstrapPreview(config.dependencyBootstrap ?? "off", {
196
+ currentRoot: repository.currentRoot,
197
+ repoRoot: repository.repoRoot,
198
+ cargoBuildCommand: config.dependencyBuildCommand,
199
+ worktreePath,
200
+ });
139
201
  if (options.json) {
140
- options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath, dryRun: true }, null, 2)}\n`);
202
+ const output = {
203
+ ...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName),
204
+ dryRun: true,
205
+ };
206
+ if (dryRunSyncDirs.length > 0)
207
+ output.syncDirs = dryRunSyncDirs;
208
+ if (dryRunDependencyBootstrap.targets.length > 0)
209
+ output.dependencyBootstrap = dryRunDependencyBootstrap;
210
+ options.stdout(`${JSON.stringify(output, null, 2)}\n`);
141
211
  }
142
212
  else {
143
213
  const resolvedEditor = options.open
@@ -146,7 +216,7 @@ export function createNewCommand(dependencies = {}) {
146
216
  const openNote = resolvedEditor
147
217
  ? `, then open in ${resolvedEditor}`
148
218
  : "";
149
- options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n`);
219
+ 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
220
  }
151
221
  return 0;
152
222
  }
@@ -154,44 +224,163 @@ export function createNewCommand(dependencies = {}) {
154
224
  const startPoint = options.fromCurrent && !options.detached
155
225
  ? await resolveCurrentWorktreeHead(options.cwd)
156
226
  : undefined;
227
+ const takeStartPoint = options.take
228
+ ? await resolveCurrentWorktreeHead(options.cwd)
229
+ : undefined;
230
+ const changedTakeFiles = options.take
231
+ ? await listTakeFiles(options.cwd)
232
+ : [];
233
+ const submoduleFiles = options.take
234
+ ? await listSubmoduleFiles(options.cwd, changedTakeFiles)
235
+ : [];
236
+ const takeFiles = changedTakeFiles.filter((file) => !submoduleFiles.includes(file));
237
+ const takeUntracked = options.take
238
+ ? await countUntrackedFiles(options.cwd)
239
+ : 0;
240
+ if (options.take && takeFiles.length === 0)
241
+ return emitNewError(options, submoduleFiles.length > 0
242
+ ? "nothing to take: submodule changes are not supported"
243
+ : "nothing to take: working tree is clean");
244
+ if (options.take && submoduleFiles.length > 0)
245
+ options.stderr(`Warning: submodule changes are not transferred: ${submoduleFiles.join(", ")}\n`);
246
+ const existingLocalBranch = await localBranchExists(repository.repoRoot, worktreeName);
247
+ const shouldRefreshBase = !options.noFetch &&
248
+ !options.detached &&
249
+ !options.fromCurrent &&
250
+ !options.take &&
251
+ options.mode !== "track" &&
252
+ options.mode !== "checkout" &&
253
+ !existingLocalBranch;
254
+ const freshBaseRef = shouldRefreshBase
255
+ ? await resolveFreshBaseRef(repository.repoRoot, config, options, promptForFetchFailure)
256
+ : undefined;
257
+ if (freshBaseRef === null)
258
+ return 1;
259
+ let stashSha = null;
260
+ if (options.take) {
261
+ try {
262
+ const inProgressState = await detectInProgressGitState(options.cwd);
263
+ if (inProgressState)
264
+ return emitNewError(options, `cannot take changes while Git is in progress (${inProgressState})`);
265
+ stashSha = await createTakeStash(options.cwd, worktreeName);
266
+ }
267
+ catch (error) {
268
+ return emitNewError(options, `could not stash changes safely: ${toExecMessage(error)}`);
269
+ }
270
+ }
157
271
  const gitArgs = options.detached
158
- ? ["worktree", "add", "--detach", worktreePath]
159
- : (await localBranchExists(repository.repoRoot, worktreeName))
160
- ? ["worktree", "add", worktreePath, worktreeName]
161
- : [
272
+ ? [
273
+ "worktree",
274
+ "add",
275
+ "--detach",
276
+ worktreePath,
277
+ ...(takeStartPoint ? [takeStartPoint] : []),
278
+ ]
279
+ : options.mode === "track"
280
+ ? [
162
281
  "worktree",
163
282
  "add",
283
+ "--track",
164
284
  "-b",
165
285
  worktreeName,
166
286
  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) {
287
+ `${options.remote ?? "origin"}/${worktreeName}`,
288
+ ]
289
+ : options.mode === "checkout" || existingLocalBranch
290
+ ? ["worktree", "add", worktreePath, worktreeName]
291
+ : [
292
+ "worktree",
293
+ "add",
294
+ "-b",
295
+ worktreeName,
296
+ worktreePath,
297
+ ...(takeStartPoint
298
+ ? [takeStartPoint]
299
+ : startPoint
300
+ ? [startPoint]
301
+ : freshBaseRef
302
+ ? [freshBaseRef]
303
+ : []),
304
+ ];
305
+ try {
306
+ await execFileAsync("git", gitArgs, { cwd: repository.repoRoot });
307
+ }
308
+ catch (error) {
309
+ if (stashSha)
310
+ await restoreTakeStash(options.cwd, stashSha, options.stderr);
311
+ return emitNewError(options, `failed to create worktree: ${toExecMessage(error)}`);
312
+ }
313
+ if (stashSha &&
314
+ !(await applyTakeStash(worktreePath, options.cwd, stashSha, !!options.copy, options.stderr))) {
315
+ try {
316
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repository.repoRoot });
317
+ }
318
+ catch {
319
+ /* rollback best effort */
320
+ }
321
+ return emitNewError(options, `could not apply taken changes; your changes are safe in stash: ${stashSha}`);
322
+ }
323
+ if (options.task !== undefined) {
175
324
  try {
176
- await syncFiles(repository.repoRoot, worktreePath, [pattern]);
325
+ await writeTask(worktreePath, options.task);
177
326
  }
178
327
  catch (error) {
179
- options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
328
+ options.stderr(`Warning: could not save task metadata: ${error instanceof Error ? error.message : String(error)}\n`);
180
329
  }
181
330
  }
182
- await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
183
- const hooks = extractHooks(config);
184
- await runHook(hooks["after-create"], worktreePath, {
331
+ const bootstrap = await bootstrapWorktree({
185
332
  branch: worktreeName,
186
- path: worktreePath,
187
- repo: basename(repository.repoRoot),
188
- }, options.stderr);
333
+ cloneDirectory,
334
+ config,
335
+ currentRoot: repository.currentRoot,
336
+ nonInteractive: !!options.json,
337
+ repoRoot: repository.repoRoot,
338
+ reporter: createBootstrapReporter(options.stderr, !!options.json),
339
+ runCommand: dependencies.runInstallCommand,
340
+ commandStdout: options.json ? () => undefined : options.stdout,
341
+ commandStderr: options.json ? () => undefined : options.stderr,
342
+ json: options.json,
343
+ worktreePath,
344
+ installDependencies: dependencies,
345
+ dependencyBootstrapPolicy: dependencyPolicy,
346
+ });
347
+ if (!bootstrap.ready) {
348
+ return emitNewError(options, "worktree bootstrap failed", {
349
+ dependencyBootstrap: bootstrap.dependencyBootstrap,
350
+ path: worktreePath,
351
+ skipped: bootstrap.skippedDirs,
352
+ syncFiles: bootstrap.syncFileFailures,
353
+ });
354
+ }
189
355
  if (options.json) {
190
- options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath }, null, 2)}\n`);
356
+ const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName);
357
+ const output = options.take
358
+ ? {
359
+ ...navigation,
360
+ taken: {
361
+ files: takeFiles.length,
362
+ untracked: takeUntracked,
363
+ submodules: submoduleFiles,
364
+ },
365
+ }
366
+ : { ...navigation };
367
+ if (bootstrap.clonedDirs.length > 0) {
368
+ output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({
369
+ dir,
370
+ ms,
371
+ }));
372
+ }
373
+ if (bootstrap.skippedDirs.length > 0)
374
+ output.skipped = bootstrap.skippedDirs;
375
+ if (bootstrap.dependencyBootstrap.mode !== "off")
376
+ output.dependencyBootstrap = bootstrap.dependencyBootstrap;
377
+ options.stdout(`${JSON.stringify(output, null, 2)}\n`);
191
378
  }
192
379
  else {
380
+ if (options.take)
381
+ options.stderr(`✓ took ${takeFiles.length} changed file${takeFiles.length === 1 ? "" : "s"} (${takeUntracked} untracked) → ${worktreeName}\n`);
193
382
  await recordWorktreeUsage(worktreePath, worktreeName);
194
- await writeOutput(worktreePath, options.stdout);
383
+ await writeOutput(worktreePath, options.stdout, options.outputEnv);
195
384
  }
196
385
  if (options.open) {
197
386
  const resolvedEditor = options.editor ?? resolveConfigString(config, "editor");
@@ -201,6 +390,60 @@ export function createNewCommand(dependencies = {}) {
201
390
  };
202
391
  }
203
392
  export const runNewCommand = createNewCommand();
393
+ async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFailure) {
394
+ const remote = resolveConfigString(config, "syncRemote") ?? "origin";
395
+ const configuredRemote = resolveConfigString(config, "syncRemote");
396
+ let baseBranch = resolveConfigString(config, "syncDefaultBranch");
397
+ try {
398
+ const remoteBase = await resolveRemoteBase(repoRoot, remote, baseBranch);
399
+ if (!remoteBase) {
400
+ if (!configuredRemote)
401
+ return "HEAD";
402
+ throw new Error("the remote default branch is unknown");
403
+ }
404
+ baseBranch = remoteBase.branch;
405
+ await runGit(repoRoot, ["fetch", "--prune", remote, remoteBase.branch]);
406
+ return remoteBase.ref;
407
+ }
408
+ catch (error) {
409
+ const detail = error instanceof Error ? error.message : String(error);
410
+ const message = `Could not refresh ${remote}: ${detail}`;
411
+ if (options.json || isHeadless()) {
412
+ emitNewError(options, `${message}. Use --no-fetch to create from the local repository HEAD.`);
413
+ return null;
414
+ }
415
+ const continueWithoutRefresh = await promptForFetchFailure(message);
416
+ if (!continueWithoutRefresh) {
417
+ options.stderr("Aborted\n");
418
+ return null;
419
+ }
420
+ if (baseBranch &&
421
+ (await localRefExists(repoRoot, `refs/remotes/${remote}/${baseBranch}`))) {
422
+ options.stderr(`Continuing with the cached ${remote}/${baseBranch}.\n`);
423
+ return `${remote}/${baseBranch}`;
424
+ }
425
+ options.stderr("Continuing from the local repository HEAD.\n");
426
+ return "HEAD";
427
+ }
428
+ }
429
+ async function defaultPromptForFetchFailure(message) {
430
+ const choice = await confirm({
431
+ active: "Continue",
432
+ inactive: "Abort",
433
+ initialValue: false,
434
+ message: `${message}. Continue without refreshing the remote base?`,
435
+ });
436
+ return !isCancel(choice) && choice;
437
+ }
438
+ async function localRefExists(repoRoot, ref) {
439
+ try {
440
+ await runGit(repoRoot, ["rev-parse", "--verify", "--quiet", ref]);
441
+ return true;
442
+ }
443
+ catch {
444
+ return false;
445
+ }
446
+ }
204
447
  export function generateBranchPlaceholder(random = Math.random) {
205
448
  const roots = [
206
449
  "socrates",
@@ -356,8 +599,41 @@ async function localBranchExists(repoRoot, branchName) {
356
599
  async function resolveCurrentWorktreeHead(cwd) {
357
600
  return execFileAsync("git", ["rev-parse", "--verify", "HEAD"], { cwd }).then(({ stdout }) => stdout.trim());
358
601
  }
359
- async function writeOutput(worktreePath, stdout) {
360
- await writeShellOutput(NEW_OUTPUT_FILE_ENV, worktreePath, stdout);
602
+ async function createTakeStash(cwd, worktreeName) {
603
+ const message = `gji-take: ${worktreeName}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
604
+ await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
605
+ const { stdout } = await execFileAsync("git", ["stash", "list", "--format=%H%x00%s"], { cwd });
606
+ const line = stdout
607
+ .split("\n")
608
+ .map((candidate) => candidate.split("\0"))
609
+ .find(([, subject]) => subject?.endsWith(message));
610
+ if (!line?.[0])
611
+ throw new Error("could not identify the stash created for --take");
612
+ return line[0];
613
+ }
614
+ async function detectInProgressGitState(cwd) {
615
+ const markers = [
616
+ ["MERGE_HEAD", "merge"],
617
+ ["rebase-merge", "rebase"],
618
+ ["rebase-apply", "rebase"],
619
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
620
+ ["REVERT_HEAD", "revert"],
621
+ ["BISECT_LOG", "bisect"],
622
+ ];
623
+ for (const [marker, name] of markers) {
624
+ try {
625
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", marker], { cwd });
626
+ await access(resolve(cwd, stdout.trim()));
627
+ return name;
628
+ }
629
+ catch {
630
+ /* marker is absent */
631
+ }
632
+ }
633
+ return null;
634
+ }
635
+ async function writeOutput(worktreePath, stdout, outputEnv) {
636
+ await writeShellOutput(outputEnv ?? NEW_OUTPUT_FILE_ENV, worktreePath, stdout);
361
637
  }
362
638
  function isNotRegisteredWorktreeError(error) {
363
639
  const stderr = hasExecStderr(error) ? error.stderr : String(error);
@@ -391,3 +667,111 @@ async function openWorktree(worktreePath, editorCli, spawnFn, stderr) {
391
667
  stderr(`gji new: failed to open editor: ${message}\n`);
392
668
  }
393
669
  }
670
+ async function listTakeFiles(cwd) {
671
+ try {
672
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1"], { cwd });
673
+ return stdout
674
+ .split("\n")
675
+ .filter((line) => line.length > 3)
676
+ .map((line) => line.slice(3).replace(/^"|"$/g, ""));
677
+ }
678
+ catch {
679
+ return [];
680
+ }
681
+ }
682
+ async function listIgnoredFiles(cwd) {
683
+ try {
684
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1", "--ignored", "--untracked-files=all"], { cwd });
685
+ return stdout
686
+ .split("\n")
687
+ .filter((line) => line.startsWith("!! "))
688
+ .map((line) => line.slice(3).replace(/^"|"$/g, ""));
689
+ }
690
+ catch {
691
+ return [];
692
+ }
693
+ }
694
+ async function listSubmoduleFiles(cwd, changedFiles) {
695
+ if (changedFiles.length === 0)
696
+ return [];
697
+ try {
698
+ const { stdout } = await execFileAsync("git", ["ls-files", "--stage"], {
699
+ cwd,
700
+ });
701
+ const submodules = new Set(stdout
702
+ .split("\n")
703
+ .filter((line) => line.startsWith("160000 "))
704
+ .map((line) => line.slice(line.indexOf("\t") + 1)));
705
+ return changedFiles.filter((file) => submodules.has(file));
706
+ }
707
+ catch {
708
+ return [];
709
+ }
710
+ }
711
+ async function countUntrackedFiles(cwd) {
712
+ try {
713
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd });
714
+ return stdout.split("\n").filter((line) => line.startsWith("?? ")).length;
715
+ }
716
+ catch {
717
+ return 0;
718
+ }
719
+ }
720
+ async function applyTakeStash(worktreePath, sourcePath, stashSha, copy, stderr) {
721
+ try {
722
+ await execFileAsync("git", ["stash", "apply", stashSha], {
723
+ cwd: worktreePath,
724
+ });
725
+ }
726
+ catch (error) {
727
+ stderr(`Warning: stash apply failed: ${toExecMessage(error)}\n`);
728
+ await restoreTakeStash(sourcePath, stashSha, stderr);
729
+ return false;
730
+ }
731
+ if (copy) {
732
+ try {
733
+ await execFileAsync("git", ["stash", "apply", stashSha], {
734
+ cwd: sourcePath,
735
+ });
736
+ }
737
+ catch (error) {
738
+ stderr(`Warning: could not restore copied changes to the source: ${toExecMessage(error)}\n`);
739
+ return false;
740
+ }
741
+ }
742
+ try {
743
+ const { stdout } = await execFileAsync("git", ["stash", "list", "--format=%H %gd"], { cwd: sourcePath });
744
+ const line = stdout
745
+ .split("\n")
746
+ .find((candidate) => candidate.startsWith(stashSha));
747
+ const ref = line?.trim().split(/\s+/).at(1);
748
+ if (ref)
749
+ await execFileAsync("git", ["stash", "drop", ref], { cwd: sourcePath });
750
+ }
751
+ catch {
752
+ /* preserving the stash is safer */
753
+ }
754
+ return true;
755
+ }
756
+ async function restoreTakeStash(sourcePath, stashSha, stderr) {
757
+ try {
758
+ await execFileAsync("git", ["stash", "apply", stashSha], {
759
+ cwd: sourcePath,
760
+ });
761
+ }
762
+ catch {
763
+ stderr(`Your changes are safe in stash: ${stashSha} — run "git stash apply ${stashSha}" to restore\n`);
764
+ }
765
+ }
766
+ function emitNewError(options, message, details) {
767
+ if (options.json)
768
+ options.stderr(`${JSON.stringify({ error: message, ...details }, null, 2)}\n`);
769
+ else {
770
+ const path = typeof details?.path === "string" ? details.path : undefined;
771
+ options.stderr(`gji new: ${message}${path ? ` at ${path}` : ""}\n`);
772
+ if (path) {
773
+ options.stderr(`Hint: inspect the worktree or remove it with 'gji done ${path}' before retrying\n`);
774
+ }
775
+ }
776
+ return 1;
777
+ }
package/dist/pr-open.js CHANGED
@@ -130,7 +130,7 @@ async function openFromWorktreeSelector(repoRoot, repoName, options, promptForWo
130
130
  const entries = await buildWorktreePromptEntries(sources, {
131
131
  queryPullRequests: async (_root, branch) => pullRequestsByBranch.get(branch) ?? [],
132
132
  });
133
- const connectedEntries = entries.filter((entry) => (entry.pullRequestNumbers?.length ?? 0) > 0);
133
+ const connectedEntries = entries.filter((entry) => (entry.pullRequests?.length ?? 0) > 0);
134
134
  if (connectedEntries.length === 0) {
135
135
  if (lookupError !== null) {
136
136
  options.stderr(`gji pr open: failed to look up open PRs: ${formatError(lookupError)}\n`);
@@ -225,15 +225,7 @@ async function openForBranch(repoRoot, branch, options, queryPullRequests, promp
225
225
  return openPullRequests(pullRequests, options, promptForPullRequest, openInBrowser);
226
226
  }
227
227
  async function openSelectedEntry(entry, options, promptForPullRequest, openInBrowser) {
228
- const numbers = entry.pullRequestNumbers ?? [];
229
- const urls = entry.pullRequestUrls ?? [];
230
- const pullRequests = numbers
231
- .map((number, index) => ({
232
- number,
233
- sourceBranch: entry.branch ?? "",
234
- url: urls[index] ?? "",
235
- }))
236
- .filter((pullRequest) => pullRequest.url.length > 0);
228
+ const pullRequests = entry.pullRequests ?? [];
237
229
  return openPullRequests(pullRequests, options, promptForPullRequest, openInBrowser);
238
230
  }
239
231
  async function openPullRequests(pullRequests, options, promptForPullRequest, openInBrowser) {
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;