@solaqua/gji 0.12.2 → 0.12.3

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 (55) hide show
  1. package/dist/bootstrap-output.d.ts +3 -2
  2. package/dist/bootstrap-output.js +1 -16
  3. package/dist/bootstrap-preview.d.ts +2 -3
  4. package/dist/bootstrap-preview.js +1 -11
  5. package/dist/cli.js +5 -1
  6. package/dist/config.d.ts +3 -13
  7. package/dist/config.js +12 -59
  8. package/dist/dependency-bootstrap.d.ts +11 -45
  9. package/dist/dependency-bootstrap.js +142 -608
  10. package/dist/gji-bundle.mjs +567 -2367
  11. package/dist/new.d.ts +4 -5
  12. package/dist/new.js +10 -45
  13. package/dist/pr.d.ts +4 -5
  14. package/dist/pr.js +9 -69
  15. package/dist/shell-completion.js +7 -5
  16. package/dist/uv-validation.d.ts +4 -0
  17. package/dist/uv-validation.js +210 -0
  18. package/dist/worktree-bootstrap.d.ts +5 -16
  19. package/dist/worktree-bootstrap.js +1 -24
  20. package/man/man1/gji-back.1 +1 -1
  21. package/man/man1/gji-clean.1 +1 -1
  22. package/man/man1/gji-completion.1 +1 -1
  23. package/man/man1/gji-config.1 +1 -1
  24. package/man/man1/gji-doctor.1 +1 -1
  25. package/man/man1/gji-done.1 +1 -1
  26. package/man/man1/gji-go.1 +1 -1
  27. package/man/man1/gji-history.1 +1 -1
  28. package/man/man1/gji-init.1 +1 -1
  29. package/man/man1/gji-ls.1 +1 -1
  30. package/man/man1/gji-new.1 +8 -5
  31. package/man/man1/gji-open.1 +1 -1
  32. package/man/man1/gji-pr.1 +6 -3
  33. package/man/man1/gji-remove.1 +1 -1
  34. package/man/man1/gji-root.1 +1 -1
  35. package/man/man1/gji-run-hook.1 +1 -1
  36. package/man/man1/gji-status.1 +1 -1
  37. package/man/man1/gji-sync-files.1 +1 -1
  38. package/man/man1/gji-sync.1 +1 -1
  39. package/man/man1/gji-task.1 +1 -1
  40. package/man/man1/gji-undo.1 +1 -1
  41. package/man/man1/gji-warp.1 +1 -1
  42. package/man/man1/gji.1 +2 -2
  43. package/package.json +1 -1
  44. package/dist/dependency-bootstrap-prompt.d.ts +0 -24
  45. package/dist/dependency-bootstrap-prompt.js +0 -103
  46. package/dist/dir-clone.d.ts +0 -32
  47. package/dist/dir-clone.js +0 -641
  48. package/dist/install-prompt.d.ts +0 -12
  49. package/dist/install-prompt.js +0 -127
  50. package/dist/package-manager.d.ts +0 -5
  51. package/dist/package-manager.js +0 -159
  52. package/dist/sync-directories.d.ts +0 -29
  53. package/dist/sync-directories.js +0 -77
  54. package/dist/sync-plan.d.ts +0 -16
  55. package/dist/sync-plan.js +0 -128
package/dist/new.d.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  import { type PathConflictChoice } from "./conflict.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";
2
+ import { type BootstrapCommandRunner } from "./dependency-bootstrap.js";
5
3
  export type { PathConflictChoice };
6
4
  export type NewWorktreeMode = "create" | "checkout" | "track";
