@solaqua/gji 0.2.1 → 0.2.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.
package/README.md CHANGED
@@ -93,6 +93,8 @@ gji sync --all # rebase every worktree
93
93
 
94
94
  gji clean # interactive bulk cleanup
95
95
  gji remove feature/auth-refactor # remove one worktree and its branch
96
+
97
+ gji trigger-hook afterCreate # re-run setup in the current worktree
96
98
  ```
97
99
 
98
100
  ## Shell setup
@@ -139,6 +141,7 @@ path=$(gji root --print)
139
141
  | `gji sync [--all]` | fetch and rebase worktrees onto default branch |
140
142
  | `gji clean [--force] [--json]` | interactively prune stale worktrees |
141
143
  | `gji remove [branch] [--force] [--json]` | remove a worktree and its branch |
144
+ | `gji trigger-hook <hook>` | run a hook in the current worktree |
142
145
  | `gji config [get\|set\|unset] [key] [value]` | manage global defaults |
143
146
  | `gji init [shell]` | print or install shell integration |
144
147
 
@@ -158,7 +161,9 @@ No setup required. Optional config lives in:
158
161
  | `syncDefaultBranch` | branch to rebase onto (default: remote `HEAD`) |
159
162
  | `syncFiles` | files to copy from main worktree into each new worktree |
160
163
  | `skipInstallPrompt` | `true` to disable the auto-install prompt permanently |
164
+ | `installSaveTarget` | `"local"` or `"global"` — where **Always**/**Never** choices are persisted (default: `"local"`); set once during `gji init --write` |
161
165
  | `hooks` | lifecycle scripts (see [Hooks](#hooks)) |
166
+ | `repos` | per-repo overrides inside the global config (see below) |
162
167
 
163
168
  ```json
164
169
  {
@@ -169,6 +174,26 @@ No setup required. Optional config lives in:
169
174
  }
170
175
  ```
171
176
 
177
+ ### Per-repo overrides in global config
178
+
179
+ If you work across many repositories, you can scope config to a specific repo inside `~/.config/gji/config.json` without adding a `.gji.json` to that repo:
180
+
181
+ ```json
182
+ {
183
+ "branchPrefix": "feature/",
184
+ "repos": {
185
+ "/home/me/code/my-repo": {
186
+ "branchPrefix": "fix/",
187
+ "hooks": {
188
+ "afterCreate": "npm install"
189
+ }
190
+ }
191
+ }
192
+ }
193
+ ```
194
+
195
+ Precedence (lowest → highest): **global defaults → per-repo global → local `.gji.json`**. Hooks from all three layers are merged per key — different keys all apply, same key the higher-precedence layer wins.
196
+
172
197
  ### Config commands
173
198
 
174
199
  ```sh
@@ -200,19 +225,34 @@ Run scripts automatically at key lifecycle moments:
200
225
 
201
226
  Hooks receive `{{branch}}`, `{{path}}`, `{{repo}}` as template variables and `GJI_BRANCH`, `GJI_PATH`, `GJI_REPO` as environment variables. A failing hook emits a warning but never aborts the command.
202
227
 
203
- Global and repo-local hooks deep-merge per key:
228
+ Hooks from all three config layers merge per key — different keys from different layers both apply, same key the higher-precedence layer wins:
204
229
 
205
230
  ```jsonc
206
231
  // ~/.config/gji/config.json
207
232
  { "hooks": { "afterCreate": "nvm use", "afterEnter": "echo hi" } }
208
233
 
234
+ // per-repo entry in ~/.config/gji/config.json
235
+ { "repos": { "/my/repo": { "hooks": { "afterCreate": "npm install" } } } }
236
+
209
237
  // .gji.json
210
- { "hooks": { "afterCreate": "pnpm install" } }
238
+ { "hooks": { "beforeRemove": "echo bye" } }
211
239
 
212
240
  // effective
213
- { "hooks": { "afterCreate": "pnpm install", "afterEnter": "echo hi" } }
241
+ { "hooks": { "afterCreate": "npm install", "afterEnter": "echo hi", "beforeRemove": "echo bye" } }
214
242
  ```
215
243
 
244
+ ### Triggering hooks manually
245
+
246
+ Run any hook in the current worktree on demand:
247
+
248
+ ```sh
249
+ gji trigger-hook afterCreate # re-run the setup script
250
+ gji trigger-hook afterEnter # re-run the enter script
251
+ gji trigger-hook beforeRemove # dry-run the cleanup script
252
+ ```
253
+
254
+ This is useful after cloning on a new machine, recovering a broken worktree, or letting an AI agent bootstrap an already-existing worktree without needing interactive prompts.
255
+
216
256
  ## Install prompt
