@solaqua/gji 0.1.0-beta.9 → 0.2.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.
package/dist/pr.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
- import { dirname } from 'node:path';
2
+ import { basename, dirname } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
+ import { loadEffectiveConfig } from './config.js';
6
+ import { syncFiles } from './file-sync.js';
5
7
  import { pathExists, promptForPathConflict } from './conflict.js';
8
+ import { extractHooks, runHook } from './hooks.js';
9
+ import { isHeadless } from './headless.js';
10
+ import { maybeRunInstallPrompt } from './install-prompt.js';
6
11
  import { detectRepository, resolveWorktreePath } from './repo.js';
7
12
  import { writeShellOutput } from './shell-handoff.js';
8
13
  const execFileAsync = promisify(execFile);
@@ -23,14 +28,31 @@ export function createPrCommand(dependencies = {}) {
23
28
  return async function runPrCommand(options) {
24
29
  const prNumber = parsePrInput(options.number);
25
30
  if (!prNumber) {
26
- 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
+ }
27
38
  return 1;
28
39
  }
29
40
  const repository = await detectRepository(options.cwd);
41
+ const config = await loadEffectiveConfig(repository.repoRoot);
30
42
  const branchName = `pr/${prNumber}`;
31
43
  const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`;
32
44
  const worktreePath = resolveWorktreePath(repository.repoRoot, branchName);
33
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
+ }
54
+ return 1;
55
+ }
34
56
  const choice = await prompt(worktreePath);
35
57
  if (choice === 'reuse') {
36
58
  await writeOutput(worktreePath, options.stdout);
@@ -43,7 +65,13 @@ export function createPrCommand(dependencies = {}) {
43
65
  await execFileAsync('git', ['fetch', 'origin', `refs/pull/${prNumber}/head:${remoteRef}`], { cwd: repository.repoRoot });
44
66
  }
45
67
  catch {
46
- options.stderr(`Failed to fetch PR #${prNumber} from origin\n`);
68
+ const message = `Failed to fetch PR #${prNumber} from origin`;
69
+ if (options.json) {
70
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
71
+ }
72
+ else {
73
+ options.stderr(`${message}\n`);
74
+ }
47
75
  return 1;
48
76
  }
49
77
  await mkdir(dirname(worktreePath), { recursive: true });
@@ -52,7 +80,27 @@ export function createPrCommand(dependencies = {}) {
52
80
  ? ['worktree', 'add', worktreePath, branchName]
53
81
  : ['worktree', 'add', '-b', branchName, worktreePath, remoteRef];
54
82
  await execFileAsync('git', worktreeArgs, { cwd: repository.repoRoot });
55
- await writeOutput(worktreePath, options.stdout);
83
+ // Sync files from main worktree before afterCreate so synced files are available to install scripts.
84
+ const syncPatterns = Array.isArray(config.syncFiles)
85
+ ? config.syncFiles.filter((p) => typeof p === 'string')
86
+ : [];
87
+ for (const pattern of syncPatterns) {
88
+ try {
89
+ await syncFiles(repository.repoRoot, worktreePath, [pattern]);
90
+ }
91
+ catch (error) {
92
+ options.stderr(`Warning: failed to sync file "${pattern}": ${error instanceof Error ? error.message : String(error)}\n`);
93
+ }
94
+ }
95
+ await maybeRunInstallPrompt(worktreePath, repository.repoRoot, config, options.stderr, dependencies, !!options.json);
96
+ const hooks = extractHooks(config);
97
+ await runHook(hooks.afterCreate, worktreePath, { branch: branchName, path: worktreePath, repo: basename(repository.repoRoot) }, options.stderr);
98
+ if (options.json) {
99
+ options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath }, null, 2)}\n`);
100
+ }
101
+ else {
102
+ await writeOutput(worktreePath, options.stdout);
103
+ }
56
104
  return 0;
57
105
  };
58
106
  }
package/dist/remove.d.ts CHANGED
@@ -2,10 +2,14 @@ import type { WorktreeEntry } from './repo.js';
2
2
  export interface RemoveCommandOptions {
3
3
  branch?: string;
4
4
  cwd: string;
5
+ force?: boolean;
6
+ json?: boolean;
5
7
  stderr: (chunk: string) => void;
6
8
  stdout: (chunk: string) => void;
7
9
  }
8
10
  export interface RemoveCommandDependencies {
11
+ confirmForceDeleteBranch: (branch: string) => Promise<boolean>;
12
+ confirmForceRemoveWorktree: (worktreePath: string) => Promise<boolean>;
9
13
  confirmRemoval: (worktree: WorktreeEntry) => Promise<boolean>;
10
14
  promptForWorktree: (worktrees: WorktreeEntry[]) => Promise<string | null>;
11
15
  }
package/dist/remove.js CHANGED
@@ -1,14 +1,31 @@
1
+ import { basename } from 'node:path';
1
2
  import { confirm, isCancel, select } from '@clack/prompts';
2
- import { deleteBranch, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
3
+ import { loadEffectiveConfig } from './config.js';
4
+ import { extractHooks, runHook } from './hooks.js';
5
+ import { isHeadless } from './headless.js';
6
+ import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
7
+ import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree } from './worktree-prompts.js';
3
8
  import { writeShellOutput } from './shell-handoff.js';
4
9
  const REMOVE_OUTPUT_FILE_ENV = 'GJI_REMOVE_OUTPUT_FILE';
5
10
  export function createRemoveCommand(dependencies = {}) {
6
11
  const promptForWorktree = dependencies.promptForWorktree ?? defaultPromptForWorktree;
7
12
  const confirmRemoval = dependencies.confirmRemoval ?? defaultConfirmRemoval;
13
+ const confirmForceRemoveWorktree = dependencies.confirmForceRemoveWorktree ?? defaultConfirmForceRemoveWorktree;
14
+ const confirmForceDeleteBranch = dependencies.confirmForceDeleteBranch ?? defaultConfirmForceDeleteBranch;
8
15
  return async function runRemoveCommand(options) {
9
16
  const { linkedWorktrees, repository } = await loadLinkedWorktrees(options.cwd);
10
17
  if (linkedWorktrees.length === 0) {
11
- options.stderr('No linked worktrees to finish\n');
18
+ emitError(options, 'No linked worktrees to finish');
19
+ return 1;
20
+ }
21
+ if (!options.branch && (options.json || isHeadless())) {
22
+ const message = 'branch argument is required';
23
+ if (options.json) {
24
+ emitError(options, message);
25
+ }
26
+ else {
27
+ options.stderr(`gji remove: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
28
+ }
12
29
  return 1;