7
5
  export interface NewCommandOptions {
@@ -16,6 +14,7 @@ export interface NewCommandOptions {
16
14
  noFetch?: boolean;
17
15
  json?: boolean;
18
16
  mode?: NewWorktreeMode;
17
+ noInstall?: boolean;
19
18
  open?: boolean;
20
19
  outputEnv?: string;
21
20
  remote?: string;
@@ -24,12 +23,12 @@ export interface NewCommandOptions {
24
23
  stdout: (chunk: string) => void;
25
24
  task?: string;
26
25
  }
27
- export interface NewCommandDependencies extends InstallPromptDependencies, DependencyBootstrapPromptDependencies {
28
- cloneDir: CloneDirectory;
26
+ export interface NewCommandDependencies {
29
27
  createBranchPlaceholder: () => string;
30
28
  promptForBranch: (placeholder: string) => Promise<string | null>;
31
29
  promptForFetchFailure: (message: string) => Promise<boolean>;
32
30
  promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
31
+ runCommand: BootstrapCommandRunner;
33
32
  spawnEditor: (cli: string, args: string[]) => Promise<void>;
34
33
  }
35
34
  export declare function createNewCommand(dependencies?: Partial<NewCommandDependencies>): (options: NewCommandOptions) => Promise<number>;
package/dist/new.js CHANGED
@@ -5,26 +5,22 @@ import { promisify } from "node:util";
5
5
  import { confirm, isCancel, text } from "@clack/prompts";
6
6
  import { createBootstrapReporter } from "./bootstrap-output.js";
7
7
  import { createDependencyBootstrapPreview, formatDependencyBootstrapPreview, } from "./bootstrap-preview.js";
8
- import { loadEffectiveConfigResult, resolveConfigString, } from "./config.js";
8
+ import { loadEffectiveConfig, resolveConfigString, } from "./config.js";
9
9
  import { pathExists, promptForPathConflict, } from "./conflict.js";
10
- import { resolveDependencyBootstrapPolicy, } from "./dependency-bootstrap-prompt.js";
11
- import { cloneDir } from "./dir-clone.js";
10
+ import { resolveDependencyBootstrapMode, } from "./dependency-bootstrap.js";
12
11
  import { defaultSpawnEditor, EDITORS } from "./editor.js";
13
- import { formatBytes } from "./format-bytes.js";
14
12
  import { resolveRemoteBase, runGit } from "./git.js";
15
13
  import { isHeadless } from "./headless.js";
16
14
  import { recordWorktreeUsage } from "./history.js";
17
15
  import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js";
18
16
  import { detectRepository, resolveWorktreePath, validateBranchName, } from "./repo.js";
19
17
  import { writeShellOutput } from "./shell-handoff.js";
20
- import { estimateSyncDirectories } from "./sync-plan.js";
21
18
  import { writeTask } from "./task.js";
22
19
  import { bootstrapWorktree } from "./worktree-bootstrap.js";
23
20
  const execFileAsync = promisify(execFile);
24
21
  const NEW_OUTPUT_FILE_ENV = "GJI_NEW_OUTPUT_FILE";
25
22
  export function createNewCommand(dependencies = {}) {
26
23
  const createBranchPlaceholder = dependencies.createBranchPlaceholder ?? generateBranchPlaceholder;
27
- const cloneDirectory = dependencies.cloneDir ?? cloneDir;
28
24
  const promptForBranch = dependencies.promptForBranch ?? defaultPromptForBranch;
29
25
  const promptForFetchFailure = dependencies.promptForFetchFailure ?? defaultPromptForFetchFailure;
30
26
  const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
@@ -44,11 +40,8 @@ export function createNewCommand(dependencies = {}) {
44
40
  }
45
41
  const repository = await detectRepository(options.cwd);
46
42
  let config;
47
- let dependencyBootstrapExplicit;
48
43
  try {
49
- const loaded = await loadEffectiveConfigResult(repository.repoRoot, undefined, options.json ? undefined : options.stderr);
50
- config = loaded.config;
51
- dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit;
44
+ config = await loadEffectiveConfig(repository.repoRoot, undefined, options.json ? undefined : options.stderr);
52
45
  }
53
46
  catch (error) {
54
47
  return emitNewError(options, error instanceof Error ? error.message : String(error));
@@ -157,21 +150,7 @@ export function createNewCommand(dependencies = {}) {
157
150
  return 1;
158
151
  }
159
152
  }
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
- };
153
+ const dependencyMode = resolveDependencyBootstrapMode(config.dependencyBootstrap, options.noInstall);
175
154
  if (options.dryRun) {
176
155
  if (options.take) {
177
156
  const changedFiles = await listTakeFiles(options.cwd);
@@ -191,8 +170,7 @@ export function createNewCommand(dependencies = {}) {
191
170
  }
192
171
  return 0;
193
172
  }
194
- const dryRunSyncDirs = await estimateSyncDirectories(repository.repoRoot, worktreePath, config.syncDirs ?? []);
195
- const dryRunDependencyBootstrap = await createDependencyBootstrapPreview(config.dependencyBootstrap ?? "off", {
173
+ const dryRunDependencyBootstrap = await createDependencyBootstrapPreview(dependencyMode, {
196
174
  currentRoot: repository.currentRoot,
197
175
  repoRoot: repository.repoRoot,
198
176
  cargoBuildCommand: config.dependencyBuildCommand,
@@ -203,8 +181,6 @@ export function createNewCommand(dependencies = {}) {
203
181
  ...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName),
204
182
  dryRun: true,
205
183
  };
206
- if (dryRunSyncDirs.length > 0)
207
- output.syncDirs = dryRunSyncDirs;
208
184
  if (dryRunDependencyBootstrap.targets.length > 0)
209
185
  output.dependencyBootstrap = dryRunDependencyBootstrap;
210
186
  options.stdout(`${JSON.stringify(output, null, 2)}\n`);
@@ -216,7 +192,7 @@ export function createNewCommand(dependencies = {}) {
216
192
  const openNote = resolvedEditor
217
193
  ? `, then open in ${resolvedEditor}`
218
194
  : "";
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)}`);
195
+ options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName}${openNote})\n${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`);
220
196
  }
221
197
  return 0;
222
198
  }
@@ -330,25 +306,22 @@ export function createNewCommand(dependencies = {}) {
330
306
  }
331
307
  const bootstrap = await bootstrapWorktree({
332
308
  branch: worktreeName,
333
- cloneDirectory,
334
309
  config,
335
310
  currentRoot: repository.currentRoot,
336
- nonInteractive: !!options.json,
311
+ dependencyDetectionRoot: worktreePath,
312
+ dependencyMode,
337
313
  repoRoot: repository.repoRoot,
338
314
  reporter: createBootstrapReporter(options.stderr, !!options.json),
339
- runCommand: dependencies.runInstallCommand,
315
+ runCommand: dependencies.runCommand,
340
316
  commandStdout: options.json ? () => undefined : options.stdout,
341
317
  commandStderr: options.json ? () => undefined : options.stderr,
342
318
  json: options.json,
343
319
  worktreePath,
344
- installDependencies: dependencies,
345
- dependencyBootstrapPolicy: dependencyPolicy,
346
320
  });
347
321
  if (!bootstrap.ready) {
348
322
  return emitNewError(options, "worktree bootstrap failed", {
349
323
  dependencyBootstrap: bootstrap.dependencyBootstrap,
350
324
  path: worktreePath,
351
- skipped: bootstrap.skippedDirs,
352
325
  syncFiles: bootstrap.syncFileFailures,
353
326
  });
354
327
  }
@@ -364,15 +337,7 @@ export function createNewCommand(dependencies = {}) {
364
337
  },
365
338
  }
366
339
  : { ...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")
340
+ if (bootstrap.dependencyBootstrap.events.length > 0)
376
341
  output.dependencyBootstrap = bootstrap.dependencyBootstrap;
377
342
  options.stdout(`${JSON.stringify(output, null, 2)}\n`);
378
343
  }
package/dist/pr.d.ts CHANGED
@@ -1,20 +1,19 @@
1
1
  import { type PathConflictChoice } from "./conflict.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";
2
+ import { type BootstrapCommandRunner } from "./dependency-bootstrap.js";
5
3
  export type { PathConflictChoice };
6
4
  export interface PrCommandOptions {
7
5
  cwd: string;
8
6
  dryRun?: boolean;
9
7
  json?: boolean;
8
+ noInstall?: boolean;
10
9
  number: string;
11
10
  outputEnv?: string;
12
11
  stderr: (chunk: string) => void;
13
12
  stdout: (chunk: string) => void;
14
13
  }
15
- export interface PrCommandDependencies extends InstallPromptDependencies, DependencyBootstrapPromptDependencies {
16
- cloneDir?: CloneDirectory;
14
+ export interface PrCommandDependencies {
17
15
  promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
16
+ runCommand: BootstrapCommandRunner;
18
17
  }
19
18
  export declare function parsePrInput(input: string): string | null;
20
19
  export declare function createPrCommand(dependencies?: Partial<PrCommandDependencies>): (options: PrCommandOptions) => Promise<number>;
package/dist/pr.js CHANGED
@@ -4,16 +4,14 @@ import { dirname } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { createBootstrapReporter } from "./bootstrap-output.js";
6
6
  import { createDependencyBootstrapPreview, formatDependencyBootstrapPreview, } from "./bootstrap-preview.js";
7
- import { loadEffectiveConfigResult, resolveConfigString, } from "./config.js";
7
+ import { loadEffectiveConfig, resolveConfigString, } from "./config.js";
8
8
  import { pathExists, promptForPathConflict, } from "./conflict.js";
9
- import { resolveDependencyBootstrapPolicy, } from "./dependency-bootstrap-prompt.js";
10
- import { formatBytes } from "./format-bytes.js";
9
+ import { resolveDependencyBootstrapMode, } from "./dependency-bootstrap.js";
11
10
  import { isHeadless } from "./headless.js";
12
11
  import { recordWorktreeUsage } from "./history.js";
13
12
  import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js";
14
13
  import { detectRepository, resolveWorktreePath } from "./repo.js";
15
14
  import { writeShellOutput } from "./shell-handoff.js";
16
- import { estimateSyncDirectories } from "./sync-plan.js";
17
15
  import { bootstrapWorktree } from "./worktree-bootstrap.js";
18
16
  const execFileAsync = promisify(execFile);
19
17
  const PR_OUTPUT_FILE_ENV = "GJI_PR_OUTPUT_FILE";
@@ -44,11 +42,8 @@ export function createPrCommand(dependencies = {}) {
44
42
  }
45
43
  const repository = await detectRepository(options.cwd);
46
44
  let config;
47
- let dependencyBootstrapExplicit;
48
45
  try {
49
- const loaded = await loadEffectiveConfigResult(repository.repoRoot, undefined, options.json ? undefined : options.stderr);
50
- config = loaded.config;
51
- dependencyBootstrapExplicit = loaded.dependencyBootstrapExplicit;
46
+ config = await loadEffectiveConfig(repository.repoRoot, undefined, options.json ? undefined : options.stderr);
52
47
  }
53
48
  catch (error) {
54
49
  const message = error instanceof Error ? error.message : String(error);
@@ -88,33 +83,9 @@ export function createPrCommand(dependencies = {}) {
88
83
  options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
89
84
  return 1;
90
85
  }
91
- let dependencyPolicy = {
92
- mode: config.dependencyBootstrap ?? "off",
93
- prompted: false,
94
- source: dependencyBootstrapExplicit ? "explicit" : "default",
95
- };
96
- if (options.dryRun) {
97
- dependencyPolicy = await resolveDependencyBootstrapPolicy({
98
- currentRoot: repository.currentRoot,
99
- repoRoot: repository.repoRoot,
100
- worktreePath,
101
- }, config, dependencyBootstrapExplicit, {
102
- dependencies,
103
- dryRun: true,
104
- legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
105
- nonInteractive: !!options.json,
106
- stderr: options.stderr,
107
- });
108
- config = {
109
- ...config,
110
- dependencyBootstrap: dependencyPolicy.mode,
111
- };
112
- }
113
- const dryRunSyncDirs = options.dryRun
114
- ? await estimateSyncDirectories(repository.repoRoot, worktreePath, config.syncDirs ?? [])
115
- : [];
86
+ const dependencyMode = resolveDependencyBootstrapMode(config.dependencyBootstrap, options.noInstall);
116
87
  const dryRunDependencyBootstrap = options.dryRun
117
- ? await createDependencyBootstrapPreview(config.dependencyBootstrap ?? "off", {
88
+ ? await createDependencyBootstrapPreview(dependencyMode, {
118
89
  currentRoot: repository.currentRoot,
119
90
  repoRoot: repository.repoRoot,
120
91
  cargoBuildCommand: config.dependencyBuildCommand,
@@ -127,14 +98,12 @@ export function createPrCommand(dependencies = {}) {
127
98
  ...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, branchName),
128
99
  dryRun: true,
129
100
  };
130
- if (dryRunSyncDirs.length > 0)
131
- output.syncDirs = dryRunSyncDirs;
132
101
  if (dryRunDependencyBootstrap?.targets.length)
133
102
  output.dependencyBootstrap = dryRunDependencyBootstrap;
134
103
  options.stdout(`${JSON.stringify(output, null, 2)}\n`);
135
104
  }
136
105
  else {
137
- options.stdout(`Would create worktree at ${worktreePath} (branch: ${branchName})\n${dryRunSyncDirs.map(({ dir, bytes }) => `Would clone ${dir} (${formatBytes(bytes)})\n`).join("")}${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`);
106
+ options.stdout(`Would create worktree at ${worktreePath} (branch: ${branchName})\n${formatDependencyBootstrapPreview(dryRunDependencyBootstrap)}`);
138
107
  }
139
108
  return 0;
140
109
  }
@@ -158,45 +127,24 @@ export function createPrCommand(dependencies = {}) {
158
127
  ? ["worktree", "add", worktreePath, branchName]
159
128
  : ["worktree", "add", "-b", branchName, worktreePath, remoteRef];
160
129
  await execFileAsync("git", worktreeArgs, { cwd: repository.repoRoot });
161
- if (!dependencyBootstrapExplicit) {
162
- dependencyPolicy = await resolveDependencyBootstrapPolicy({
163
- currentRoot: repository.currentRoot,
164
- detectionRoot: worktreePath,
165
- repoRoot: repository.repoRoot,
166
- worktreePath,
167
- }, config, false, {
168
- dependencies,
169
- legacyInstallPromptConfigured: dependencies.promptForInstallChoice !== undefined,
170
- nonInteractive: !!options.json,
171
- stderr: options.stderr,
172
- });
173
- config = {
174
- ...config,
175
- dependencyBootstrap: dependencyPolicy.mode,
176
- };
177
- }
178
130
  const bootstrap = await bootstrapWorktree({
179
131
  branch: branchName,
180
- cloneDirectory: dependencies.cloneDir,
181
132
  config,
182
133
  currentRoot: repository.currentRoot,
183
134
  dependencyDetectionRoot: worktreePath,
184
- nonInteractive: !!options.json,
135
+ dependencyMode,
185
136
  repoRoot: repository.repoRoot,
186
137
  reporter: createBootstrapReporter(options.stderr, !!options.json),
187
- runCommand: dependencies.runInstallCommand,
138
+ runCommand: dependencies.runCommand,
188
139
  commandStdout: options.json ? () => undefined : options.stdout,
189
140
  commandStderr: options.json ? () => undefined : options.stderr,
190
141
  json: options.json,
191
142
  worktreePath,
192
- installDependencies: dependencies,
193
- dependencyBootstrapPolicy: dependencyPolicy,
194
143
  });
195
144
  if (!bootstrap.ready) {
196
145
  const details = {
197
146
  dependencyBootstrap: bootstrap.dependencyBootstrap,
198
147
  path: worktreePath,
199
- skipped: bootstrap.skippedDirs,
200
148
  syncFiles: bootstrap.syncFileFailures,
201
149
  };
202
150
  if (options.json) {
@@ -212,15 +160,7 @@ export function createPrCommand(dependencies = {}) {
212
160
  const output = {
213
161
  ...createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, branchName),
214
162
  };
215
- if (bootstrap.clonedDirs.length > 0) {
216
- output.cloned = bootstrap.clonedDirs.map(({ dir, ms }) => ({
217
- dir,
218
- ms,
219
- }));
220
- }
221
- if (bootstrap.skippedDirs.length > 0)
222
- output.skipped = bootstrap.skippedDirs;
223
- if (bootstrap.dependencyBootstrap.mode !== "off")
163
+ if (bootstrap.dependencyBootstrap.events.length > 0)
224
164
  output.dependencyBootstrap = bootstrap.dependencyBootstrap;
225
165
  options.stdout(`${JSON.stringify(output, null, 2)}\n`);
226
166
  }
@@ -2,7 +2,7 @@ import { KNOWN_GLOBAL_CONFIG_KEYS } from "./config.js";
2
2
  const TOP_LEVEL_COMMANDS = [
3
3
  {
4
4
  name: "new",
5
- description: "create a new branch or detached linked worktree and CoW-bootstrap configured directories",
5
+ description: "create a new branch or detached linked worktree",
6
6
  },
7
7
  {
8
8
  name: "done",
@@ -149,7 +149,7 @@ _gji_completion() {
149
149
 
150
150
  case "$command_name" in
151
151
  new)
152
- COMPREPLY=( $(compgen -W "--detached --from-current --no-fetch --take --copy --task --force --open --editor --dry-run --json --help" -- "$cur") )
152
+ COMPREPLY=( $(compgen -W "--detached --from-current --no-fetch --take --copy --task --force --open --editor --dry-run --no-install --json --help" -- "$cur") )
153
153
  ;;
154
154
  done)
155
155
  COMPREPLY=( $(compgen -W "--force --keep-branch --json --help" -- "$cur") )
@@ -187,7 +187,7 @@ _gji_completion() {
187
187
  COMPREPLY=( $(compgen -W "$(__gji_pr_targets) --select --help" -- "$cur") )
188
188
  fi
189
189
  else
190
- COMPREPLY=( $(compgen -W "--dry-run --json --help" -- "$cur") )
190
+ COMPREPLY=( $(compgen -W "--dry-run --no-install --json --help" -- "$cur") )
191
191
  fi
192
192
  ;;
193
193
  back)
@@ -395,6 +395,7 @@ complete -c gji -n '__fish_seen_subcommand_from new' -l force -d 'remove and rec
395
395
  complete -c gji -n '__fish_seen_subcommand_from new' -l open -d 'open the new worktree in an editor after creation'
396
396
  complete -c gji -n '__fish_seen_subcommand_from new' -l editor -r -d 'editor CLI to use with --open (code, cursor, zed, …)'
397
397
  complete -c gji -n '__fish_seen_subcommand_from new' -l dry-run -d 'show what would be created without executing any git commands or writing files'
398
+ complete -c gji -n '__fish_seen_subcommand_from new' -l no-install -d 'skip automatic dependency setup in the new worktree'
398
399
  complete -c gji -n '__fish_seen_subcommand_from new' -l json -d 'emit JSON on success or error instead of human-readable output'
399
400
 
400
401
  complete -c gji -n '__fish_seen_subcommand_from init' -l write -d 'write the integration to the shell config file'
@@ -410,6 +411,7 @@ complete -c gji -n '__fish_seen_subcommand_from completion' -a 'fish' -d 'shell'
410
411
  complete -c gji -n '__fish_seen_subcommand_from completion' -a 'zsh' -d 'shell'
411
412
 
412
413
  complete -c gji -n '__fish_seen_subcommand_from pr; and test (commandline -opc)[3] != open' -l dry-run -d 'show what would be created without executing any git commands or writing files'
414
+ complete -c gji -n '__fish_seen_subcommand_from pr; and test (commandline -opc)[3] != open' -l no-install -d 'skip automatic dependency setup in the PR worktree'
413
415
  complete -c gji -n '__fish_seen_subcommand_from pr; and test (commandline -opc)[3] != open' -l json -d 'emit JSON on success or error instead of human-readable output'
414
416
  complete -c gji -n '__fish_seen_subcommand_from pr' -a 'open' -d 'open a pull request in the default browser'
415
417
  complete -c gji -n '__gji_should_complete_pr_select' -l select -d 'choose a pull request from any linked worktree'
@@ -551,7 +553,7 @@ fi
551
553
 
552
554
  case "\${words[2]}" in
553
555
  new)
554
- _arguments '--detached[create a detached worktree without a branch]' '--from-current[base the new branch on the current worktree instead of the main worktree]' '--no-fetch[skip refreshing the remote default branch before creating the new branch]' '--take[move current uncommitted changes into the new worktree]' '--copy[copy current uncommitted changes instead of moving them (requires --take)]' '--task[record the purpose of the new worktree]:description:' '--force[remove and recreate the worktree if the target path already exists]' '--open[open the new worktree in an editor after creation]' '--editor[editor CLI to use with --open (code, cursor, zed, …)]:editor:' '--dry-run[show what would be created without executing any git commands or writing files]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
556
+ _arguments '--detached[create a detached worktree without a branch]' '--from-current[base the new branch on the current worktree instead of the main worktree]' '--no-fetch[skip refreshing the remote default branch before creating the new branch]' '--take[move current uncommitted changes into the new worktree]' '--copy[copy current uncommitted changes instead of moving them (requires --take)]' '--task[record the purpose of the new worktree]:description:' '--force[remove and recreate the worktree if the target path already exists]' '--open[open the new worktree in an editor after creation]' '--editor[editor CLI to use with --open (code, cursor, zed, …)]:editor:' '--dry-run[show what would be created without executing any git commands or writing files]' '--no-install[skip automatic dependency setup in the new worktree]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
555
557
  ;;
556
558
  done)
557
559
  _arguments '--force[remove dirty or unmerged worktrees without prompting]' '--keep-branch[remove the worktree but preserve its branch]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
@@ -578,7 +580,7 @@ case "\${words[2]}" in
578
580
  _arguments '--select[choose a pull request from any linked worktree]' '4:branch or PR number:->pr_targets'
579
581
  fi
580
582
  else
581
- _arguments '--dry-run[show what would be created without executing any git commands or writing files]' '--json[emit JSON on success or error instead of human-readable output]' '2:ref:(open)'
583
+ _arguments '--dry-run[show what would be created without executing any git commands or writing files]' '--no-install[skip automatic dependency setup in the PR worktree]' '--json[emit JSON on success or error instead of human-readable output]' '2:ref:(open)'
582
584
  fi
583
585
  ;;
584
586
  back)
@@ -0,0 +1,4 @@
1
+ import type { BootstrapExecutionContext, BootstrapTarget } from "./dependency-bootstrap.js";
2
+ export declare function validateUvRelocation(target: BootstrapTarget, context: BootstrapExecutionContext): Promise<void>;
3
+ export declare function validateUvInstallation(target: BootstrapTarget, context: BootstrapExecutionContext): Promise<void>;
4
+ export declare function validateUvStructure(target: BootstrapTarget, context: BootstrapExecutionContext): Promise<void>;
@@ -0,0 +1,210 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { isNotFoundError } from "./fs-utils.js";
7
+ const execFileAsync = promisify(execFile);
8
+ const UV_VALIDATION_MAX_ENTRIES = 10_000;
9
+ const UV_VALIDATION_MAX_FILE_BYTES = 1024 * 1024;
10
+ const UV_VALIDATION_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
11
+ function virtualEnvironmentPrefixes(text) {
12
+ const prefixes = [];
13
+ for (const line of text.split(/\r?\n/u)) {
14
+ if (!/\bVIRTUAL_ENV\b/u.test(line))
15
+ continue;
16
+ const quotedAssignment = line.match(/\bVIRTUAL_ENV(?:\s*=|\s+)\s*(?:"([^"]+)"|'([^']+)')/u);
17
+ const quotedPrefix = quotedAssignment?.[1] ?? quotedAssignment?.[2];
18
+ if (quotedPrefix && isAbsolute(quotedPrefix))
19
+ prefixes.push(quotedPrefix);
20
+ const unquoted = line.match(/\bVIRTUAL_ENV(?:\s*=|\s+)\s*([^\s"']+)/u)?.[1];
21
+ if (unquoted && isAbsolute(unquoted))
22
+ prefixes.push(unquoted);
23
+ }
24
+ return uniquePaths(prefixes);
25
+ }
26
+ function pythonPrefixFromShebang(text) {
27
+ const command = text
28
+ .match(/^#!\s*([^\r\n]+)/u)?.[1]
29
+ ?.trim()
30
+ .split(/\s+/u)[0];
31
+ if (!command || !isAbsolute(command))
32
+ return undefined;
33
+ if (!/^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(command.split(sep).at(-1) ?? "")) {
34
+ return undefined;
35
+ }
36
+ return dirname(dirname(command));
37
+ }
38
+ function pythonPrefixesFromText(text) {
39
+ const interpreters = [];
40
+ for (const match of text.matchAll(/["'](\/[^"']+)["']/gu)) {
41
+ if (match[1])
42
+ interpreters.push(match[1]);
43
+ }
44
+ for (const match of text.matchAll(/(?:^|\s)(\/[^\s"']+)/gmu)) {
45
+ if (match[1])
46
+ interpreters.push(match[1]);
47
+ }
48
+ return uniquePaths(interpreters.flatMap((interpreter) => {
49
+ const name = interpreter.split(sep).at(-1) ?? "";
50
+ return /^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(name)
51
+ ? [dirname(dirname(interpreter))]
52
+ : [];
53
+ }));
54
+ }
55
+ export async function validateUvRelocation(target, context) {
56
+ if (context.input === "preserve")
57
+ return;
58
+ if (!target.targetPath || !target.sourcePath) {
59
+ throw new Error("uv environment target is not project-local");
60
+ }
61
+ const scriptsDirectory = join(target.targetPath, process.platform === "win32" ? "Scripts" : "bin");
62
+ const interpreter = join(scriptsDirectory, process.platform === "win32" ? "python.exe" : "python");
63
+ const { stdout } = await execFileAsync(interpreter, [
64
+ "-c",
65
+ "import os, sys; print(os.path.realpath(sys.prefix))",
66
+ ]);
67
+ const [actualPrefix, expectedPrefix] = await Promise.all([
68
+ realpath(stdout.trim()),
69
+ realpath(target.targetPath),
70
+ ]);
71
+ if (actualPrefix !== expectedPrefix) {
72
+ throw new Error(`uv environment still points to its source prefix: ${actualPrefix}`);
73
+ }
74
+ const sourcePaths = uniquePaths([
75
+ target.sourcePath,
76
+ (await realpath(target.sourcePath).catch(() => undefined)) ?? "",
77
+ ]).filter(Boolean);
78
+ const entries = await readdir(scriptsDirectory, { withFileTypes: true });
79
+ assertSafeUvScriptEntries(entries);
80
+ const environmentName = basename(target.targetPath);
81
+ const acceptedPrefixes = new Set([
82
+ resolve(target.targetPath),
83
+ await realpath(target.targetPath),
84
+ ]);
85
+ let validatedBytes = 0;
86
+ for (const path of [
87
+ join(target.targetPath, "pyvenv.cfg"),
88
+ ...entries
89
+ .filter((entry) => entry.isFile())
90
+ .map((entry) => join(scriptsDirectory, entry.name)),
91
+ ]) {
92
+ const validated = await readBoundedUvTextFile(path, UV_VALIDATION_MAX_TOTAL_BYTES - validatedBytes);
93
+ if (!validated)
94
+ continue;
95
+ validatedBytes += validated.bytes;
96
+ if (validated.text === undefined)
97
+ continue;
98
+ const { text } = validated;
99
+ if (sourcePaths.some((sourcePath) => text.includes(sourcePath))) {
100
+ throw new Error(`uv environment contains a stale source path: ${path}`);
101
+ }
102
+ const shebangPrefix = pythonPrefixFromShebang(text);
103
+ if (shebangPrefix &&
104
+ basename(shebangPrefix) === environmentName &&
105
+ !acceptedPrefixes.has(resolve(shebangPrefix))) {
106
+ throw new Error(`uv launcher points outside its environment: ${path}`);
107
+ }
108
+ if (pythonPrefixesFromText(text)
109
+ .filter((prefix) => basename(prefix) === environmentName)
110
+ .some((prefix) => !acceptedPrefixes.has(resolve(prefix)))) {
111
+ throw new Error(`uv launcher points outside its environment: ${path}`);
112
+ }
113
+ if (virtualEnvironmentPrefixes(text).some((prefix) => !acceptedPrefixes.has(resolve(prefix)))) {
114
+ throw new Error(`uv activation script points outside its environment: ${path}`);
115
+ }
116
+ }
117
+ }
118
+ export async function validateUvInstallation(target, context) {
119
+ await validateUvStructure(target, context);
120
+ await validateUvRelocation(target, context);
121
+ }
122
+ export async function validateUvStructure(target, context) {
123
+ if (context.input === "preserve")
124
+ return;
125
+ if (!target.targetPath)
126
+ throw new Error("uv environment target is not project-local");
127
+ const targetStats = await lstat(target.targetPath).catch(() => undefined);
128
+ if (!targetStats)
129
+ return;
130
+ if (!targetStats.isDirectory() || targetStats.isSymbolicLink()) {
131
+ throw new Error("uv environment must be a real directory");
132
+ }
133
+ const configStats = await lstat(join(target.targetPath, "pyvenv.cfg")).catch(() => undefined);
134
+ if (configStats && (!configStats.isFile() || configStats.isSymbolicLink())) {
135
+ throw new Error("uv pyvenv.cfg must be a regular file");
136
+ }
137
+ const scriptsDirectory = join(target.targetPath, process.platform === "win32" ? "Scripts" : "bin");
138
+ const scriptsStats = await lstat(scriptsDirectory).catch(() => undefined);
139
+ if (!scriptsStats)
140
+ return;
141
+ if (!scriptsStats.isDirectory() || scriptsStats.isSymbolicLink()) {
142
+ throw new Error("uv scripts path must be a real directory");
143
+ }
144
+ assertSafeUvScriptEntries(await readdir(scriptsDirectory, { withFileTypes: true }));
145
+ }
146
+ function assertSafeUvScriptEntries(entries) {
147
+ if (entries.length > UV_VALIDATION_MAX_ENTRIES) {
148
+ throw new Error("uv scripts directory exceeds the validation entry limit");
149
+ }
150
+ for (const entry of entries) {
151
+ if (entry.isSymbolicLink() && !isUvInterpreterName(entry.name)) {
152
+ throw new Error(`uv script must not be a symbolic link: ${entry.name}`);
153
+ }
154
+ }
155
+ }
156
+ async function readBoundedUvTextFile(path, remainingBytes) {
157
+ let handle;
158
+ try {
159
+ handle = await open(path, constants.O_RDONLY |
160
+ (process.platform === "win32" ? 0 : constants.O_NOFOLLOW));
161
+ }
162
+ catch (error) {
163
+ if (isNotFoundError(error))
164
+ return undefined;
165
+ throw error;
166
+ }
167
+ try {
168
+ const stats = await handle.stat();
169
+ if (!stats.isFile())
170
+ return undefined;
171
+ if (stats.size > UV_VALIDATION_MAX_FILE_BYTES) {
172
+ throw new Error(`uv launcher exceeds the validation size limit: ${path}`);
173
+ }
174
+ if (stats.size > remainingBytes) {
175
+ throw new Error("uv launchers exceed the total validation size limit");
176
+ }
177
+ const readLimit = Math.min(UV_VALIDATION_MAX_FILE_BYTES, remainingBytes);
178
+ const contents = await readFilePrefix(handle, readLimit + 1);
179
+ if (contents.byteLength > UV_VALIDATION_MAX_FILE_BYTES) {
180
+ throw new Error(`uv launcher exceeds the validation size limit: ${path}`);
181
+ }
182
+ if (contents.byteLength > remainingBytes) {
183
+ throw new Error("uv launchers exceed the total validation size limit");
184
+ }
185
+ const text = contents.toString("utf8");
186
+ return Buffer.from(text, "utf8").equals(contents)
187
+ ? { bytes: contents.byteLength, text }
188
+ : { bytes: contents.byteLength };
189
+ }
190
+ finally {
191
+ await handle.close();
192
+ }
193
+ }
194
+ async function readFilePrefix(handle, maxBytes) {
195
+ const contents = Buffer.allocUnsafe(maxBytes);
196
+ let offset = 0;
197
+ while (offset < contents.byteLength) {
198
+ const { bytesRead } = await handle.read(contents, offset, contents.byteLength - offset, offset);
199
+ if (bytesRead === 0)
200
+ break;
201
+ offset += bytesRead;
202
+ }
203
+ return contents.subarray(0, offset);
204
+ }
205
+ function isUvInterpreterName(name) {
206
+ return /^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(name);
207
+ }
208
+ function uniquePaths(paths) {
209
+ return [...new Set(paths.filter((path) => Boolean(path)))];
210
+ }
@@ -1,35 +1,24 @@
1
1
  import type { EffectiveGjiConfig } from "./config.js";
2
- import { type BootstrapCommandRunner, type BootstrapEvent, type DependencyBootstrapReport, type DependencyBootstrapReporter } from "./dependency-bootstrap.js";
3
- import type { DependencyBootstrapPolicyResolution } from "./dependency-bootstrap-prompt.js";
4
- import { type CloneDirectory } from "./dir-clone.js";
5
- import { type InstallPromptDependencies } from "./install-prompt.js";
6
- import { type ClonedDirectory, type SyncDirectoryReporter } from "./sync-directories.js";
7
- export type { ClonedDirectory as ClonedDir } from "./sync-directories.js";
2
+ import { type BootstrapCommandRunner, type BootstrapEvent, type DependencyBootstrapMode, type DependencyBootstrapReport, type DependencyBootstrapReporter } from "./dependency-bootstrap.js";
8
3
  export interface WorktreeBootstrapOptions {
9
4
  branch: string;
10
- cloneDirectory?: CloneDirectory;
11
5
  config: EffectiveGjiConfig;
12
6
  currentRoot?: string;
13
7
  dependencyDetectionRoot?: string;
14
- installDependencies?: InstallPromptDependencies;
8
+ dependencyMode: DependencyBootstrapMode;
15
9
  runCommand?: BootstrapCommandRunner;
16
10
  commandStdout?: (chunk: string) => void;
17
11
  commandStderr?: (chunk: string) => void;
18
- dependencyBootstrapPolicy?: DependencyBootstrapPolicyResolution;
19
12
  json?: boolean;
20
- nonInteractive: boolean;
21
13
  repoRoot: string;
22
- reporter: SyncDirectoryReporter & DependencyBootstrapReporter;
14
+ reporter: DependencyBootstrapReporter & {
15
+ write: (chunk: string) => void;
16
+ };
23
17
  worktreePath: string;
24
18
  }
25
19
  export interface WorktreeBootstrapResult {
26
- clonedDirs: readonly ClonedDirectory[];
27
20
  dependencyBootstrap: DependencyBootstrapReport;
28
21
  ready: boolean;
29
22
  syncFileFailures: readonly BootstrapEvent[];
30
- skippedDirs: readonly {
31
- dir: string;
32
- reason: string;
33
- }[];
34
23
  }
35
24
  export declare function bootstrapWorktree(options: WorktreeBootstrapOptions): Promise<WorktreeBootstrapResult>;