217
257
 
218
258
  When `gji new` or `gji pr` creates a worktree, `gji` detects the project's package manager from its lockfile and offers to run the install command:
@@ -225,7 +265,7 @@ Run `pnpm install` in the new worktree?
225
265
  Never disable this prompt for this repo
226
266
  ```
227
267
 
228
- **Always** saves `hooks.afterCreate` to `.gji.json`; **Never** writes `skipInstallPrompt: true`. Both are local-onlyglobal config is never modified.
268
+ **Always** saves `hooks.afterCreate`; **Never** writes `skipInstallPrompt: true`. Where they are saved depends on `installSaveTarget` (see [Available keys](#available-keys))defaults to `.gji.json`.
229
269
 
230
270
  ## JSON output
231
271
 
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { runRemoveCommand } from './remove.js';
11
11
  import { runRootCommand } from './root.js';
12
12
  import { runStatusCommand } from './status.js';
13
13
  import { runSyncCommand } from './sync.js';
14
+ import { runTriggerHookCommand } from './trigger-hook.js';
14
15
  export function createProgram() {
15
16
  const program = new Command();
16
17
  const packageVersion = readPackageVersion();
@@ -114,6 +115,10 @@ function registerCommands(program) {
114
115
  .option('--dry-run', 'show what would be deleted without removing anything')
115
116
  .option('--json', 'emit JSON on success or error instead of human-readable output')
116
117
  .action(notImplemented('remove'));
118
+ program
119
+ .command('trigger-hook <hook>')
120
+ .description('run a named hook (afterCreate, afterEnter, beforeRemove) in the current worktree')
121
+ .action(notImplemented('trigger-hook'));
117
122
  const configCommand = program
118
123
  .command('config')
119
124
  .description('manage global config defaults')
@@ -257,6 +262,18 @@ function attachCommandActions(program, options) {
257
262
  program.commands
258
263
  .find((command) => command.name() === 'remove')
259
264
  ?.action(runRemovalCommand);
265
+ program.commands
266
+ .find((command) => command.name() === 'trigger-hook')
267
+ ?.action(async (hook) => {
268
+ const exitCode = await runTriggerHookCommand({
269
+ cwd: options.cwd,
270
+ hook,
271
+ stderr: options.stderr,
272
+ });
273
+ if (exitCode !== 0) {
274
+ throw commanderExit(exitCode);
275
+ }
276
+ });
260
277
  const configCommand = program.commands.find((command) => command.name() === 'config');
261
278
  configCommand?.action(async () => {
262
279
  const exitCode = await runConfigCommand({
package/dist/config.d.ts CHANGED
@@ -16,5 +16,6 @@ export declare function updateLocalConfigKey(root: string, key: string, value: u
16
16
  export declare function saveGlobalConfig(config: GjiConfig, home?: string): Promise<string>;
17
17
  export declare function unsetGlobalConfigKey(key: string, home?: string): Promise<GjiConfig>;
18
18
  export declare function updateGlobalConfigKey(key: string, value: unknown, home?: string): Promise<GjiConfig>;
19
+ export declare function updateGlobalRepoConfigKey(repoRoot: string, key: string, value: unknown, home?: string): Promise<GjiConfig>;
19
20
  export declare function GLOBAL_CONFIG_FILE_PATH(home?: string): string;
20
21
  export declare function parseConfigValue(value: string): unknown;
package/dist/config.js CHANGED
@@ -14,11 +14,27 @@ export async function loadEffectiveConfig(root, home = homedir()) {
14
14
  loadGlobalConfig(home),
15
15
  loadConfig(root),
16
16
  ]);
17
- const merged = mergeConfig(globalConfig.config, localConfig.config);
18
- const globalHooks = isPlainObject(globalConfig.config.hooks) ? globalConfig.config.hooks : {};
17
+ // Extract per-repo override keyed by the absolute repo path.
18
+ // Keys may use ~ as shorthand for the home directory (e.g. ~/code/my-repo).
19
+ const repos = globalConfig.config.repos;
20
+ const perRepoConfig = isPlainObject(repos)
21
+ ? findPerRepoConfig(repos, root, home)
22
+ : {};
23
+ // Strip the internal `repos` registry from the global base before merging.
24
+ const globalBase = { ...globalConfig.config };
25
+ delete globalBase.repos;
26
+ // Precedence (lowest → highest): global base → per-repo global → local.
27
+ const merged = mergeConfig(globalBase, perRepoConfig, localConfig.config);
28
+ // Hooks are spread across all three layers so that different hook keys from
29
+ // different layers both apply (e.g. global afterEnter + local afterCreate).
30
+ // Within each key the higher-precedence layer wins (same spread order).
31
+ const globalHooks = isPlainObject(globalBase.hooks) ? globalBase.hooks : {};
32
+ const perRepoHooks = isPlainObject(perRepoConfig.hooks) ? perRepoConfig.hooks : {};
19
33
  const localHooks = isPlainObject(localConfig.config.hooks) ? localConfig.config.hooks : {};
20
- if (Object.keys(globalHooks).length > 0 || Object.keys(localHooks).length > 0) {
21
- merged.hooks = { ...globalHooks, ...localHooks };
34
+ if (Object.keys(globalHooks).length > 0 ||
35
+ Object.keys(perRepoHooks).length > 0 ||
36
+ Object.keys(localHooks).length > 0) {
37
+ merged.hooks = { ...globalHooks, ...perRepoHooks, ...localHooks };
22
38
  }
23
39
  return merged;
24
40
  }
@@ -61,6 +77,15 @@ export async function updateGlobalConfigKey(key, value, home = homedir()) {
61
77
  await saveGlobalConfig(nextConfig, home);
62
78
  return nextConfig;
63
79
  }
80
+ export async function updateGlobalRepoConfigKey(repoRoot, key, value, home = homedir()) {
81
+ const loaded = await loadGlobalConfig(home);
82
+ const repos = isPlainObject(loaded.config.repos) ? { ...loaded.config.repos } : {};
83
+ const existing = isPlainObject(repos[repoRoot]) ? repos[repoRoot] : {};
84
+ repos[repoRoot] = { ...existing, [key]: value };
85
+ const nextConfig = { ...loaded.config, repos };
86
+ await saveGlobalConfig(nextConfig, home);
87
+ return nextConfig;
88
+ }
64
89
  export function GLOBAL_CONFIG_FILE_PATH(home = homedir()) {
65
90
  return join(home, GLOBAL_CONFIG_DIRECTORY, GLOBAL_CONFIG_NAME);
66
91
  }
@@ -99,6 +124,22 @@ function mergeConfig(...values) {
99
124
  ...value,
100
125
  }), { ...DEFAULT_CONFIG });
101
126
  }
127
+ function findPerRepoConfig(repos, repoRoot, home) {
128
+ for (const [key, value] of Object.entries(repos)) {
129
+ const expandedKey = expandTilde(key, home);
130
+ if (expandedKey === repoRoot && isPlainObject(value)) {
131
+ return value;
132
+ }
133
+ }
134
+ return {};
135
+ }
136
+ function expandTilde(value, home) {
137
+ if (value === '~')
138
+ return home;
139
+ if (value.startsWith('~/'))
140
+ return join(home, value.slice(2));
141
+ return value;
142
+ }
102
143
  function isPlainObject(value) {
103
144
  return typeof value === 'object' && value !== null && !Array.isArray(value);
104
145
  }
package/dist/init.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export type SupportedShell = 'bash' | 'fish' | 'zsh';
2
+ export type InstallSaveTarget = 'local' | 'global';
2
3
  export interface InitCommandOptions {
3
4
  cwd: string;
5
+ home?: string;
6
+ promptForInstallSaveTarget?: () => Promise<InstallSaveTarget | null>;
4
7
  shell?: string;
5
8
  stderr?: (chunk: string) => void;
6
9
  stdout: (chunk: string) => void;
package/dist/init.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
+ import { isCancel, select } from '@clack/prompts';
5
+ import { loadGlobalConfig, updateGlobalConfigKey } from './config.js';
4
6
  const START_MARKER = '# >>> gji init >>>';
5
7
  const END_MARKER = '# <<< gji init <<<';
6
8
  const SHELL_WRAPPED_COMMANDS = [
@@ -42,6 +44,7 @@ const SHELL_WRAPPED_COMMANDS = [
42
44
  ];
43
45
  export async function runInitCommand(options) {
44
46
  const shell = resolveShell(options.shell, process.env.SHELL);
47
+ const home = options.home ?? homedir();
45
48
  if (!shell) {
46
49
  options.stderr?.('Unable to detect a supported shell. Specify one explicitly: bash, fish, or zsh.\n');
47
50
  return 1;
@@ -51,12 +54,25 @@ export async function runInitCommand(options) {
51
54
  options.stdout(script);
52
55
  return 0;
53
56
  }
54
- const rcPath = resolveShellConfigPath(shell, homedir());
57
+ const rcPath = resolveShellConfigPath(shell, home);
55
58
  await mkdir(dirname(rcPath), { recursive: true });
56
59
  const current = await readExistingConfig(rcPath);
57
60
  const next = upsertShellIntegration(current, script);
58
61
  await writeFile(rcPath, next, 'utf8');
59
62
  options.stdout(`${rcPath}\n`);
63
+ // After the shell integration is in place, ask once where to save hooks/prefs.
64
+ // Skip if already configured. When using the default interactive prompt, also
65
+ // require a real TTY so we don't block in piped/headless environments.
66
+ const { config: globalConfig } = await loadGlobalConfig(home);
67
+ const hasCustomPrompt = options.promptForInstallSaveTarget !== undefined;
68
+ const canPrompt = hasCustomPrompt || process.stdout.isTTY === true;
69
+ if (!('installSaveTarget' in globalConfig) && canPrompt) {
70
+ const prompt = options.promptForInstallSaveTarget ?? defaultPromptForInstallSaveTarget;
71
+ const target = await prompt();
72
+ if (target) {
73
+ await updateGlobalConfigKey('installSaveTarget', target, home);
74
+ }
75
+ }
60
76
  return 0;
61
77
  }
62
78
  export function renderShellIntegration(shell) {
@@ -195,3 +211,16 @@ function indentBlock(value, spaces) {
195
211
  .map((line) => line.length === 0 ? '' : `${prefix}${line}`)
196
212
  .join('\n');
197
213
  }
214
+ async function defaultPromptForInstallSaveTarget() {
215
+ const choice = await select({
216
+ message: 'Where should saved hooks and preferences be stored by default?',
217
+ options: [
218
+ { value: 'local', label: '.gji.json', hint: 'local — committed, shared with the team' },
219
+ { value: 'global', label: '~/.config/gji/config.json', hint: 'global — personal, never committed' },
220
+ ],
221
+ });
222
+ if (isCancel(choice)) {
223
+ return null;
224
+ }
225
+ return choice;
226
+ }
@@ -6,5 +6,6 @@ export interface InstallPromptDependencies {
6
6
  promptForInstallChoice?: (pm: PackageManager) => Promise<InstallChoice | null>;
7
7
  runInstallCommand?: (command: string, cwd: string, stderr: (chunk: string) => void) => Promise<void>;
8
8
  writeConfigKey?: (root: string, key: string, value: unknown) => Promise<void>;
9
+ writeGlobalRepoConfigKey?: (repoRoot: string, key: string, value: unknown) => Promise<void>;
9
10
  }
10
11
  export declare function maybeRunInstallPrompt(worktreePath: string, repoRoot: string, config: GjiConfig, stderr: (chunk: string) => void, dependencies?: InstallPromptDependencies, nonInteractive?: boolean): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { isCancel, select } from '@clack/prompts';
3
- import { loadConfig, updateLocalConfigKey } from './config.js';
3
+ import { loadConfig, loadGlobalConfig, updateGlobalRepoConfigKey, updateLocalConfigKey } from './config.js';
4
4
  import { isHeadless } from './headless.js';
5
5
  import { detectPackageManager } from './package-manager.js';
6
6
  export async function maybeRunInstallPrompt(worktreePath, repoRoot, config, stderr, dependencies = {}, nonInteractive = false) {
@@ -36,13 +36,22 @@ export async function maybeRunInstallPrompt(worktreePath, repoRoot, config, stde
36
36
  stderr(`gji: install command failed: ${error instanceof Error ? error.message : String(error)}\n`);
37
37
  }
38
38
  }
39
+ const saveGlobal = config.installSaveTarget === 'global';
39
40
  const writeKey = dependencies.writeConfigKey ?? defaultWriteConfigKey;
41
+ const writeGlobalKey = dependencies.writeGlobalRepoConfigKey ?? defaultWriteGlobalRepoConfigKey;
40
42
  if (choice === 'always') {
41
43
  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 });
44
+ if (saveGlobal) {
45
+ // Deep-merge with any existing per-repo global hooks so other keys are preserved.
46
+ const existingHooks = await loadExistingGlobalRepoHooks(repoRoot);
47
+ await writeGlobalKey(repoRoot, 'hooks', { ...existingHooks, afterCreate: pm.installCommand });
48
+ }
49
+ else {
50
+ // Read local config hooks to deep-merge so other hook keys (e.g. afterEnter) are preserved.
51
+ const { config: localConfig } = await loadConfig(repoRoot);
52
+ const existingLocalHooks = isPlainObject(localConfig.hooks) ? localConfig.hooks : {};
53
+ await writeKey(repoRoot, 'hooks', { ...existingLocalHooks, afterCreate: pm.installCommand });
54
+ }
46
55
  }
47
56
  catch (error) {
48
57
  stderr(`gji: failed to save config: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -50,7 +59,12 @@ export async function maybeRunInstallPrompt(worktreePath, repoRoot, config, stde
50
59
  }
