@solaqua/gji 0.1.0 → 0.2.1

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.
package/dist/config.js CHANGED
@@ -25,6 +25,20 @@ export async function loadEffectiveConfig(root, home = homedir()) {
25
25
  export async function loadGlobalConfig(home = homedir()) {
26
26
  return loadConfigFile(GLOBAL_CONFIG_FILE_PATH(home));
27
27
  }
28
+ export async function saveLocalConfig(root, config) {
29
+ const path = join(root, CONFIG_FILE_NAME);
30
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
31
+ return path;
32
+ }
33
+ export async function updateLocalConfigKey(root, key, value) {
34
+ const loaded = await loadConfig(root);
35
+ const nextConfig = {
36
+ ...loaded.config,
37
+ [key]: value,
38
+ };
39
+ await saveLocalConfig(root, nextConfig);
40
+ return nextConfig;
41
+ }
28
42
  export async function saveGlobalConfig(config, home = homedir()) {
29
43
  const path = GLOBAL_CONFIG_FILE_PATH(home);
30
44
  await mkdir(dirname(path), { recursive: true });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Copies files matching each pattern (relative to mainRoot) into the equivalent
3
+ * relative path under targetPath, creating parent directories as needed.
4
+ *
5
+ * - Non-destructive: silently skips if the target file already exists.
6
+ * - Silently skips if the source file does not exist.
7
+ * - Rejects patterns that are absolute paths or contain `..` segments.
8
+ */
9
+ export declare function syncFiles(mainRoot: string, targetPath: string, patterns: string[]): Promise<void>;
@@ -0,0 +1,52 @@
1
+ import { copyFile, mkdir, stat } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, normalize } from 'node:path';
3
+ /**
4
+ * Copies files matching each pattern (relative to mainRoot) into the equivalent
5
+ * relative path under targetPath, creating parent directories as needed.
6
+ *
7
+ * - Non-destructive: silently skips if the target file already exists.
8
+ * - Silently skips if the source file does not exist.
9
+ * - Rejects patterns that are absolute paths or contain `..` segments.
10
+ */
11
+ export async function syncFiles(mainRoot, targetPath, patterns) {
12
+ for (const pattern of patterns) {
13
+ if (isAbsolute(pattern)) {
14
+ throw new Error(`syncFiles: pattern must be a relative path, got: ${pattern}`);
15
+ }
16
+ const normalized = normalize(pattern);
17
+ if (normalized.startsWith('..')) {
18
+ throw new Error(`syncFiles: pattern must not contain '..' segments, got: ${pattern}`);
19
+ }
20
+ const sourcePath = join(mainRoot, normalized);
21
+ const destPath = join(targetPath, normalized);
22
+ // Skip silently if source does not exist
23
+ const sourceExists = await fileExists(sourcePath);
24
+ if (!sourceExists) {
25
+ continue;
26
+ }
27
+ // Skip silently if target already exists
28
+ const destExists = await fileExists(destPath);
29
+ if (destExists) {
30
+ continue;
31
+ }
32
+ await mkdir(dirname(destPath), { recursive: true });
33
+ await copyFile(sourcePath, destPath);
34
+ }
35
+ }
36
+ async function fileExists(path) {
37
+ try {
38
+ await stat(path);
39
+ return true;
40
+ }
41
+ catch (error) {
42
+ if (isNotFoundError(error)) {
43
+ return false;
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+ function isNotFoundError(error) {
49
+ return (error instanceof Error &&
50
+ 'code' in error &&
51
+ error.code === 'ENOENT');
52
+ }
package/dist/go.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { basename } from 'node:path';
2
2
  import { isCancel, select } from '@clack/prompts';
3
3
  import { loadEffectiveConfig } from './config.js';
4
+ import { isHeadless } from './headless.js';
4
5
  import { extractHooks, runHook } from './hooks.js';
5
6
  import { detectRepository, listWorktrees } from './repo.js';
6
7
  import { writeShellOutput } from './shell-handoff.js';
@@ -12,14 +13,22 @@ export function createGoCommand(dependencies = {}) {
12
13
  listWorktrees(options.cwd),
13
14
  detectRepository(options.cwd),
14
15
  ]);
16
+ if (!options.branch && isHeadless()) {
17
+ options.stderr('gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n');
18
+ return 1;
19
+ }
15
20
  const prompted = options.branch ? null : await prompt(worktrees);
16
21
  const resolvedPath = options.branch
17
22
  ? worktrees.find((entry) => entry.branch === options.branch)?.path
18
23
  : prompted ?? undefined;
19
24
  if (!resolvedPath) {
20
- options.stderr(options.branch
21
- ? `No worktree found for branch: ${options.branch}\n`
22
- : 'Aborted\n');
25
+ if (options.branch) {
26
+ options.stderr(`No worktree found for branch: ${options.branch}\n`);
27
+ options.stderr(`Hint: Use 'gji ls' to see available worktrees\n`);
28
+ }
29
+ else {
30
+ options.stderr('Aborted\n');
31
+ }
23
32
  return 1;
24
33
  }
25
34
  const chosenWorktree = worktrees.find((w) => w.path === resolvedPath);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Returns true when running in a non-interactive (headless) environment.
3
+ * Set GJI_NO_TUI=1 to disable all interactive prompts.
4
+ * Commands that would otherwise hang waiting for input must fail fast instead.
5
+ */
6
+ export declare function isHeadless(): boolean;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns true when running in a non-interactive (headless) environment.
3
+ * Set GJI_NO_TUI=1 to disable all interactive prompts.
4
+ * Commands that would otherwise hang waiting for input must fail fast instead.
5
+ */
6
+ export function isHeadless() {
7
+ return process.env.GJI_NO_TUI === '1';
8
+ }
@@ -0,0 +1,10 @@
1
+ import { type GjiConfig } from './config.js';
2
+ import { type PackageManager } from './package-manager.js';
3
+ export type InstallChoice = 'yes' | 'no' | 'always' | 'never';
4
+ export interface InstallPromptDependencies {
5
+ detectInstallPackageManager?: (root: string) => Promise<PackageManager | null>;
6
+ promptForInstallChoice?: (pm: PackageManager) => Promise<InstallChoice | null>;
7
+ runInstallCommand?: (command: string, cwd: string, stderr: (chunk: string) => void) => Promise<void>;
8
+ writeConfigKey?: (root: string, key: string, value: unknown) => Promise<void>;
9
+ }
10
+ export declare function maybeRunInstallPrompt(worktreePath: string, repoRoot: string, config: GjiConfig, stderr: (chunk: string) => void, dependencies?: InstallPromptDependencies, nonInteractive?: boolean): Promise<void>;
@@ -0,0 +1,99 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { isCancel, select } from '@clack/prompts';
3
+ import { loadConfig, updateLocalConfigKey } from './config.js';
4
+ import { isHeadless } from './headless.js';
5
+ import { detectPackageManager } from './package-manager.js';
6
+ export async function maybeRunInstallPrompt(worktreePath, repoRoot, config, stderr, dependencies = {}, nonInteractive = false) {
7
+ // Skip in non-interactive mode — no prompt can be shown.
8
+ if (isHeadless() || nonInteractive) {
9
+ return;
10
+ }
11
+ // Skip if afterCreate hook is already configured in effective config.
12
+ const hooks = isPlainObject(config.hooks) ? config.hooks : null;
13
+ if (typeof hooks?.afterCreate === 'string' && hooks.afterCreate.length > 0) {
14
+ return;
15
+ }
16
+ // Skip if user has permanently opted out of install prompts.
17
+ if (config.skipInstallPrompt === true) {
18
+ return;
19
+ }
20
+ const detect = dependencies.detectInstallPackageManager ?? detectPackageManager;
21
+ const pm = await detect(worktreePath);
22
+ if (!pm) {
23
+ return;
24
+ }
25
+ const prompt = dependencies.promptForInstallChoice ?? defaultPromptForInstallChoice;
26
+ const choice = await prompt(pm);
27
+ if (!choice || choice === 'no') {
28
+ return;
29
+ }
30
+ if (choice === 'yes' || choice === 'always') {
31
+ const runner = dependencies.runInstallCommand ?? defaultRunInstallCommand;
32
+ try {
33
+ await runner(pm.installCommand, worktreePath, stderr);
34
+ }
35
+ catch (error) {
36
+ stderr(`gji: install command failed: ${error instanceof Error ? error.message : String(error)}\n`);
37
+ }
38
+ }
39
+ const writeKey = dependencies.writeConfigKey ?? defaultWriteConfigKey;
40
+ if (choice === 'always') {
41
+ try {
42
+ // Read local config hooks to deep-merge so other hook keys (e.g. afterEnter) are preserved.
43
+ const { config: localConfig } = await loadConfig(repoRoot);
44
+ const existingLocalHooks = isPlainObject(localConfig.hooks) ? localConfig.hooks : {};
45
+ await writeKey(repoRoot, 'hooks', { ...existingLocalHooks, afterCreate: pm.installCommand });
46
+ }
47
+ catch (error) {
48
+ stderr(`gji: failed to save config: ${error instanceof Error ? error.message : String(error)}\n`);
49
+ }
50
+ }
51
+ if (choice === 'never') {
52
+ try {
53
+ await writeKey(repoRoot, 'skipInstallPrompt', true);
54
+ }
55
+ catch (error) {
56
+ stderr(`gji: failed to save config: ${error instanceof Error ? error.message : String(error)}\n`);
57
+ }
58
+ }
59
+ }
60
+ async function defaultRunInstallCommand(command, cwd, stderr) {
61
+ await new Promise((resolve, reject) => {
62
+ const child = spawn(command, { cwd, shell: true, stdio: ['ignore', 'inherit', 'pipe'] });
63
+ child.stderr.on('data', (chunk) => {
64
+ stderr(chunk.toString());
65
+ });
66
+ child.on('close', (code) => {
67
+ if (code !== 0) {
68
+ reject(new Error(`exited with code ${code}`));
69
+ }
70
+ else {
71
+ resolve();
72
+ }
73
+ });
74
+ child.on('error', (err) => {
75
+ reject(err);
76
+ });
77
+ });
78
+ }
79
+ async function defaultWriteConfigKey(root, key, value) {
80
+ await updateLocalConfigKey(root, key, value);
81
+ }
82
+ async function defaultPromptForInstallChoice(pm) {
83
+ const choice = await select({
84
+ message: `Run \`${pm.installCommand}\` in the new worktree?`,
85
+ options: [
86
+ { value: 'yes', label: 'Yes', hint: 'run once' },
87
+ { value: 'no', label: 'No', hint: 'skip this time' },
88
+ { value: 'always', label: 'Always', hint: 'save as afterCreate hook' },
89
+ { value: 'never', label: 'Never', hint: 'disable this prompt for this repo' },
90
+ ],
91
+ });
92
+ if (isCancel(choice)) {
93
+ return null;
94
+ }
95
+ return choice;
96
+ }
97
+ function isPlainObject(value) {
98
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
99
+ }
package/dist/new.d.ts CHANGED
@@ -1,13 +1,16 @@
1
+ import { type InstallPromptDependencies } from './install-prompt.js';
1
2
  import { type PathConflictChoice } from './conflict.js';
2
3
  export type { PathConflictChoice };
3
4
  export interface NewCommandOptions {
4
5
  branch?: string;
5
6
  cwd: string;
6
7
  detached?: boolean;
8
+ dryRun?: boolean;
9
+ json?: boolean;
7
10
  stderr: (chunk: string) => void;
8
11
  stdout: (chunk: string) => void;
9
12
  }
10
- export interface NewCommandDependencies {
13
+ export interface NewCommandDependencies extends InstallPromptDependencies {
11
14
  createBranchPlaceholder: () => string;
12
15
  promptForBranch: (placeholder: string) => Promise<string | null>;
13
16
  promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
package/dist/new.js CHANGED
@@ -4,7 +4,10 @@ import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { isCancel, text } from '@clack/prompts';
6
6
  import { loadEffectiveConfig } from './config.js';
7
+ import { syncFiles } from './file-sync.js';
7
8
  import { extractHooks, runHook } from './hooks.js';
9
+ import { isHeadless } from './headless.js';
10
+ import { maybeRunInstallPrompt } from './install-prompt.js';
8
11
  import { pathExists, promptForPathConflict } from './conflict.js';
9
12
  import { detectRepository, resolveWorktreePath } from './repo.js';
10
13
  import { writeShellOutput } from './shell-handoff.js';
@@ -18,11 +21,26 @@ export function createNewCommand(dependencies = {}) {
18
21
  const repository = await detectRepository(options.cwd);
19
22
  const config = await loadEffectiveConfig(repository.repoRoot);
20
23
  const usesGeneratedDetachedName = options.detached && options.branch === undefined;
24
+ if (!options.detached && !options.branch && (options.json || isHeadless())) {
25
+ const message = 'branch argument is required';
26
+ if (options.json) {
27
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
28
+ }
29
+ else {
30
+ options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
31
+ }
32
+ return 1;
33
+ }
21
34
  const rawBranch = options.detached
22
35
  ? options.branch ?? createBranchPlaceholder()
23
36
  : options.branch ?? await promptForBranch(createBranchPlaceholder());
24
37
  if (!rawBranch) {
25
- options.stderr('Aborted\n');
38
+ if (options.json) {
39
+ options.stderr(`${JSON.stringify({ error: 'Aborted' }, null, 2)}\n`);
40
+ }
41
+ else {
42
+ options.stderr('Aborted\n');
43
+ }
26
44
  return 1;
27
45
  }
28
46
  const worktreeName = options.detached
@@ -32,6 +50,17 @@ export function createNewCommand(dependencies = {}) {
32
50
  ? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName)
33
51
  : resolveWorktreePath(repository.repoRoot, worktreeName);
34
52
  if (!usesGeneratedDetachedName && await pathExists(worktreePath)) {
53
+ if (options.json || isHeadless()) {
54
+ const message = `target worktree path already exists: ${worktreePath}`;
55
+ if (options.json) {
56
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
57
+ }
58
+ else {
59
+ options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
60
+ options.stderr(`Hint: Use 'gji remove ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
61
+ }
62
+ return 1;
63
+ }
35
64
  const choice = await prompt(worktreePath);
36
65
  if (choice === 'reuse') {
37
66
  await writeOutput(worktreePath, options.stdout);
@@ -40,6 +69,15 @@ export function createNewCommand(dependencies = {}) {
40
69
  options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
41
70
  return 1;
42
71
  }
72
+ if (options.dryRun) {
73
+ if (options.json) {
74
+ options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath, dryRun: true }, null, 2)}\n`);
75
+ }
76
+ else {
77
+ options.stdout(`Would create worktree at ${worktreePath} (branch: ${worktreeName})\n`);
78
+ }
79
+ return 0;
80
+ }
43
81
  await mkdir(dirname(worktreePath), { recursive: true });
44
82
  const gitArgs = options.detached
45
83
  ? ['worktree', 'add', '--detach', worktreePath]
@@ -47,9 +85,27 @@ export function createNewCommand(dependencies = {}) {
47
85
  ? ['worktree', 'add', worktreePath, worktreeName]
48
86
  : ['worktree', 'add', '-b', worktreeName, worktreePath];
49
87
  await execFileAsync('git', gitArgs, { cwd: repository.repoRoot });
88
+ // Sync files from main worktree before afterCreate so synced files are available to install scripts.
89
+ const syncPatterns = Array.isArray(config.syncFiles)
90
+ ? config.syncFiles.filter((p) => typeof p === 'string')
91
+ : [];
92
+ for (const pattern of syncPatterns) {
93
+ try {
94
+ await syncFiles(repository.repoRoot, worktreePath, [pattern]);
95
+ }
96
+ catch (error) {
97
+ options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
98
+ }
99
+ }
100
+ await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
50
101
  const hooks = extractHooks(config);
51
102
  await runHook(hooks.afterCreate, worktreePath, { branch: worktreeName, path: worktreePath, repo: basename(repository.repoRoot) }, options.stderr);
52
- await writeOutput(worktreePath, options.stdout);
103
+ if (options.json) {
104
+ options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath }, null, 2)}\n`);
105
+ }
106
+ else {
107
+ await writeOutput(worktreePath, options.stdout);
108
+ }
53
109
  return 0;
54
110
  };
55
111
  }
@@ -0,0 +1,5 @@
1
+ export interface PackageManager {
2
+ name: string;
3
+ installCommand: string;
4
+ }
5
+ export declare function detectPackageManager(repoRoot: string): Promise<PackageManager | null>;
@@ -0,0 +1,108 @@
1
+ import { access, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const ENTRIES = [
4
+ // JavaScript / TypeScript
5
+ { name: 'pnpm', signals: ['pnpm-lock.yaml'], command: 'pnpm install' },
6
+ { name: 'yarn', signals: ['yarn.lock'], command: 'yarn install' },
7
+ { name: 'bun', signals: ['bun.lockb'], command: 'bun install' },
8
+ { name: 'npm', signals: ['package-lock.json'], command: 'npm install' },
9
+ { name: 'deno', signals: ['deno.json', 'deno.jsonc'], command: 'deno cache' },
10
+ // Python
11
+ { name: 'poetry', signals: ['poetry.lock'], command: 'poetry install' },
12
+ { name: 'uv', signals: ['uv.lock'], command: 'uv sync' },
13
+ { name: 'pipenv', signals: ['Pipfile.lock'], command: 'pipenv install' },
14
+ { name: 'pdm', signals: ['pdm.lock'], command: 'pdm install' },
15
+ { name: 'conda-lock', signals: ['conda-lock.yml'], command: 'conda-lock install' },
16
+ { name: 'conda', signals: ['environment.yml'], command: 'conda env update --file environment.yml' },
17
+ // R
18
+ { name: 'renv', signals: ['renv.lock'], command: "Rscript -e 'renv::restore()'" },
19
+ // Rust
20
+ { name: 'cargo', signals: ['Cargo.lock'], command: 'cargo build' },
21
+ // Go
22
+ { name: 'go', signals: ['go.sum'], command: 'go mod download' },
23
+ // Ruby
24
+ { name: 'bundler', signals: ['Gemfile.lock'], command: 'bundle install' },
25
+ // PHP
26
+ { name: 'composer', signals: ['composer.lock'], command: 'composer install' },
27
+ // Elixir / Erlang
28
+ { name: 'mix', signals: ['mix.lock'], command: 'mix deps.get' },
29
+ { name: 'rebar3', signals: ['rebar.lock'], command: 'rebar3 deps' },
30
+ // Dart / Flutter
31
+ { name: 'dart', signals: ['pubspec.lock'], command: 'dart pub get' },
32
+ // Java / Kotlin / Scala
33
+ { name: 'maven', signals: ['pom.xml'], command: 'mvn install' },
34
+ { name: 'gradle', signals: ['gradlew'], command: './gradlew build' },
35
+ { name: 'gradle', signals: ['build.gradle', 'build.gradle.kts'], command: 'gradle build' },
36
+ { name: 'sbt', signals: ['build.sbt'], command: 'sbt compile' },
37
+ // .NET (C# / F# / VB)
38
+ { name: 'dotnet', signals: ['*.sln', '*.csproj', '*.fsproj', '*.vbproj'], command: 'dotnet restore', glob: true },
39
+ // Swift
40
+ { name: 'swift', signals: ['Package.swift'], command: 'swift package resolve' },
41
+ // Haskell
42
+ { name: 'stack', signals: ['stack.yaml'], command: 'stack build' },
43
+ { name: 'cabal', signals: ['cabal.project'], command: 'cabal install --only-dependencies' },
44
+ { name: 'cabal', signals: ['*.cabal'], command: 'cabal install --only-dependencies', glob: true },
45
+ // Clojure
46
+ { name: 'clojure', signals: ['deps.edn'], command: 'clojure -P' },
47
+ { name: 'leiningen', signals: ['project.clj'], command: 'lein deps' },
48
+ // OCaml
49
+ { name: 'dune', signals: ['dune-project'], command: 'dune build' },
50
+ // Julia
51
+ { name: 'julia', signals: ['Manifest.toml'], command: "julia --project -e 'using Pkg; Pkg.instantiate()'" },
52
+ // Nim
53
+ { name: 'nimble', signals: ['*.nimble'], command: 'nimble install', glob: true },
54
+ // Crystal
55
+ { name: 'shards', signals: ['shard.yml'], command: 'shards install' },
56
+ // Perl
57
+ { name: 'cpanm', signals: ['cpanfile'], command: 'cpanm --installdeps .' },
58
+ // Zig
59
+ { name: 'zig', signals: ['build.zig.zon'], command: 'zig build' },
60
+ // C / C++
61
+ { name: 'vcpkg', signals: ['vcpkg.json'], command: 'vcpkg install' },
62
+ { name: 'conan', signals: ['conanfile.py', 'conanfile.txt'], command: 'conan install .' },
63
+ // Nix
64
+ { name: 'nix', signals: ['flake.nix'], command: 'nix develop' },
65
+ { name: 'nix-shell', signals: ['shell.nix'], command: 'nix-shell' },
66
+ // Terraform / OpenTofu
67
+ { name: 'terraform', signals: ['terraform.lock.hcl'], command: 'terraform init' },
68
+ ];
69
+ export async function detectPackageManager(repoRoot) {
70
+ for (const entry of ENTRIES) {
71
+ const matched = entry.glob
72
+ ? await matchesGlob(repoRoot, entry.signals)
73
+ : await matchesExact(repoRoot, entry.signals);
74
+ if (matched) {
75
+ return { name: entry.name, installCommand: entry.command };
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ async function matchesExact(repoRoot, signals) {
81
+ for (const signal of signals) {
82
+ try {
83
+ await access(join(repoRoot, signal));
84
+ return true;
85
+ }
86
+ catch {
87
+ // file not found, try next signal
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+ async function matchesGlob(repoRoot, patterns) {
93
+ let files;
94
+ try {
95
+ files = await readdir(repoRoot);
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ const regexes = patterns.map(patternToRegex);
101
+ return files.some((file) => regexes.some((re) => re.test(file)));
102
+ }
103
+ function patternToRegex(pattern) {
104
+ const escaped = pattern
105
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
106
+ .replace(/\*/g, '[^/]*');
107
+ return new RegExp(`^${escaped}$`);
108
+ }
package/dist/pr.d.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { type PathConflictChoice } from './conflict.js';
2
+ import { type InstallPromptDependencies } from './install-prompt.js';
2
3
  export type { PathConflictChoice };
3
4
  export interface PrCommandOptions {
4
5
  cwd: string;
6
+ dryRun?: boolean;
7
+ json?: boolean;
5
8
  number: string;
6
9
  stderr: (chunk: string) => void;
7
10
  stdout: (chunk: string) => void;
8
11
  }
9
- export interface PrCommandDependencies {
12
+ export interface PrCommandDependencies extends InstallPromptDependencies {
10
13
  promptForPathConflict: (path: string) => Promise<PathConflictChoice>;
11
14
  }
12
15
  export declare function parsePrInput(input: string): string | null;
package/dist/pr.js CHANGED
@@ -3,8 +3,11 @@ import { basename, dirname } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { loadEffectiveConfig } from './config.js';
6
+ import { syncFiles } from './file-sync.js';
6
7
  import { pathExists, promptForPathConflict } from './conflict.js';
7
8
  import { extractHooks, runHook } from './hooks.js';
9
+ import { isHeadless } from './headless.js';
10
+ import { maybeRunInstallPrompt } from './install-prompt.js';
8
11
  import { detectRepository, resolveWorktreePath } from './repo.js';
9
12
  import { writeShellOutput } from './shell-handoff.js';
10
13
  const execFileAsync = promisify(execFile);
@@ -25,14 +28,32 @@ export function createPrCommand(dependencies = {}) {
25
28
  return async function runPrCommand(options) {
26
29
  const prNumber = parsePrInput(options.number);
27
30
  if (!prNumber) {
28
- options.stderr(`Invalid PR reference: ${options.number}\n`);
31
+ const message = `Invalid PR reference: ${options.number}`;
32
+ if (options.json) {
33
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
34
+ }
35
+ else {
36
+ options.stderr(`${message}\n`);
37
+ }
29
38
  return 1;
30
39
  }
31
40
  const repository = await detectRepository(options.cwd);
41
+ const config = await loadEffectiveConfig(repository.repoRoot);
32
42
  const branchName = `pr/${prNumber}`;
33
43
  const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`;
34
44
  const worktreePath = resolveWorktreePath(repository.repoRoot, branchName);
35
45
  if (await pathExists(worktreePath)) {
46
+ if (options.json || isHeadless()) {
47
+ const message = `target worktree path already exists: ${worktreePath}`;
48
+ if (options.json) {
49
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
50
+ }
51
+ else {
52
+ options.stderr(`gji pr: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
53
+ options.stderr(`Hint: Use 'gji remove pr/${prNumber}' or 'gji clean' to remove the existing worktree\n`);
54
+ }
55
+ return 1;
56
+ }
36
57
  const choice = await prompt(worktreePath);
37
58
  if (choice === 'reuse') {
38
59
  await writeOutput(worktreePath, options.stdout);
@@ -41,11 +62,27 @@ export function createPrCommand(dependencies = {}) {
41
62
  options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
42
63
  return 1;
43
64
  }
65
+ if (options.dryRun) {
66
+ if (options.json) {
67
+ options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath, dryRun: true }, null, 2)}\n`);
68
+ }
69
+ else {
70
+ options.stdout(`Would create worktree at ${worktreePath} (branch: ${branchName})\n`);
71
+ }
72
+ return 0;
73
+ }
44
74
  try {
45
75
  await execFileAsync('git', ['fetch', 'origin', `refs/pull/${prNumber}/head:${remoteRef}`], { cwd: repository.repoRoot });
46
76
  }
47
77
  catch {
48
- options.stderr(`Failed to fetch PR #${prNumber} from origin\n`);
78
+ const message = `Failed to fetch PR #${prNumber} from origin`;
79
+ if (options.json) {
80
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
81
+ }
82
+ else {
83
+ options.stderr(`${message}\n`);
84
+ options.stderr(`Hint: Verify the remote is reachable: git fetch origin\n`);
85
+ }
49
86
  return 1;
50
87
  }
51
88
  await mkdir(dirname(worktreePath), { recursive: true });
@@ -54,10 +91,27 @@ export function createPrCommand(dependencies = {}) {
54
91
  ? ['worktree', 'add', worktreePath, branchName]
55
92
  : ['worktree', 'add', '-b', branchName, worktreePath, remoteRef];
56
93
  await execFileAsync('git', worktreeArgs, { cwd: repository.repoRoot });
57
- const config = await loadEffectiveConfig(repository.repoRoot);
94
+ // Sync files from main worktree before afterCreate so synced files are available to install scripts.
95
+ const syncPatterns = Array.isArray(config.syncFiles)
96
+ ? config.syncFiles.filter((p) => typeof p === 'string')
97
+ : [];
98
+ for (const pattern of syncPatterns) {
99
+ try {
100
+ await syncFiles(repository.repoRoot, worktreePath, [pattern]);
101
+ }
102
+ catch (error) {
103
+ options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
104
+ }
105
+ }
106
+ await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
58
107
  const hooks = extractHooks(config);
59
108
  await runHook(hooks.afterCreate, worktreePath, { branch: branchName, path: worktreePath, repo: basename(repository.repoRoot) }, options.stderr);
60
- await writeOutput(worktreePath, options.stdout);
109
+ if (options.json) {
110
+ options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath }, null, 2)}\n`);
111
+ }
112
+ else {
113
+ await writeOutput(worktreePath, options.stdout);
114
+ }
61
115
  return 0;
62
116
  };
63
117
  }
package/dist/remove.d.ts CHANGED
@@ -2,7 +2,9 @@ import type { WorktreeEntry } from './repo.js';
2
2
  export interface RemoveCommandOptions {
3
3
  branch?: string;
4
4
  cwd: string;
5
+ dryRun?: boolean;
5
6
  force?: boolean;
7
+ json?: boolean;
6
8
  stderr: (chunk: string) => void;
7
9
  stdout: (chunk: string) => void;
8
10
  }