@solaqua/gji 0.2.4 → 0.4.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/README.md CHANGED
@@ -73,6 +73,8 @@ Worktrees land at a deterministic path so your editor bookmarks and scripts alwa
73
73
  ../worktrees/<repo>/<branch>
74
74
  ```
75
75
 
76
+ Set `worktreePath` in your config to use a different base (e.g. `"~/worktrees"` → `~/worktrees/<branch>`).
77
+
76
78
  ## Daily workflow
77
79
 
78
80
  ```sh
@@ -132,7 +134,7 @@ path=$(gji root --print)
132
134
 
133
135
  | Command | Description |
134
136
  |---|---|
135
- | `gji new [branch] [--detached] [--json]` | create branch + worktree, cd in |
137
+ | `gji new [branch] [--detached] [--json]` | create branch + worktree, cd in (validates branch name against Git rules) |
136
138
  | `gji pr <ref> [--json]` | fetch PR ref, create worktree, cd in |
137
139
  | `gji go [branch] [--print]` | jump to a worktree |
138
140
  | `gji root [--print]` | jump to the main repo root |
@@ -157,6 +159,7 @@ No setup required. Optional config lives in:
157
159
  | Key | Description |
158
160
  |---|---|
159
161
  | `branchPrefix` | prefix added to new branch names (e.g. `"feature/"`) |
162
+ | `worktreePath` | base directory for new worktrees (absolute or `~/…`); overrides the default `../worktrees/<repo>/` layout |
160
163
  | `syncRemote` | remote for `gji sync` (default: `origin`) |
161
164
  | `syncDefaultBranch` | branch to rebase onto (default: remote `HEAD`) |
162
165
  | `syncFiles` | files to copy from main worktree into each new worktree |
package/dist/clean.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { confirm, isCancel, multiselect } from '@clack/prompts';
2
+ import { readWorktreeHealth } from './git.js';
2
3
  import { isHeadless } from './headless.js';
3
4
  import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
4
5
  import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree } from './worktree-prompts.js';
@@ -146,13 +147,18 @@ function toMessage(error) {
146
147
  return error instanceof Error ? error.message : String(error);
147
148
  }