51
60
  if (choice === 'never') {
52
61
  try {
53
- await writeKey(repoRoot, 'skipInstallPrompt', true);
62
+ if (saveGlobal) {
63
+ await writeGlobalKey(repoRoot, 'skipInstallPrompt', true);
64
+ }
65
+ else {
66
+ await writeKey(repoRoot, 'skipInstallPrompt', true);
67
+ }
54
68
  }
55
69
  catch (error) {
56
70
  stderr(`gji: failed to save config: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -79,6 +93,15 @@ async function defaultRunInstallCommand(command, cwd, stderr) {
79
93
  async function defaultWriteConfigKey(root, key, value) {
80
94
  await updateLocalConfigKey(root, key, value);
81
95
  }
96
+ async function defaultWriteGlobalRepoConfigKey(repoRoot, key, value) {
97
+ await updateGlobalRepoConfigKey(repoRoot, key, value);
98
+ }
99
+ async function loadExistingGlobalRepoHooks(repoRoot) {
100
+ const { config: globalConfig } = await loadGlobalConfig();
101
+ const repos = isPlainObject(globalConfig.repos) ? globalConfig.repos : {};
102
+ const perRepo = isPlainObject(repos[repoRoot]) ? repos[repoRoot] : {};
103
+ return isPlainObject(perRepo.hooks) ? perRepo.hooks : {};
104
+ }
82
105
  async function defaultPromptForInstallChoice(pm) {
83
106
  const choice = await select({
84
107
  message: `Run \`${pm.installCommand}\` in the new worktree?`,
package/dist/new.js CHANGED
@@ -58,6 +58,7 @@ export function createNewCommand(dependencies = {}) {
58
58
  else {
59
59
  options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
60
60
  options.stderr(`Hint: Use 'gji remove ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
61
+ options.stderr(`Hint: Use 'gji trigger-hook afterCreate' inside the worktree to re-run setup hooks\n`);
61
62
  }
62
63
  return 1;
63
64
  }
@@ -0,0 +1,6 @@
1
+ export interface TriggerHookCommandOptions {
2
+ cwd: string;
3
+ hook: string;
4
+ stderr: (chunk: string) => void;
5
+ }
6
+ export declare function runTriggerHookCommand(options: TriggerHookCommandOptions): Promise<number>;
@@ -0,0 +1,26 @@
1
+ import { loadEffectiveConfig } from './config.js';
2
+ import { extractHooks, runHook } from './hooks.js';
3
+ import { detectRepository, listWorktrees } from './repo.js';
4
+ const VALID_HOOKS = ['afterCreate', 'afterEnter', 'beforeRemove'];
5
+ function isValidHook(hook) {
6
+ return VALID_HOOKS.includes(hook);
7
+ }
8
+ export async function runTriggerHookCommand(options) {
9
+ if (!isValidHook(options.hook)) {
10
+ options.stderr(`gji trigger-hook: unknown hook '${options.hook}'. Valid hooks: ${VALID_HOOKS.join(', ')}\n`);
11
+ return 1;
12
+ }
13
+ const hookName = options.hook;
14
+ const repository = await detectRepository(options.cwd);
15
+ const config = await loadEffectiveConfig(repository.repoRoot);
16
+ const hooks = extractHooks(config);
17
+ // Find the branch for the current worktree (undefined for detached HEAD).
18
+ const worktrees = await listWorktrees(options.cwd);
19
+ const currentWorktree = worktrees.find((w) => w.path === repository.currentRoot);
20
+ await runHook(hooks[hookName], repository.currentRoot, {
21
+ branch: currentWorktree?.branch ?? undefined,
22
+ path: repository.currentRoot,
23
+ repo: repository.repoName,
24
+ }, options.stderr);
25
+ return 0;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solaqua/gji",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Git worktree CLI for fast context switching.",
5
5
  "license": "MIT",
6
6
  "author": "sjquant",