13
30
  }
14
31
  const selection = options.branch ?? (await promptForWorktree(linkedWorktrees));
@@ -18,18 +35,72 @@ export function createRemoveCommand(dependencies = {}) {
18
35
  }
19
36
  const worktree = linkedWorktrees.find((entry) => entry.branch === selection || entry.path === selection);
20
37
  if (!worktree) {
21
- options.stderr(`No linked worktree found for branch: ${selection}\n`);
38
+ emitError(options, `No linked worktree found for branch: ${selection}`);
22
39
  return 1;
23
40
  }
24
- if (!(await confirmRemoval(worktree))) {
41
+ if (!options.force && (options.json || isHeadless())) {
42
+ const message = '--force is required';
43
+ if (options.json) {
44
+ emitError(options, message);
45
+ }
46
+ else {
47
+ options.stderr(`gji remove: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
48
+ }
49
+ return 1;
50
+ }
51
+ if (!options.force && !(await confirmRemoval(worktree))) {
25
52
  options.stderr('Aborted\n');
26
53
  return 1;
27
54
  }
28
- await removeWorktree(repository.repoRoot, worktree.path);
55
+ const config = await loadEffectiveConfig(repository.repoRoot);
56
+ const hooks = extractHooks(config);
57
+ await runHook(hooks.beforeRemove, worktree.path, { branch: worktree.branch ?? undefined, path: worktree.path, repo: basename(repository.repoRoot) }, options.stderr);
58
+ try {
59
+ await removeWorktree(repository.repoRoot, worktree.path);
60
+ }
61
+ catch (error) {
62
+ if (!isWorktreeDirtyError(error)) {
63
+ throw error;
64
+ }
65
+ if (!options.force && !(await confirmForceRemoveWorktree(worktree.path))) {
66
+ options.stderr('Aborted\n');
67
+ return 1;
68
+ }
69
+ try {
70
+ await forceRemoveWorktree(repository.repoRoot, worktree.path);
71
+ }
72
+ catch (forceError) {
73
+ emitError(options, `Failed to remove worktree at ${worktree.path}: ${toMessage(forceError)}`);
74
+ return 1;
75
+ }
76
+ }
29
77
  if (worktree.branch) {
30
- await deleteBranch(repository.repoRoot, worktree.branch);
78
+ try {
79
+ await deleteBranch(repository.repoRoot, worktree.branch);
80
+ }
81
+ catch (error) {
82
+ if (!isBranchUnmergedError(error)) {
83
+ throw error;
84
+ }
85
+ if (options.force || (await confirmForceDeleteBranch(worktree.branch))) {
86
+ try {
87
+ await forceDeleteBranch(repository.repoRoot, worktree.branch);
88
+ }
89
+ catch (forceError) {
90
+ options.stderr(`Failed to delete branch ${worktree.branch}: ${toMessage(forceError)}\n`);
91
+ }
92
+ }
93
+ else {
94
+ options.stderr(`Branch ${worktree.branch} was not deleted (has unmerged commits)\n`);
95
+ }
96
+ }
97
+ }
98
+ if (options.json) {
99
+ options.stdout(`${JSON.stringify({ branch: worktree.branch, path: worktree.path, deleted: true }, null, 2)}\n`);
100
+ }
101
+ else {
102
+ await writeOutput(repository.repoRoot, options.stdout);
31
103
  }
32
- await writeOutput(repository.repoRoot, options.stdout);
33
104
  return 0;
34
105
  };
35
106
  }
@@ -59,3 +130,14 @@ async function defaultConfirmRemoval(worktree) {
59
130
  async function writeOutput(repoRoot, stdout) {
60
131
  await writeShellOutput(REMOVE_OUTPUT_FILE_ENV, repoRoot, stdout);
61
132
  }
133
+ function emitError(options, message) {
134
+ if (options.json) {
135
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
136
+ }
137
+ else {
138
+ options.stderr(`${message}\n`);
139
+ }
140
+ }
141
+ function toMessage(error) {
142
+ return error instanceof Error ? error.message : String(error);
143
+ }
@@ -5,4 +5,8 @@ export interface LinkedWorktreeContext {
5
5
  }
6
6
  export declare function loadLinkedWorktrees(cwd: string): Promise<LinkedWorktreeContext>;
7
7
  export declare function removeWorktree(repoRoot: string, worktreePath: string): Promise<void>;
8
+ export declare function forceRemoveWorktree(repoRoot: string, worktreePath: string): Promise<void>;
8
9
  export declare function deleteBranch(repoRoot: string, branch: string): Promise<void>;
10
+ export declare function forceDeleteBranch(repoRoot: string, branch: string): Promise<void>;
11
+ export declare function isWorktreeDirtyError(error: unknown): boolean;
12
+ export declare function isBranchUnmergedError(error: unknown): boolean;
@@ -2,6 +2,8 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { detectRepository, listWorktrees } from './repo.js';
4
4
  const execFileAsync = promisify(execFile);
5
+ // Force English output so error message string matching is locale-independent.
6
+ const GIT_ENV = { ...process.env, LC_ALL: 'C' };
5
7
  export async function loadLinkedWorktrees(cwd) {
6
8
  const repository = await detectRepository(cwd);
7
9
  const linkedWorktrees = (await listWorktrees(cwd)).filter((worktree) => worktree.path !== repository.repoRoot);
@@ -11,8 +13,23 @@ export async function loadLinkedWorktrees(cwd) {
11
13
  };
12
14
  }
13
15
  export async function removeWorktree(repoRoot, worktreePath) {
14
- await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoRoot });
16
+ await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoRoot, env: GIT_ENV });
17
+ }
18
+ export async function forceRemoveWorktree(repoRoot, worktreePath) {
19
+ await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot, env: GIT_ENV });
15
20
  }
16
21
  export async function deleteBranch(repoRoot, branch) {
17
- await execFileAsync('git', ['branch', '-d', branch], { cwd: repoRoot });
22
+ await execFileAsync('git', ['branch', '-d', branch], { cwd: repoRoot, env: GIT_ENV });
23
+ }
24
+ export async function forceDeleteBranch(repoRoot, branch) {
25
+ await execFileAsync('git', ['branch', '-D', branch], { cwd: repoRoot, env: GIT_ENV });
26
+ }
27
+ export function isWorktreeDirtyError(error) {
28
+ return hasStderr(error) && error.stderr.includes('contains modified or untracked files');
29
+ }
30
+ export function isBranchUnmergedError(error) {
31
+ return hasStderr(error) && error.stderr.includes('is not fully merged');
32
+ }
33
+ function hasStderr(error) {
34
+ return error instanceof Error && 'stderr' in error && typeof error.stderr === 'string';
18
35
  }
@@ -0,0 +1,2 @@
1
+ export declare function defaultConfirmForceRemoveWorktree(worktreePath: string): Promise<boolean>;
2
+ export declare function defaultConfirmForceDeleteBranch(branch: string): Promise<boolean>;
@@ -0,0 +1,19 @@
1
+ import { confirm, isCancel } from '@clack/prompts';
2
+ export async function defaultConfirmForceRemoveWorktree(worktreePath) {
3
+ const choice = await confirm({
4
+ active: 'Yes',
5
+ inactive: 'No',
6
+ initialValue: false,
7
+ message: `Worktree at ${worktreePath} has untracked or modified files. Force remove?`,
8
+ });
9
+ return !isCancel(choice) && choice;
10
+ }
11
+ export async function defaultConfirmForceDeleteBranch(branch) {
12
+ const choice = await confirm({
13
+ active: 'Yes',
14
+ inactive: 'No',
15
+ initialValue: false,
16
+ message: `Branch ${branch} has unmerged commits. Force delete?`,
17
+ });
18
+ return !isCancel(choice) && choice;
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solaqua/gji",
3
- "version": "0.1.0-beta.9",
3
+ "version": "0.2.0",
4
4
  "description": "Git worktree CLI for fast context switching.",
5
5
  "license": "MIT",
6
6
  "author": "sjquant",