148
149
  async function defaultPromptForWorktrees(worktrees) {
150
+ const healthResults = await Promise.allSettled(worktrees.map((w) => readWorktreeHealth(w.path)));
149
151
  const choice = await multiselect({
150
152
  message: 'Choose worktrees to clean',
151
- options: worktrees.map((worktree) => ({
152
- hint: worktree.path,
153
- label: worktree.branch ?? '(detached)',
154
- value: worktree.path,
155
- })),
153
+ options: worktrees.map((worktree, i) => {
154
+ const health = healthResults[i].status === 'fulfilled' ? healthResults[i].value : null;
155
+ const isStale = health?.upstreamGone === true;
156
+ return {
157
+ hint: isStale ? `${worktree.path} (upstream gone)` : worktree.path,
158
+ label: worktree.branch ?? '(detached)',
159
+ value: worktree.path,
160
+ };
161
+ }),
156
162
  required: true,
157
163
  });
158
164
  return isCancel(choice) ? null : choice;
package/dist/cli.js CHANGED
@@ -59,6 +59,7 @@ function registerCommands(program) {
59
59
  program
60
60
  .command('new [branch]')
61
61
  .description('create a new branch or detached linked worktree')
62
+ .option('-f, --force', 'remove and recreate the worktree if the target path already exists')
62
63
  .option('--detached', 'create a detached worktree without a branch')
63
64
  .option('--dry-run', 'show what would be created without executing any git commands or writing files')
64
65
  .option('--json', 'emit JSON on success or error instead of human-readable output')
@@ -140,7 +141,7 @@ function attachCommandActions(program, options) {
140
141
  program.commands
141
142
  .find((command) => command.name() === 'new')
142
143
  ?.action(async (branch, commandOptions) => {
143
- const exitCode = await runNewCommand({ ...options, branch, detached: commandOptions.detached, dryRun: commandOptions.dryRun, json: commandOptions.json });
144
+ const exitCode = await runNewCommand({ ...options, branch, detached: commandOptions.detached, dryRun: commandOptions.dryRun, force: commandOptions.force, json: commandOptions.json });
144
145
  if (exitCode !== 0) {
145
146
  throw commanderExit(exitCode);
146
147
  }
@@ -151,6 +152,7 @@ function attachCommandActions(program, options) {
151
152
  const exitCode = await runInitCommand({
152
153
  cwd: options.cwd,
153
154
  shell,
155
+ stderr: options.stderr,
154
156
  stdout: options.stdout,
155
157
  write: commandOptions.write,
156
158
  });
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export declare const CONFIG_FILE_NAME = ".gji.json";
2
2
  export declare const GLOBAL_CONFIG_DIRECTORY = ".config/gji";
3
3
  export declare const GLOBAL_CONFIG_NAME = "config.json";
4
+ export declare const KNOWN_CONFIG_KEYS: ReadonlySet<string>;
4
5
  export type GjiConfig = Record<string, unknown>;
5
6
  export interface LoadedConfig {
6
7
  config: GjiConfig;
@@ -9,7 +10,7 @@ export interface LoadedConfig {
9
10
  }
10
11
  export declare const DEFAULT_CONFIG: GjiConfig;
11
12
  export declare function loadConfig(root: string): Promise<LoadedConfig>;
12
- export declare function loadEffectiveConfig(root: string, home?: string): Promise<GjiConfig>;
13
+ export declare function loadEffectiveConfig(root: string, home?: string, onWarning?: (message: string) => void): Promise<GjiConfig>;
13
14
  export declare function loadGlobalConfig(home?: string): Promise<LoadedConfig>;
14
15
  export declare function saveLocalConfig(root: string, config: GjiConfig): Promise<string>;
15
16
  export declare function updateLocalConfigKey(root: string, key: string, value: unknown): Promise<GjiConfig>;
@@ -19,3 +20,4 @@ export declare function updateGlobalConfigKey(key: string, value: unknown, home?
19
20
  export declare function updateGlobalRepoConfigKey(repoRoot: string, key: string, value: unknown, home?: string): Promise<GjiConfig>;
20
21
  export declare function GLOBAL_CONFIG_FILE_PATH(home?: string): string;
21
22
  export declare function parseConfigValue(value: string): unknown;
23
+ export declare function resolveConfigString(config: GjiConfig, key: string): string | undefined;
package/dist/config.js CHANGED
@@ -1,15 +1,30 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, join, resolve } from 'node:path';
4
4
  export const CONFIG_FILE_NAME = '.gji.json';
5
5
  export const GLOBAL_CONFIG_DIRECTORY = '.config/gji';
6
6
  export const GLOBAL_CONFIG_NAME = 'config.json';
7
+ export const KNOWN_CONFIG_KEYS = new Set([
8
+ 'branchPrefix',
9
+ 'hooks',
10
+ 'installSaveTarget',
11
+ 'shellIntegration',
12
+ 'skipInstallPrompt',
13
+ 'syncDefaultBranch',
14
+ 'syncFiles',
15
+ 'syncRemote',
16
+ 'worktreePath',
17
+ ]);
18
+ const KNOWN_GLOBAL_CONFIG_KEYS = new Set([
19
+ ...KNOWN_CONFIG_KEYS,
20
+ 'repos',
21
+ ]);
7
22
  export const DEFAULT_CONFIG = Object.freeze({});
8
23
  export async function loadConfig(root) {
9
24
  const path = join(root, CONFIG_FILE_NAME);
10
25
  return loadConfigFile(path);
11
26
  }
12
- export async function loadEffectiveConfig(root, home = homedir()) {
27
+ export async function loadEffectiveConfig(root, home = homedir(), onWarning) {
13
28
  const [globalConfig, localConfig] = await Promise.all([
14
29
  loadGlobalConfig(home),
15
30
  loadConfig(root),
@@ -23,8 +38,28 @@ export async function loadEffectiveConfig(root, home = homedir()) {
23
38
  // Strip the internal `repos` registry from the global base before merging.
24
39
  const globalBase = { ...globalConfig.config };
25
40
  delete globalBase.repos;
41
+ if (onWarning) {
42
+ if (globalConfig.exists) {
43
+ warnUnknownKeys(globalBase, globalConfig.path, KNOWN_GLOBAL_CONFIG_KEYS, onWarning);
44
+ if (Object.keys(perRepoConfig).length > 0) {
45
+ warnUnknownKeys(perRepoConfig, globalConfig.path, KNOWN_CONFIG_KEYS, onWarning);
46
+ }
47
+ }
48
+ if (localConfig.exists) {
49
+ warnUnknownKeys(localConfig.config, localConfig.path, KNOWN_CONFIG_KEYS, onWarning);
50
+ }
51
+ }
26
52
  // Precedence (lowest → highest): global base → per-repo global → local.
27
53
  const merged = mergeConfig(globalBase, perRepoConfig, localConfig.config);
54
+ // Warn about relative worktreePath: it must be absolute or tilde-prefixed.
55
+ const worktreePathValue = merged.worktreePath;
56
+ if (onWarning &&
57
+ typeof worktreePathValue === 'string' &&
58
+ worktreePathValue.length > 0 &&
59
+ !worktreePathValue.startsWith('/') &&
60
+ !worktreePathValue.startsWith('~')) {
61
+ onWarning(`gji: "worktreePath" must be an absolute path or start with ~, got "${worktreePathValue}" — using default\n`);
62
+ }
28
63
  // Hooks are spread across all three layers so that different hook keys from
29
64
  // different layers both apply (e.g. global afterEnter + local afterCreate).
30
65
  // Within each key the higher-precedence layer wins (same spread order).
@@ -87,6 +122,10 @@ export async function updateGlobalRepoConfigKey(repoRoot, key, value, home = hom
87
122
  return nextConfig;
88
123
  }
89
124
  export function GLOBAL_CONFIG_FILE_PATH(home = homedir()) {
125
+ const configDir = process.env.GJI_CONFIG_DIR;
126
+ if (configDir) {
127
+ return join(resolve(configDir), GLOBAL_CONFIG_NAME);
128
+ }
90
129
  return join(home, GLOBAL_CONFIG_DIRECTORY, GLOBAL_CONFIG_NAME);
91
130
  }
92
131
  export function parseConfigValue(value) {
@@ -97,6 +136,10 @@ export function parseConfigValue(value) {
97
136
  return value;
98
137
  }
99
138
  }
139
+ export function resolveConfigString(config, key) {
140
+ const value = config[key];
141
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
142
+ }
100
143
  async function loadConfigFile(path) {
101
144
  try {
102
145
  const rawConfig = await readFile(path, 'utf8');
@@ -148,3 +191,38 @@ function isMissingFileError(error) {
148
191
  'code' in error &&
149
192
  error.code === 'ENOENT');
150
193
  }
194
+ function warnUnknownKeys(config, filePath, knownKeys, onWarning) {
195
+ for (const key of Object.keys(config)) {
196
+ if (!knownKeys.has(key)) {
197
+ const suggestion = closestKey(key, knownKeys);
198
+ const hint = suggestion ? ` (did you mean "${suggestion}"?)` : '';
199
+ onWarning(`gji: unknown config key "${key}" in ${filePath}${hint}\n`);
200
+ }
201
+ }
202
+ }
203
+ function closestKey(unknown, knownKeys) {
204
+ let best = null;
205
+ let bestDist = Infinity;
206
+ for (const key of knownKeys) {
207
+ const dist = levenshtein(unknown, key);
208
+ if (dist < bestDist) {
209
+ bestDist = dist;
210
+ best = key;
211
+ }
212
+ }
213
+ return bestDist <= Math.max(2, Math.floor(unknown.length / 2)) ? best : null;
214
+ }
215
+ function levenshtein(a, b) {
216
+ const m = a.length;
217
+ const n = b.length;
218
+ const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
219
+ for (let i = 1; i <= m; i++) {
220
+ for (let j = 1; j <= n; j++) {
221
+ dp[i][j] =
222
+ a[i - 1] === b[j - 1]
223
+ ? dp[i - 1][j - 1]
224
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
225
+ }
226
+ }
227
+ return dp[m][n];
228
+ }
package/dist/git.d.ts CHANGED
@@ -2,6 +2,7 @@ export interface WorktreeHealth {
2
2
  ahead: number;
3
3
  behind: number;
4
4
  hasUpstream: boolean;
5
+ upstreamGone: boolean;
5
6
  status: 'clean' | 'dirty';
6
7
  }
7
8
  export declare function runGit(cwd: string, args: string[]): Promise<string>;
package/dist/git.js CHANGED
@@ -23,6 +23,7 @@ function parseWorktreeHealth(output) {
23
23
  let ahead = 0;
24
24
  let behind = 0;
25
25
  let hasUpstream = false;
26
+ let hasAb = false;
26
27
  let dirty = false;
27
28
  for (const line of output.split('\n').filter(Boolean)) {
28
29
  if (line.startsWith('# branch.upstream ')) {
@@ -34,6 +35,7 @@ function parseWorktreeHealth(output) {
34
35
  if (!match) {
35
36
  throw new Error(`Unexpected branch.ab output: '${line}'`);
36
37
  }
38
+ hasAb = true;
37
39
  ahead = Number(match[1]);
38
40
  behind = Number(match[2]);
39
41
  continue;
@@ -46,6 +48,7 @@ function parseWorktreeHealth(output) {
46
48
  ahead,
47
49
  behind,
48
50
  hasUpstream,
51
+ upstreamGone: hasUpstream && !hasAb,
49
52
  status: dirty ? 'dirty' : 'clean',
50
53
  };
51
54
  }
package/dist/go.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type WorktreeHealth } from './git.js';
1
2
  import { type WorktreeEntry } from './repo.js';
2
3
  export interface GoCommandOptions {
3
4
  branch?: string;
@@ -11,3 +12,4 @@ export interface GoCommandDependencies {
11
12
  }
12
13
  export declare function createGoCommand(dependencies?: Partial<GoCommandDependencies>): (options: GoCommandOptions) => Promise<number>;
13
14
  export declare const runGoCommand: (options: GoCommandOptions) => Promise<number>;
15
+ export declare function formatUpstreamHint(branch: string | null, health: WorktreeHealth): string | null;
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 { readWorktreeHealth } from './git.js';
4
5
  import { isHeadless } from './headless.js';
5
6
  import { extractHooks, runHook } from './hooks.js';
6
7
  import { detectRepository, listWorktrees, sortByCurrentFirst } from './repo.js';
@@ -32,7 +33,7 @@ export function createGoCommand(dependencies = {}) {
32
33
  return 1;
33
34
  }
34
35
  const chosenWorktree = worktrees.find((w) => w.path === resolvedPath);
35
- const config = await loadEffectiveConfig(repository.repoRoot);
36
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
36
37
  const hooks = extractHooks(config);
37
38
  await runHook(hooks.afterEnter, resolvedPath, { branch: chosenWorktree?.branch ?? undefined, path: resolvedPath, repo: basename(repository.repoRoot) }, options.stderr);
38
39
  await writeShellOutput(GO_OUTPUT_FILE_ENV, resolvedPath, options.stdout);
@@ -41,16 +42,37 @@ export function createGoCommand(dependencies = {}) {
41
42
  }
42
43
  export const runGoCommand = createGoCommand();
43
44
  async function promptForWorktree(worktrees) {
45
+ const healthResults = await Promise.allSettled(worktrees.map((w) => readWorktreeHealth(w.path)));
44
46
  const choice = await select({
45
47
  message: 'Choose a worktree',
46
- options: worktrees.map((worktree) => ({
47
- value: worktree.path,
48
- label: worktree.branch ?? '(detached)',
49
- hint: worktree.isCurrent ? `${worktree.path} (current)` : worktree.path,
50
- })),
48
+ options: worktrees.map((worktree, i) => {
49
+ const health = healthResults[i].status === 'fulfilled' ? healthResults[i].value : null;
50
+ const pathHint = worktree.isCurrent ? `${worktree.path} (current)` : worktree.path;
51
+ const upstream = health ? formatUpstreamHint(worktree.branch, health) : null;
52
+ return {
53
+ value: worktree.path,
54
+ label: worktree.branch ?? '(detached)',
55
+ hint: upstream ? `${upstream} · ${pathHint}` : pathHint,
56
+ };
57
+ }),
51
58
  });
52
59
  if (isCancel(choice)) {
53
60
  return null;
54
61
  }
55
62
  return choice;
56
63
  }
64
+ export function formatUpstreamHint(branch, health) {
65
+ if (branch === null)
66
+ return null;
67
+ if (!health.hasUpstream)
68
+ return 'no upstream';
69
+ if (health.upstreamGone)
70
+ return 'upstream gone';
71
+ if (health.ahead === 0 && health.behind === 0)
72
+ return 'up to date';
73
+ if (health.ahead === 0)
74
+ return `behind ${health.behind}`;
75
+ if (health.behind === 0)
76
+ return `ahead ${health.ahead}`;
77
+ return `ahead ${health.ahead}, behind ${health.behind}`;
78
+ }
package/dist/index.js CHANGED
@@ -1,8 +1,19 @@
1
1
  #!/usr/bin/env node
2
+ import { homedir } from 'node:os';
3
+ import { loadGlobalConfig } from './config.js';
2
4
  import { runCli } from './cli.js';
3
5
  async function main() {
4
6
  try {
5
- const result = await runCli(process.argv.slice(2), {
7
+ const argv = process.argv.slice(2);
8
+ // Warn once (until fixed) when shell integration hasn't been set up.
9
+ // Only shown in interactive terminals — suppressed in pipes and after gji init --write.
10
+ const isMetaArg = argv[0] === 'init'
11
+ || argv[0] === '--version' || argv[0] === '-V'
12
+ || argv[0] === '--help' || argv[0] === '-h';
13
+ if (process.stderr.isTTY === true && !isMetaArg) {
14
+ await warnIfMissingShellIntegration();
15
+ }
16
+ const result = await runCli(argv, {
6
17
  stderr: (chunk) => process.stderr.write(chunk),
7
18
  stdout: (chunk) => process.stdout.write(chunk),
8
19
  });
@@ -14,4 +25,17 @@ async function main() {
14
25
  process.exitCode = 1;
15
26
  }
16
27
  }
28
+ async function warnIfMissingShellIntegration() {
29
+ try {
30
+ const { config } = await loadGlobalConfig(homedir());
31
+ if (!config.shellIntegration) {
32
+ const shellBin = (process.env.SHELL ?? '').split('/').at(-1);
33
+ const shellArg = shellBin && ['bash', 'zsh', 'fish'].includes(shellBin) ? ` ${shellBin}` : '';
34
+ process.stderr.write(`gji: shell integration not set up — run \`gji init${shellArg} --write\` to enable automatic cd.\n`);
35
+ }
36
+ }
37
+ catch {
38
+ // best-effort; never block the command
39
+ }
40
+ }
17
41
  void main();
package/dist/init.d.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  export type SupportedShell = 'bash' | 'fish' | 'zsh';
2
2
  export type InstallSaveTarget = 'local' | 'global';
3
+ export interface SetupWizardResult {
4
+ branchPrefix?: string;
5
+ hooks?: {
6
+ afterCreate?: string;
7
+ afterEnter?: string;
8
+ beforeRemove?: string;
9
+ };
10
+ installSaveTarget: InstallSaveTarget;
11
+ worktreePath?: string;
12
+ }
3
13
  export interface InitCommandOptions {
4
14
  cwd: string;
5
15
  home?: string;
6
- promptForInstallSaveTarget?: () => Promise<InstallSaveTarget | null>;
16
+ promptForSetup?: () => Promise<SetupWizardResult | null>;
7
17
  shell?: string;
8
18
  stderr?: (chunk: string) => void;
9
19
  stdout: (chunk: string) => void;
package/dist/init.js CHANGED
@@ -1,8 +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
+ import { intro, isCancel, outro, select, text } from '@clack/prompts';
5
+ import { loadConfig, loadGlobalConfig, saveGlobalConfig, saveLocalConfig, updateGlobalConfigKey } from './config.js';
6
6
  const START_MARKER = '# >>> gji init >>>';
7
7
  const END_MARKER = '# <<< gji init <<<';
8
8
  const SHELL_WRAPPED_COMMANDS = [
@@ -60,19 +60,21 @@ export async function runInitCommand(options) {
60
60
  const next = upsertShellIntegration(current, script);
61
61
  await writeFile(rcPath, next, 'utf8');
62
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.
63
+ // Run the setup wizard on the first-ever init (not on subsequent re-runs).
66
64
  const { config: globalConfig } = await loadGlobalConfig(home);
67
- const hasCustomPrompt = options.promptForInstallSaveTarget !== undefined;
65
+ const alreadyConfigured = 'shellIntegration' in globalConfig || 'installSaveTarget' in globalConfig;
66
+ const hasCustomPrompt = options.promptForSetup !== undefined;
68
67
  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);
68
+ if (!alreadyConfigured && canPrompt) {
69
+ const prompt = options.promptForSetup ?? defaultPromptForSetup;
70
+ const result = await prompt();
71
+ if (result) {
72
+ await updateGlobalConfigKey('installSaveTarget', result.installSaveTarget, home);
73
+ await saveWizardConfig(result, options.cwd, home);
74
74
  }
75
75
  }
76
+ // Mark shell integration as installed so the first-run nudge is suppressed.
77
+ await updateGlobalConfigKey('shellIntegration', true, home);
76
78
  return 0;
77
79
  }
78
80
  export function renderShellIntegration(shell) {
@@ -111,6 +113,32 @@ export function upsertShellIntegration(existingConfig, script) {
111
113
  }
112
114
  return ensureTrailingNewline(`${prefix}\n\n${trimmedScript}`);
113
115
  }
116
+ async function saveWizardConfig(result, cwd, home) {
117
+ const values = {};
118
+ if (result.branchPrefix)
119
+ values.branchPrefix = result.branchPrefix;
120
+ if (result.worktreePath)
121
+ values.worktreePath = result.worktreePath;
122
+ const hooks = {};
123
+ if (result.hooks?.afterCreate)
124
+ hooks.afterCreate = result.hooks.afterCreate;
125
+ if (result.hooks?.afterEnter)
126
+ hooks.afterEnter = result.hooks.afterEnter;
127
+ if (result.hooks?.beforeRemove)
128
+ hooks.beforeRemove = result.hooks.beforeRemove;
129
+ if (Object.keys(hooks).length > 0)
130
+ values.hooks = hooks;
131
+ if (Object.keys(values).length === 0)
132
+ return;
133
+ if (result.installSaveTarget === 'local') {
134
+ const loaded = await loadConfig(cwd);
135
+ await saveLocalConfig(cwd, { ...loaded.config, ...values });
136
+ }
137
+ else {
138
+ const { config: existing } = await loadGlobalConfig(home);
139
+ await saveGlobalConfig({ ...existing, ...values }, home);
140
+ }
141
+ }
114
142
  function resolveShell(requestedShell, detectedShell) {
115
143
  const requested = normalizeShell(requestedShell);
116
144
  if (requested) {
@@ -211,16 +239,71 @@ function indentBlock(value, spaces) {
211
239
  .map((line) => line.length === 0 ? '' : `${prefix}${line}`)
212
240
  .join('\n');
213
241
  }
214
- async function defaultPromptForInstallSaveTarget() {
215
- const choice = await select({
216
- message: 'Where should saved hooks and preferences be stored by default?',
242
+ async function defaultPromptForSetup() {
243
+ intro('gji setup');
244
+ const installSaveTarget = await select({
245
+ message: 'Where should preferences be saved?',
217
246
  options: [
218
- { value: 'local', label: '.gji.json', hint: 'local — committed, shared with the team' },
219
- { value: 'global', label: '~/.config/gji/config.json', hint: 'globalpersonal, never committed' },
247
+ { value: 'global', label: '~/.config/gji/config.json', hint: 'personalnever committed' },
248
+ { value: 'local', label: '.gji.json', hint: 'repocommitted with the project' },
220
249
  ],
221
250
  });
222
- if (isCancel(choice)) {
251
+ if (isCancel(installSaveTarget)) {
252
+ outro('Setup skipped.');
253
+ return null;
254
+ }
255
+ const branchPrefix = await text({
256
+ message: 'Default branch prefix?',
257
+ placeholder: 'e.g. feat/ or fix/ — leave blank to skip',
258
+ });
259
+ if (isCancel(branchPrefix)) {
260
+ outro('Setup skipped.');
261
+ return null;
262
+ }
263
+ const worktreePath = await text({
264
+ message: 'Worktree base path?',
265
+ placeholder: 'leave blank to use the default path',
266
+ });
267
+ if (isCancel(worktreePath)) {
268
+ outro('Setup skipped.');
269
+ return null;
270
+ }
271
+ const afterCreate = await text({
272
+ message: 'afterCreate hook — run after creating a worktree?',
273
+ placeholder: 'e.g. pnpm install — leave blank to skip',
274
+ });
275
+ if (isCancel(afterCreate)) {
276
+ outro('Setup skipped.');
277
+ return null;
278
+ }
279
+ const afterEnter = await text({
280
+ message: 'afterEnter hook — run after entering a worktree?',
281
+ placeholder: 'e.g. nvm use — leave blank to skip',
282
+ });
283
+ if (isCancel(afterEnter)) {
284
+ outro('Setup skipped.');
285
+ return null;
286
+ }
287
+ const beforeRemove = await text({
288
+ message: 'beforeRemove hook — run before removing a worktree?',
289
+ placeholder: 'leave blank to skip',
290
+ });
291
+ if (isCancel(beforeRemove)) {
292
+ outro('Setup skipped.');
223
293
  return null;
224
294
  }
225
- return choice;
295
+ outro('Setup complete!');
296
+ const hooks = {};
297
+ if (afterCreate)
298
+ hooks.afterCreate = afterCreate;
299
+ if (afterEnter)
300
+ hooks.afterEnter = afterEnter;
301
+ if (beforeRemove)
302
+ hooks.beforeRemove = beforeRemove;
303
+ return {
304
+ branchPrefix: branchPrefix || undefined,
305
+ hooks: Object.keys(hooks).length > 0 ? hooks : undefined,
306
+ installSaveTarget,
307
+ worktreePath: worktreePath || undefined,
308
+ };
226
309
  }
package/dist/new.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface NewCommandOptions {
6
6
  cwd: string;
7
7
  detached?: boolean;
8
8
  dryRun?: boolean;
9
+ force?: boolean;
9
10
  json?: boolean;
10
11
  stderr: (chunk: string) => void;
11
12
  stdout: (chunk: string) => void;
package/dist/new.js CHANGED
@@ -3,13 +3,13 @@ import { basename, dirname } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { isCancel, text } from '@clack/prompts';
6
- import { loadEffectiveConfig } from './config.js';
6
+ import { loadEffectiveConfig, resolveConfigString } from './config.js';
7
7
  import { syncFiles } from './file-sync.js';
8
8
  import { extractHooks, runHook } from './hooks.js';
9
9
  import { isHeadless } from './headless.js';
10
10
  import { maybeRunInstallPrompt } from './install-prompt.js';
11
11
  import { pathExists, promptForPathConflict } from './conflict.js';
12
- import { detectRepository, resolveWorktreePath } from './repo.js';
12
+ import { detectRepository, resolveWorktreePath, validateBranchName } from './repo.js';
13
13
  import { writeShellOutput } from './shell-handoff.js';
14
14
  const execFileAsync = promisify(execFile);
15
15
  const NEW_OUTPUT_FILE_ENV = 'GJI_NEW_OUTPUT_FILE';
@@ -19,7 +19,7 @@ export function createNewCommand(dependencies = {}) {
19
19
  const prompt = dependencies.promptForPathConflict ?? promptForPathConflict;
20
20
  return async function runNewCommand(options) {
21
21
  const repository = await detectRepository(options.cwd);
22
- const config = await loadEffectiveConfig(repository.repoRoot);
22
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
23
23
  const usesGeneratedDetachedName = options.detached && options.branch === undefined;
24
24
  if (!options.detached && !options.branch && (options.json || isHeadless())) {
25
25
  const message = 'branch argument is required';
@@ -43,14 +43,54 @@ export function createNewCommand(dependencies = {}) {
43
43
  }
44
44
  return 1;
45
45
  }
46
+ if (!options.detached) {
47
+ const branchError = validateBranchName(rawBranch);
48
+ if (branchError) {
49
+ if (options.json) {
50
+ options.stderr(`${JSON.stringify({ error: branchError }, null, 2)}\n`);
51
+ }
52
+ else {
53
+ options.stderr(`gji new: ${branchError}\n`);
54
+ }
55
+ return 1;
56
+ }
57
+ }
58
+ const rawBasePath = resolveConfigString(config, 'worktreePath');
59
+ const configuredBasePath = rawBasePath?.startsWith('/') || rawBasePath?.startsWith('~') ? rawBasePath : undefined;
46
60
  const worktreeName = options.detached
47
61
  ? rawBranch
48
62
  : applyConfiguredBranchPrefix(rawBranch, config.branchPrefix);
49
63
  const worktreePath = usesGeneratedDetachedName
50
- ? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName)
51
- : resolveWorktreePath(repository.repoRoot, worktreeName);
64
+ ? await resolveUniqueDetachedWorktreePath(repository.repoRoot, worktreeName, configuredBasePath)
65
+ : resolveWorktreePath(repository.repoRoot, worktreeName, configuredBasePath);
52
66
  if (!usesGeneratedDetachedName && await pathExists(worktreePath)) {
53
- if (options.json || isHeadless()) {
67
+ if (options.force) {
68
+ if (!options.dryRun) {
69
+ try {
70
+ await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repository.repoRoot });
71
+ }
72
+ catch (err) {
73
+ if (!isNotRegisteredWorktreeError(err)) {
74
+ const msg = `could not remove existing worktree at ${worktreePath}: ${toExecMessage(err)}`;
75
+ if (options.json) {
76
+ options.stderr(`${JSON.stringify({ warning: msg }, null, 2)}\n`);
77
+ }
78
+ else {
79
+ options.stderr(`Warning: ${msg}\n`);
80
+ }
81
+ }
82
+ }
83
+ if (!options.detached) {
84
+ try {
85
+ await execFileAsync('git', ['branch', '-D', worktreeName], { cwd: repository.repoRoot });
86
+ }
87
+ catch {
88
+ // Branch may not exist; proceed anyway.
89
+ }
90
+ }
91
+ }
92
+ }
93
+ else if (options.json || isHeadless()) {
54
94
  const message = `target worktree path already exists: ${worktreePath}`;
55
95
  if (options.json) {
56
96
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
@@ -62,13 +102,15 @@ export function createNewCommand(dependencies = {}) {
62
102
  }
63
103
  return 1;
64
104
  }
65
- const choice = await prompt(worktreePath);
66
- if (choice === 'reuse') {
67
- await writeOutput(worktreePath, options.stdout);
68
- return 0;
105
+ else {
106
+ const choice = await prompt(worktreePath);
107
+ if (choice === 'reuse') {
108
+ await writeOutput(worktreePath, options.stdout);
109
+ return 0;
110
+ }
111
+ options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
112
+ return 1;
69
113
  }
70
- options.stderr(`Aborted because target worktree path already exists: ${worktreePath}\n`);
71
- return 1;
72
114
  }
73
115
  if (options.dryRun) {
74
116
  if (options.json) {
@@ -162,11 +204,11 @@ function applyConfiguredBranchPrefix(branch, branchPrefix) {
162
204
  }
163
205
  return `${branchPrefix}${branch}`;
164
206
  }
165
- async function resolveUniqueDetachedWorktreePath(repoRoot, baseName) {
207
+ async function resolveUniqueDetachedWorktreePath(repoRoot, baseName, basePath) {
166
208
  let attempt = 1;
167
209
  while (true) {
168
210
  const candidateName = attempt === 1 ? baseName : `${baseName}-${attempt}`;
169
- const candidatePath = resolveWorktreePath(repoRoot, candidateName);
211
+ const candidatePath = resolveWorktreePath(repoRoot, candidateName, basePath);
170
212
  if (!await pathExists(candidatePath)) {
171
213
  return candidatePath;
172
214
  }
@@ -178,7 +220,10 @@ async function defaultPromptForBranch(placeholder) {
178
220
  defaultValue: placeholder,
179
221
  message: 'Name the new branch',
180
222
  placeholder,
181
- validate: (value) => value.trim().length === 0 ? 'Branch name must not be empty.' : undefined,
223
+ validate: (value) => {
224
+ const trimmed = value.trim();
225
+ return validateBranchName(trimmed) ?? undefined;
226
+ },
182
227
  });
183
228
  if (isCancel(choice)) {
184
229
  return null;
@@ -201,3 +246,13 @@ async function localBranchExists(repoRoot, branchName) {
201
246
  async function writeOutput(worktreePath, stdout) {
202
247
  await writeShellOutput(NEW_OUTPUT_FILE_ENV, worktreePath, stdout);
203
248
  }
249
+ function isNotRegisteredWorktreeError(error) {
250
+ const stderr = hasExecStderr(error) ? error.stderr : String(error);
251
+ return stderr.includes('is not a working tree') || stderr.includes('not a linked working tree');
252
+ }
253
+ function hasExecStderr(error) {
254
+ return error instanceof Error && 'stderr' in error && typeof error.stderr === 'string';
255
+ }
256
+ function toExecMessage(error) {
257
+ return hasExecStderr(error) ? error.stderr.trim() : String(error);
258
+ }
package/dist/pr.js CHANGED
@@ -2,7 +2,7 @@ import { mkdir } from 'node:fs/promises';
2
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';
5
+ import { loadEffectiveConfig, resolveConfigString } from './config.js';
6
6
  import { syncFiles } from './file-sync.js';
7
7
  import { pathExists, promptForPathConflict } from './conflict.js';
8
8
  import { extractHooks, runHook } from './hooks.js';
@@ -38,10 +38,12 @@ export function createPrCommand(dependencies = {}) {
38
38
  return 1;
39
39
  }
40
40
  const repository = await detectRepository(options.cwd);
41
- const config = await loadEffectiveConfig(repository.repoRoot);
41
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
42
42
  const branchName = `pr/${prNumber}`;
43
43
  const remoteRef = `refs/remotes/origin/pull/${prNumber}/head`;
44
- const worktreePath = resolveWorktreePath(repository.repoRoot, branchName);
44
+ const rawBasePath = resolveConfigString(config, 'worktreePath');
45
+ const configuredBasePath = rawBasePath?.startsWith('/') || rawBasePath?.startsWith('~') ? rawBasePath : undefined;
46
+ const worktreePath = resolveWorktreePath(repository.repoRoot, branchName, configuredBasePath);
45
47
  if (await pathExists(worktreePath)) {
46
48
  if (options.json || isHeadless()) {
47
49
  const message = `target worktree path already exists: ${worktreePath}`;
package/dist/remove.js CHANGED
@@ -63,7 +63,7 @@ export function createRemoveCommand(dependencies = {}) {
63
63
  }
64
64
  return 0;
65
65
  }
66
- const config = await loadEffectiveConfig(repository.repoRoot);
66
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
67
67
  const hooks = extractHooks(config);
68
68
  await runHook(hooks.beforeRemove, worktree.path, { branch: worktree.branch ?? undefined, path: worktree.path, repo: basename(repository.repoRoot) }, options.stderr);
69
69
  try {
package/dist/repo.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface WorktreeEntry {
11
11
  path: string;
12
12
  }
13
13
  export declare function detectRepository(cwd: string): Promise<RepositoryContext>;
14
- export declare function resolveWorktreePath(repoRoot: string, branch: string): string;
14
+ export declare function resolveWorktreePath(repoRoot: string, branch: string, basePath?: string): string;
15
+ export declare function validateBranchName(name: string): string | null;
15
16
  export declare function listWorktrees(cwd: string): Promise<WorktreeEntry[]>;
16
17
  export declare function sortByCurrentFirst(worktrees: WorktreeEntry[]): WorktreeEntry[];
package/dist/repo.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
2
+ import { homedir } from 'node:os';
2
3
  import { runGit } from './git.js';
3
4
  export async function detectRepository(cwd) {
4
5
  const currentRoot = await runGit(cwd, ['rev-parse', '--show-toplevel']);
@@ -15,7 +16,7 @@ export async function detectRepository(cwd) {
15
16
  repoRoot,
16
17
  };
17
18
  }
18
- export function resolveWorktreePath(repoRoot, branch) {
19
+ export function resolveWorktreePath(repoRoot, branch, basePath) {
19
20
  const segments = branch.split('/').filter(Boolean);
20
21
  if (segments.length === 0) {
21
22
  throw new Error('Branch name must not be empty.');
@@ -23,7 +24,52 @@ export function resolveWorktreePath(repoRoot, branch) {
23
24
  if (segments.some((segment) => segment === '.' || segment === '..')) {
24
25
  throw new Error(`Branch name '${branch}' contains an invalid path segment.`);
25
26
  }
26
- return join(dirname(repoRoot), 'worktrees', basename(repoRoot), ...segments);
27
+ const base = basePath
28
+ ? expandTildeInPath(basePath)
29
+ : join(dirname(repoRoot), 'worktrees', basename(repoRoot));
30
+ return join(base, ...segments);
31
+ }
32
+ export function validateBranchName(name) {
33
+ if (name.length === 0) {
34
+ return 'Branch name must not be empty.';
35
+ }
36
+ if (/[\x00-\x1f\x7f ~^:?*[\\\s]/.test(name)) {
37
+ return `Branch name '${name}' contains an invalid character.`;
38
+ }
39
+ if (name.startsWith('-')) {
40
+ return `Branch name '${name}' must not start with a dash.`;
41
+ }
42
+ if (name.startsWith('/') || name.endsWith('/') || name.includes('//')) {
43
+ return `Branch name '${name}' has invalid slash placement.`;
44
+ }
45
+ if (name.includes('..')) {
46
+ return `Branch name '${name}' must not contain '..'.`;
47
+ }
48
+ if (name.endsWith('.')) {
49
+ return `Branch name '${name}' must not end with '.'.`;
50
+ }
51
+ if (name.includes('@{')) {
52
+ return `Branch name '${name}' must not contain '@{'.`;
53
+ }
54
+ if (name === '@') {
55
+ return "Branch name cannot be '@'.";
56
+ }
57
+ for (const segment of name.split('/')) {
58
+ if (segment.startsWith('.')) {
59
+ return `Branch name '${name}' contains a path component starting with '.'.`;
60
+ }
61
+ if (segment.endsWith('.lock')) {
62
+ return `Branch name '${name}' contains a path component ending with '.lock'.`;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ function expandTildeInPath(p) {
68
+ if (p === '~')
69
+ return homedir();
70
+ if (p.startsWith('~/'))
71
+ return join(homedir(), p.slice(2));
72
+ return p;
27
73
  }
28
74
  export async function listWorktrees(cwd) {
29
75
  const [output, currentRoot] = await Promise.all([
package/dist/status.d.ts CHANGED
@@ -14,6 +14,8 @@ type UpstreamState = {
14
14
  kind: 'detached';
15
15
  } | {
16
16
  kind: 'no-upstream';
17
+ } | {
18
+ kind: 'stale';
17
19
  } | {
18
20
  kind: 'tracked';
19
21
  ahead: number;
package/dist/status.js CHANGED
@@ -58,6 +58,9 @@ function buildUpstreamState(branch, health) {
58
58
  if (!health.hasUpstream) {
59
59
  return { kind: 'no-upstream' };
60
60
  }
61
+ if (health.upstreamGone) {
62
+ return { kind: 'stale' };
63
+ }
61
64
  return {
62
65
  ahead: health.ahead,
63
66
  behind: health.behind,
@@ -71,6 +74,9 @@ function formatUpstreamState(upstream) {
71
74
  if (upstream.kind === 'no-upstream') {
72
75
  return 'no-upstream';
73
76
  }
77
+ if (upstream.kind === 'stale') {
78
+ return 'gone';
79
+ }
74
80
  if (upstream.ahead === 0 && upstream.behind === 0) {
75
81
  return 'up to date';
76
82
  }
package/dist/sync.js CHANGED
@@ -4,7 +4,7 @@ import { comparePaths } from './paths.js';
4
4
  import { detectRepository, listWorktrees } from './repo.js';
5
5
  export async function runSyncCommand(options) {
6
6
  const repository = await detectRepository(options.cwd);
7
- const config = await loadEffectiveConfig(repository.repoRoot);
7
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
8
8
  const worktrees = await listWorktrees(options.cwd);
9
9
  const remote = resolveConfiguredString(config.syncRemote) ?? 'origin';
10
10
  let defaultBranch;
@@ -12,7 +12,7 @@ export async function runTriggerHookCommand(options) {
12
12
  }
13
13
  const hookName = options.hook;
14
14
  const repository = await detectRepository(options.cwd);
15
- const config = await loadEffectiveConfig(repository.repoRoot);
15
+ const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
16
16
  const hooks = extractHooks(config);
17
17
  // Find the branch for the current worktree (undefined for detached HEAD).
18
18
  const worktrees = await listWorktrees(options.cwd);
@@ -0,0 +1,19 @@
1
+ .TH GJI\-CLEAN 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-clean \- interactively prune linked worktrees
4
+ .SH SYNOPSIS
5
+ .B gji clean [\fIoptions\fR] [options]
6
+ .SH DESCRIPTION
7
+ interactively prune linked worktrees
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-f, \-\-force
11
+ bypass prompts, force\-remove dirty worktrees, and force\-delete unmerged branches
12
+ .TP
13
+ .B \-\-dry\-run
14
+ show what would be deleted without removing anything
15
+ .TP
16
+ .B \-\-json
17
+ emit JSON on success or error instead of human\-readable output
18
+ .SH "SEE ALSO"
19
+ .BR gji (1)
@@ -0,0 +1,19 @@
1
+ .TH GJI\-CONFIG 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-config \- manage global config defaults
4
+ .SH SYNOPSIS
5
+ .B gji config [options] [command]
6
+ .SH DESCRIPTION
7
+ manage global config defaults
8
+ .SH SUBCOMMANDS
9
+ .TP
10
+ .B get
11
+ print the global config or a single key
12
+ .TP
13
+ .B set
14
+ set a global config value
15
+ .TP
16
+ .B unset
17
+ remove a global config value
18
+ .SH "SEE ALSO"
19
+ .BR gji (1)
@@ -0,0 +1,13 @@
1
+ .TH GJI\-GO 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-go \- print or select a worktree path
4
+ .SH SYNOPSIS
5
+ .B gji go [\fIoptions\fR] [options] [branch]
6
+ .SH DESCRIPTION
7
+ print or select a worktree path
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-\-print
11
+ print the resolved worktree path explicitly
12
+ .SH "SEE ALSO"
13
+ .BR gji (1)
@@ -0,0 +1,13 @@
1
+ .TH GJI\-INIT 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-init \- print or install shell integration
4
+ .SH SYNOPSIS
5
+ .B gji init [\fIoptions\fR] [options] [shell]
6
+ .SH DESCRIPTION
7
+ print or install shell integration
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-\-write
11
+ write the integration to the shell config file
12
+ .SH "SEE ALSO"
13
+ .BR gji (1)
@@ -0,0 +1,13 @@
1
+ .TH GJI\-LS 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-ls \- list active worktrees
4
+ .SH SYNOPSIS
5
+ .B gji ls [\fIoptions\fR] [options]
6
+ .SH DESCRIPTION
7
+ list active worktrees
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-\-json
11
+ print active worktrees as JSON
12
+ .SH "SEE ALSO"
13
+ .BR gji (1)
@@ -0,0 +1,22 @@
1
+ .TH GJI\-NEW 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-new \- create a new branch or detached linked worktree
4
+ .SH SYNOPSIS
5
+ .B gji new [\fIoptions\fR] [options] [branch]
6
+ .SH DESCRIPTION
7
+ create a new branch or detached linked worktree
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-f, \-\-force
11
+ remove and recreate the worktree if the target path already exists
12
+ .TP
13
+ .B \-\-detached
14
+ create a detached worktree without a branch
15
+ .TP
16
+ .B \-\-dry\-run
17
+ show what would be created without executing any git commands or writing files
18
+ .TP
19
+ .B \-\-json
20
+ emit JSON on success or error instead of human\-readable output
21
+ .SH "SEE ALSO"
22
+ .BR gji (1)
@@ -0,0 +1,16 @@
1
+ .TH GJI\-PR 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-pr \- fetch a pull request by number, #number, or URL into a linked worktree
4
+ .SH SYNOPSIS
5
+ .B gji pr [\fIoptions\fR] [options] <ref>
6
+ .SH DESCRIPTION
7
+ fetch a pull request by number, #number, or URL into a linked worktree
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-\-dry\-run
11
+ show what would be created without executing any git commands or writing files
12
+ .TP
13
+ .B \-\-json
14
+ emit JSON on success or error instead of human\-readable output
15
+ .SH "SEE ALSO"
16
+ .BR gji (1)
@@ -0,0 +1,19 @@
1
+ .TH GJI\-REMOVE 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-remove \- remove a linked worktree and delete its branch when present
4
+ .SH SYNOPSIS
5
+ .B gji remove [\fIoptions\fR] [options] [branch]
6
+ .SH DESCRIPTION
7
+ remove a linked worktree and delete its branch when present
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-f, \-\-force
11
+ bypass prompts, force\-remove a dirty worktree, and force\-delete an unmerged branch
12
+ .TP
13
+ .B \-\-dry\-run
14
+ show what would be deleted without removing anything
15
+ .TP
16
+ .B \-\-json
17
+ emit JSON on success or error instead of human\-readable output
18
+ .SH "SEE ALSO"
19
+ .BR gji (1)
@@ -0,0 +1,13 @@
1
+ .TH GJI\-ROOT 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-root \- print the main repository root path
4
+ .SH SYNOPSIS
5
+ .B gji root [\fIoptions\fR] [options]
6
+ .SH DESCRIPTION
7
+ print the main repository root path
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-\-print
11
+ print the resolved repository root path explicitly
12
+ .SH "SEE ALSO"
13
+ .BR gji (1)
@@ -0,0 +1,13 @@
1
+ .TH GJI\-STATUS 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-status \- summarize repository and worktree health
4
+ .SH SYNOPSIS
5
+ .B gji status [\fIoptions\fR] [options]
6
+ .SH DESCRIPTION
7
+ summarize repository and worktree health
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-\-json
11
+ print repository and worktree health as JSON
12
+ .SH "SEE ALSO"
13
+ .BR gji (1)
@@ -0,0 +1,16 @@
1
+ .TH GJI\-SYNC 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-sync \- fetch and update one or all worktrees
4
+ .SH SYNOPSIS
5
+ .B gji sync [\fIoptions\fR] [options]
6
+ .SH DESCRIPTION
7
+ fetch and update one or all worktrees
8
+ .SH OPTIONS
9
+ .TP
10
+ .B \-\-all
11
+ sync every worktree in the repository
12
+ .TP
13
+ .B \-\-json
14
+ emit JSON on success or error instead of human\-readable output
15
+ .SH "SEE ALSO"
16
+ .BR gji (1)
@@ -0,0 +1,9 @@
1
+ .TH GJI\-TRIGGER\-HOOK 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji\-trigger\-hook \- run a named hook (afterCreate, afterEnter, beforeRemove) in the current worktree
4
+ .SH SYNOPSIS
5
+ .B gji trigger\-hook [options] <hook>
6
+ .SH DESCRIPTION
7
+ run a named hook (afterCreate, afterEnter, beforeRemove) in the current worktree
8
+ .SH "SEE ALSO"
9
+ .BR gji (1)
package/man/man1/gji.1 ADDED
@@ -0,0 +1,67 @@
1
+ .TH GJI 1 "April 2026" "gji 0.4.0" "User Commands"
2
+ .SH NAME
3
+ gji \- Context switching without the mess.
4
+ .SH SYNOPSIS
5
+ .B gji
6
+ [\fIoptions\fR] <\fIcommand\fR> [\fIargs\fR]
7
+ .SH DESCRIPTION
8
+ Context switching without the mess.
9
+ .PP
10
+ Each branch lives in its own worktree with its own node_modules and terminal,
11
+ eliminating the need for \fBgit stash\fR when switching contexts.
12
+ .SH COMMANDS
13
+ .TP
14
+ .B new [options] [branch]
15
+ create a new branch or detached linked worktree
16
+ .TP
17
+ .B init [options] [shell]
18
+ print or install shell integration
19
+ .TP
20
+ .B pr [options] <ref>
21
+ fetch a pull request by number, #number, or URL into a linked worktree
22
+ .TP
23
+ .B go [options] [branch]
24
+ print or select a worktree path
25
+ .TP
26
+ .B root [options]
27
+ print the main repository root path
28
+ .TP
29
+ .B status [options]
30
+ summarize repository and worktree health
31
+ .TP
32
+ .B sync [options]
33
+ fetch and update one or all worktrees
34
+ .TP
35
+ .B ls [options]
36
+ list active worktrees
37
+ .TP
38
+ .B clean [options]
39
+ interactively prune linked worktrees
40
+ .TP
41
+ .B remove [options] [branch]
42
+ remove a linked worktree and delete its branch when present
43
+ .br
44
+ Alias: \fBrm\fR
45
+ .TP
46
+ .B trigger\-hook [options] <hook>
47
+ run a named hook (afterCreate, afterEnter, beforeRemove) in the current worktree
48
+ .TP
49
+ .B config [options] [command]
50
+ manage global config defaults
51
+ .SH OPTIONS
52
+ .TP
53
+ .B \-V, \-\-version
54
+ output the version number
55
+ .SH "SEE ALSO"
56
+ .BR gji\-new (1),
57
+ .BR gji\-init (1),
58
+ .BR gji\-pr (1),
59
+ .BR gji\-go (1),
60
+ .BR gji\-root (1),
61
+ .BR gji\-status (1),
62
+ .BR gji\-sync (1),
63
+ .BR gji\-ls (1),
64
+ .BR gji\-clean (1),
65
+ .BR gji\-remove (1),
66
+ .BR gji\-trigger\-hook (1),
67
+ .BR gji\-config (1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solaqua/gji",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Git worktree CLI for fast context switching.",
5
5
  "license": "MIT",
6
6
  "author": "sjquant",
@@ -24,9 +24,13 @@
24
24
  ],
25
25
  "files": [
26
26
  "dist",
27
+ "man",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ],
31
+ "man": [
32
+ "man/man1/gji.1"
33
+ ],
30
34
  "publishConfig": {
31
35
  "access": "public"
32
36
  },
@@ -38,7 +42,8 @@
38
42
  },
39
43
  "scripts": {
40
44
  "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
41
- "prepublishOnly": "pnpm test && pnpm build",
45
+ "generate-man": "node scripts/generate-man.mjs",
46
+ "prepublishOnly": "pnpm test && pnpm build && pnpm generate-man",
42
47
  "test": "vitest run --passWithNoTests",
43
48
  "test:watch": "vitest"
44
49
  },