@solaqua/gji 0.6.1 → 0.7.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.
Files changed (80) hide show
  1. package/README.md +26 -1
  2. package/dist/back.d.ts +1 -1
  3. package/dist/back.js +23 -17
  4. package/dist/clean.d.ts +1 -1
  5. package/dist/clean.js +44 -35
  6. package/dist/cli.d.ts +1 -1
  7. package/dist/cli.js +264 -164
  8. package/dist/completion.js +3 -3
  9. package/dist/config-command.js +5 -5
  10. package/dist/config.js +41 -35
  11. package/dist/conflict.d.ts +1 -1
  12. package/dist/conflict.js +14 -6
  13. package/dist/editor.js +29 -9
  14. package/dist/file-sync.d.ts +1 -0
  15. package/dist/file-sync.js +15 -11
  16. package/dist/git.d.ts +1 -1
  17. package/dist/git.js +21 -19
  18. package/dist/gji-bundle.mjs +1709 -850
  19. package/dist/go.d.ts +2 -2
  20. package/dist/go.js +39 -26
  21. package/dist/headless.js +1 -1
  22. package/dist/history-command.js +3 -3
  23. package/dist/history.js +12 -12
  24. package/dist/hooks.js +16 -16
  25. package/dist/index.js +13 -9
  26. package/dist/init.d.ts +2 -2
  27. package/dist/init.js +106 -94
  28. package/dist/install-prompt.d.ts +3 -3
  29. package/dist/install-prompt.js +46 -28
  30. package/dist/ls.d.ts +2 -2
  31. package/dist/ls.js +29 -29
  32. package/dist/new.d.ts +2 -2
  33. package/dist/new.js +96 -81
  34. package/dist/open.d.ts +2 -2
  35. package/dist/open.js +24 -21
  36. package/dist/package-manager.js +96 -45
  37. package/dist/pr.d.ts +2 -2
  38. package/dist/pr.js +47 -34
  39. package/dist/remove.d.ts +1 -1
  40. package/dist/remove.js +39 -27
  41. package/dist/repo-registry.js +45 -19
  42. package/dist/repo.js +29 -28
  43. package/dist/root.js +3 -3
  44. package/dist/shell-completion.d.ts +1 -1
  45. package/dist/shell-completion.js +65 -37
  46. package/dist/shell-handoff.js +2 -2
  47. package/dist/shell.d.ts +1 -1
  48. package/dist/shell.js +4 -4
  49. package/dist/status.d.ts +5 -5
  50. package/dist/status.js +23 -23
  51. package/dist/sync-files-command.d.ts +10 -0
  52. package/dist/sync-files-command.js +137 -0
  53. package/dist/sync.js +23 -15
  54. package/dist/trigger-hook.js +9 -5
  55. package/dist/warp.js +66 -34
  56. package/dist/worktree-info.d.ts +9 -9
  57. package/dist/worktree-info.js +31 -29
  58. package/dist/worktree-management.d.ts +1 -1
  59. package/dist/worktree-management.js +26 -11
  60. package/dist/worktree-prompts.js +5 -5
  61. package/man/man1/gji-back.1 +1 -1
  62. package/man/man1/gji-clean.1 +1 -1
  63. package/man/man1/gji-completion.1 +1 -1
  64. package/man/man1/gji-config.1 +1 -1
  65. package/man/man1/gji-go.1 +1 -1
  66. package/man/man1/gji-history.1 +1 -1
  67. package/man/man1/gji-init.1 +1 -1
  68. package/man/man1/gji-ls.1 +1 -1
  69. package/man/man1/gji-new.1 +1 -1
  70. package/man/man1/gji-open.1 +1 -1
  71. package/man/man1/gji-pr.1 +1 -1
  72. package/man/man1/gji-remove.1 +1 -1
  73. package/man/man1/gji-root.1 +1 -1
  74. package/man/man1/gji-status.1 +1 -1
  75. package/man/man1/gji-sync-files.1 +23 -0
  76. package/man/man1/gji-sync.1 +1 -1
  77. package/man/man1/gji-trigger-hook.1 +1 -1
  78. package/man/man1/gji-warp.1 +1 -1
  79. package/man/man1/gji.1 +5 -1
  80. package/package.json +8 -2
package/README.md CHANGED
@@ -249,6 +249,7 @@ path=$(gji root --print)
249
249
  | `gji status [--json]` | repo overview, worktree health, ahead/behind |
250
250
  | `gji ls [--compact] [--json]` | list active worktrees |
251
251
  | `gji sync [--all]` | fetch and rebase worktrees onto default branch |
252
+ | `gji sync-files [list\|add\|remove] [paths...]` | manage local files copied into new worktrees |
252
253
  | `gji clean [--stale] [--force] [--json]` | interactively prune linked worktrees |
253
254
  | `gji remove [branch] [--force] [--json]` | remove a worktree and its branch |
254
255
  | `gji trigger-hook <hook>` | run a hook in the current worktree |
@@ -272,7 +273,7 @@ No setup required. Optional config lives in:
272
273
  | `worktreePath` | base directory for new worktrees (absolute or `~/…`); overrides the default `../worktrees/<repo>/` layout |
273
274
  | `syncRemote` | remote for `gji sync` (default: `origin`) |
274
275
  | `syncDefaultBranch` | branch to rebase onto (default: remote `HEAD`) |
275
- | `syncFiles` | files to copy from main worktree into each new worktree |
276
+ | `syncFiles` | files to copy from main worktree into each new worktree; use global per-repo config for private files |
276
277
  | `skipInstallPrompt` | `true` to disable the auto-install prompt permanently |
277
278
  | `installSaveTarget` | `"local"` or `"global"` — where **Always**/**Never** choices are persisted (default: `"local"`); set once during `gji init --write` |
278
279
  | `hooks` | lifecycle scripts (see [Hooks](#hooks)) |
@@ -287,6 +288,30 @@ No setup required. Optional config lives in:
287
288
  }
288
289
  ```
289
290
 
291
+ ### Syncing local files
292
+
293
+ Use `syncFiles` for private, gitignored, or machine-local files that every new worktree needs, such as `.env.local` or `.npmrc`. `gji new` copies these files from the main worktree before install hooks run, skips missing source files, and does not overwrite files that already exist in the target worktree.
294
+
295
+ For private files, prefer the `sync-files` command. It writes to your global per-repo config so secret filenames do not need to be committed to `.gji.json`:
296
+
297
+ ```sh
298
+ gji sync-files add .env.local .npmrc
299
+ gji sync-files list
300
+ gji sync-files remove .npmrc
301
+ ```
302
+
303
+ This stores:
304
+
305
+ ```json
306
+ {
307
+ "repos": {
308
+ "/home/me/code/my-repo": {
309
+ "syncFiles": [".env.local"]
310
+ }
311
+ }
312
+ }
313
+ ```
314
+
290
315
  ### Per-repo overrides in global config
291
316
 
292
317
  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:
package/dist/back.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type HistoryEntry } from './history.js';
1
+ import { type HistoryEntry } from "./history.js";
2
2
  export declare const BACK_OUTPUT_FILE_ENV = "GJI_BACK_OUTPUT_FILE";
3
3
  export interface BackCommandOptions {
4
4
  cwd: string;
package/dist/back.js CHANGED
@@ -1,16 +1,16 @@
1
- import { access } from 'node:fs/promises';
2
- import { basename } from 'node:path';
3
- import { loadEffectiveConfig } from './config.js';
4
- import { extractHooks, runHook } from './hooks.js';
5
- import { appendHistory, loadHistory } from './history.js';
6
- import { detectRepository } from './repo.js';
7
- import { writeShellOutput } from './shell-handoff.js';
8
- export const BACK_OUTPUT_FILE_ENV = 'GJI_BACK_OUTPUT_FILE';
1
+ import { access } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { loadEffectiveConfig } from "./config.js";
4
+ import { appendHistory, loadHistory } from "./history.js";
5
+ import { extractHooks, runHook } from "./hooks.js";
6
+ import { detectRepository } from "./repo.js";
7
+ import { writeShellOutput } from "./shell-handoff.js";
8
+ export const BACK_OUTPUT_FILE_ENV = "GJI_BACK_OUTPUT_FILE";
9
9
  export async function runBackCommand(options) {
10
10
  const history = await loadHistory(options.home);
11
11
  const steps = options.n ?? 1;
12
12
  if (steps < 1) {
13
- options.stderr('gji back: step count must be at least 1\n');
13
+ options.stderr("gji back: step count must be at least 1\n");
14
14
  return 1;
15
15
  }
16
16
  let found = 0;
@@ -31,7 +31,7 @@ export async function runBackCommand(options) {
31
31
  }
32
32
  }
33
33
  if (!target) {
34
- options.stderr('gji back: no previous worktree in history\n');
34
+ options.stderr("gji back: no previous worktree in history\n");
35
35
  options.stderr("Hint: Use 'gji go', 'gji new', or 'gji pr' to navigate between worktrees\n");
36
36
  return 1;
37
37
  }
@@ -39,7 +39,11 @@ export async function runBackCommand(options) {
39
39
  const repository = await detectRepository(target.path);
40
40
  const config = await loadEffectiveConfig(repository.repoRoot, options.home, options.stderr);
41
41
  const hooks = extractHooks(config);
42
- await runHook(hooks.afterEnter, target.path, { branch: target.branch ?? undefined, path: target.path, repo: basename(repository.repoRoot) }, options.stderr);
42
+ await runHook(hooks.afterEnter, target.path, {
43
+ branch: target.branch ?? undefined,
44
+ path: target.path,
45
+ repo: basename(repository.repoRoot),
46
+ }, options.stderr);
43
47
  }
44
48
  catch {
45
49
  // Not in a git repo or hooks unavailable — proceed without hook
@@ -49,20 +53,22 @@ export async function runBackCommand(options) {
49
53
  return 0;
50
54
  }
51
55
  export function formatHistoryList(history, cwd) {
52
- const branchWidth = Math.max('BRANCH'.length, ...history.map((e) => (e.branch ?? '(detached)').length));
53
- const lines = [' ' + 'BRANCH'.padEnd(branchWidth) + ' WHEN PATH'];
56
+ const branchWidth = Math.max("BRANCH".length, ...history.map((e) => (e.branch ?? "(detached)").length));
57
+ const lines = [
58
+ " " + "BRANCH".padEnd(branchWidth) + " WHEN PATH",
59
+ ];
54
60
  for (const entry of history) {
55
61
  const isCurrent = entry.path === cwd;
56
- const branch = (entry.branch ?? '(detached)').padEnd(branchWidth);
62
+ const branch = (entry.branch ?? "(detached)").padEnd(branchWidth);
57
63
  const when = formatAge(entry.timestamp).padEnd(10);
58
- lines.push(`${isCurrent ? '*' : ' '} ${branch} ${when} ${entry.path}`);
64
+ lines.push(`${isCurrent ? "*" : " "} ${branch} ${when} ${entry.path}`);
59
65
  }
60
- return lines.join('\n') + '\n';
66
+ return lines.join("\n") + "\n";
61
67
  }
62
68
  export function formatAge(timestamp) {
63
69
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
64
70
  if (seconds < 60)
65
- return 'just now';
71
+ return "just now";
66
72
  const minutes = Math.floor(seconds / 60);
67
73
  if (minutes < 60)
68
74
  return `${minutes}m ago`;
package/dist/clean.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { WorktreeEntry } from './repo.js';
1
+ import type { WorktreeEntry } from "./repo.js";
2
2
  export interface CleanCommandOptions {
3
3
  cwd: string;
4
4
  dryRun?: boolean;
package/dist/clean.js CHANGED
@@ -1,14 +1,15 @@
1
- import { confirm, isCancel, multiselect } from '@clack/prompts';
2
- import { loadEffectiveConfig } from './config.js';
3
- import { isBranchMergedInto, readWorktreeHealth, resolveRemoteDefaultBranch, runGit } from './git.js';
4
- import { isHeadless } from './headless.js';
5
- import { formatLastCommit, formatUpstreamState, formatWorktreeHint, readWorktreeInfos, serializeWorktreeInfo, } from './worktree-info.js';
6
- import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from './worktree-management.js';
7
- import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree } from './worktree-prompts.js';
1
+ import { confirm, isCancel, multiselect } from "@clack/prompts";
2
+ import { loadEffectiveConfig } from "./config.js";
3
+ import { isBranchMergedInto, readWorktreeHealth, resolveRemoteDefaultBranch, runGit, } from "./git.js";
4
+ import { isHeadless } from "./headless.js";
5
+ import { formatLastCommit, formatUpstreamState, formatWorktreeHint, readWorktreeInfos, serializeWorktreeInfo, } from "./worktree-info.js";
6
+ import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
7
+ import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
8
8
  export function createCleanCommand(dependencies = {}) {
9
9
  const promptForWorktrees = dependencies.promptForWorktrees ?? defaultPromptForWorktrees;
10
10
  const confirmRemoval = dependencies.confirmRemoval ?? defaultConfirmRemoval;
11
- const confirmForceRemoveWorktree = dependencies.confirmForceRemoveWorktree ?? defaultConfirmForceRemoveWorktree;
11
+ const confirmForceRemoveWorktree = dependencies.confirmForceRemoveWorktree ??
12
+ defaultConfirmForceRemoveWorktree;
12
13
  const confirmForceDeleteBranch = dependencies.confirmForceDeleteBranch ?? defaultConfirmForceDeleteBranch;
13
14
  return async function runCleanCommand(options) {
14
15
  const { linkedWorktrees, repository } = await loadLinkedWorktrees(options.cwd);
@@ -24,11 +25,11 @@ export function createCleanCommand(dependencies = {}) {
24
25
  emitNoStaleCandidates(options);
25
26
  return 0;
26
27
  }
27
- emitError(options, 'No linked worktrees to clean');
28
+ emitError(options, "No linked worktrees to clean");
28
29
  return 1;
29
30
  }
30
31
  if (!options.dryRun && !options.force && (options.json || isHeadless())) {
31
- const message = '--force is required';
32
+ const message = "--force is required";
32
33
  if (options.json) {
33
34
  emitError(options, message);
34
35
  }
@@ -38,23 +39,26 @@ export function createCleanCommand(dependencies = {}) {
38
39
  return 1;
39
40
  }
40
41
  // With --force, or non-interactive dry-runs, skip selection prompt and target all candidates.
41
- const shouldSelectAll = options.force || (options.dryRun && (options.stale || options.json || isHeadless()));
42
+ const shouldSelectAll = options.force ||
43
+ (options.dryRun && (options.stale || options.json || isHeadless()));
42
44
  const selections = shouldSelectAll
43
45
  ? cleanupCandidates.map((w) => w.path)
44
46
  : await promptForWorktrees(cleanupCandidates);
45
47
  if (!selections || selections.length === 0) {
46
- options.stderr('Aborted\n');
48
+ options.stderr("Aborted\n");
47
49
  return 1;
48
50
  }
49
51
  const selectedWorktrees = resolveSelectedWorktrees(cleanupCandidates, selections);
50
52
  if (selectedWorktrees.length !== selections.length) {
51
- options.stderr('Selected worktree no longer exists\n');
53
+ options.stderr("Selected worktree no longer exists\n");
52
54
  return 1;
53
55
  }
54
56
  const selectedWorktreeInfos = await readWorktreeInfos(selectedWorktrees);
55
57
  const selectedInfoByPath = new Map(selectedWorktreeInfos.map((info) => [info.path, info]));
56
- if (!options.dryRun && !options.force && !(await confirmRemoval(selectedWorktrees))) {
57
- options.stderr('Aborted\n');
58
+ if (!options.dryRun &&
59
+ !options.force &&
60
+ !(await confirmRemoval(selectedWorktrees))) {
61
+ options.stderr("Aborted\n");
58
62
  return 1;
59
63
  }
60
64
  if (options.dryRun) {
@@ -72,7 +76,8 @@ export function createCleanCommand(dependencies = {}) {
72
76
  const removedPaths = [];
73
77
  const removedWorktrees = [];
74
78
  for (const worktree of selectedWorktrees) {
75
- if (options.stale && !(await isStaleCleanupCandidate(repository.repoRoot, worktree, staleBaseRef))) {
79
+ if (options.stale &&
80
+ !(await isStaleCleanupCandidate(repository.repoRoot, worktree, staleBaseRef))) {
76
81
  options.stderr(`Skipped ${worktree.path}: no longer a safe stale cleanup candidate\n`);
77
82
  continue;
78
83
  }
@@ -87,9 +92,10 @@ export function createCleanCommand(dependencies = {}) {
87
92
  options.stderr(`Skipped ${worktree.path}: no longer a safe stale cleanup candidate\n`);
88
93
  continue;
89
94
  }
90
- if (!options.force && !(await confirmForceRemoveWorktree(worktree.path))) {
95
+ if (!options.force &&
96
+ !(await confirmForceRemoveWorktree(worktree.path))) {
91
97
  reportRemovedPaths(removedPaths, options.stderr);
92
- options.stderr('Aborted\n');
98
+ options.stderr("Aborted\n");
93
99
  return 1;
94
100
  }
95
101
  try {
@@ -113,7 +119,8 @@ export function createCleanCommand(dependencies = {}) {
113
119
  if (!isBranchUnmergedError(error)) {
114
120
  throw error;
115
121
  }
116
- if (options.force || (await confirmForceDeleteBranch(worktree.branch))) {
122
+ if (options.force ||
123
+ (await confirmForceDeleteBranch(worktree.branch))) {
117
124
  try {
118
125
  await forceDeleteBranch(repository.repoRoot, worktree.branch);
119
126
  }
@@ -152,7 +159,7 @@ async function filterStaleCleanupCandidates(repoRoot, worktrees, baseBranch) {
152
159
  }
153
160
  async function resolveStaleBaseRef(repoRoot, stderr) {
154
161
  const config = await loadEffectiveConfig(repoRoot, undefined, stderr);
155
- const remote = resolveConfiguredString(config.syncRemote) ?? 'origin';
162
+ const remote = resolveConfiguredString(config.syncRemote) ?? "origin";
156
163
  const configuredDefaultBranch = resolveConfiguredString(config.syncDefaultBranch);
157
164
  if (configuredDefaultBranch) {
158
165
  return await resolveFetchedRemoteRef(repoRoot, remote, configuredDefaultBranch);
@@ -169,7 +176,7 @@ async function resolveStaleBaseRef(repoRoot, stderr) {
169
176
  }
170
177
  async function resolveFetchedRemoteRef(repoRoot, remote, branch) {
171
178
  try {
172
- await runGit(repoRoot, ['fetch', '--prune', remote]);
179
+ await runGit(repoRoot, ["fetch", "--prune", remote]);
173
180
  return `${remote}/${branch}`;
174
181
  }
175
182
  catch {
@@ -177,7 +184,7 @@ async function resolveFetchedRemoteRef(repoRoot, remote, branch) {
177
184
  }
178
185
  }
179
186
  function resolveConfiguredString(value) {
180
- return typeof value === 'string' && value.length > 0 ? value : null;
187
+ return typeof value === "string" && value.length > 0 ? value : null;
181
188
  }
182
189
  async function isStaleCleanupCandidate(repoRoot, worktree, baseBranch) {
183
190
  if (baseBranch === null) {
@@ -187,7 +194,7 @@ async function isStaleCleanupCandidate(repoRoot, worktree, baseBranch) {
187
194
  return false;
188
195
  }
189
196
  const health = await readWorktreeHealth(worktree.path);
190
- if (health.status !== 'clean' || !health.upstreamGone) {
197
+ if (health.status !== "clean" || !health.upstreamGone) {
191
198
  return false;
192
199
  }
193
200
  return isBranchMergedInto(repoRoot, worktree.branch, baseBranch);
@@ -207,15 +214,15 @@ function resolveSelectedWorktrees(worktrees, selections) {
207
214
  }
208
215
  function reportRemovedPaths(paths, stderr) {
209
216
  if (paths.length > 0) {
210
- stderr(`Already removed: ${paths.join(', ')}\n`);
217
+ stderr(`Already removed: ${paths.join(", ")}\n`);
211
218
  }
212
219
  }
213
220
  function formatCleanInfo(info) {
214
- const branch = info.branch === null ? 'detached' : `branch: ${info.branch}`;
221
+ const branch = info.branch === null ? "detached" : `branch: ${info.branch}`;
215
222
  const status = `status: ${info.status}`;
216
223
  const upstream = `upstream: ${formatUpstreamState(info.upstream)}`;
217
224
  const last = `last: ${formatLastCommit(info.lastCommitTimestamp)}`;
218
- return [branch, status, upstream, last].join(', ');
225
+ return [branch, status, upstream, last].join(", ");
219
226
  }
220
227
  function emitError(options, message) {
221
228
  if (options.json) {
@@ -233,7 +240,7 @@ function emitNoStaleCandidates(options) {
233
240
  options.stdout(`${JSON.stringify(payload, null, 2)}\n`);
234
241
  return;
235
242
  }
236
- options.stdout('No stale linked worktrees to clean\n');
243
+ options.stdout("No stale linked worktrees to clean\n");
237
244
  }
238
245
  function toMessage(error) {
239
246
  return error instanceof Error ? error.message : String(error);
@@ -241,11 +248,11 @@ function toMessage(error) {
241
248
  async function defaultPromptForWorktrees(worktrees) {
242
249
  const infos = await readWorktreeInfos(worktrees);
243
250
  const choice = await multiselect({
244
- message: 'Choose worktrees to clean',
251
+ message: "Choose worktrees to clean",
245
252
  options: worktrees.map((worktree, i) => {
246
253
  return {
247
254
  hint: formatWorktreeHint(infos[i]),
248
- label: worktree.branch ?? '(detached)',
255
+ label: worktree.branch ?? "(detached)",
249
256
  value: worktree.path,
250
257
  };
251
258
  }),
@@ -256,18 +263,20 @@ async function defaultPromptForWorktrees(worktrees) {
256
263
  async function defaultConfirmRemoval(worktrees) {
257
264
  const branchCount = worktrees.filter((worktree) => worktree.branch !== null).length;
258
265
  const detachedCount = worktrees.length - branchCount;
259
- const messageParts = [`Remove ${worktrees.length} linked worktree${worktrees.length === 1 ? '' : 's'}`];
266
+ const messageParts = [
267
+ `Remove ${worktrees.length} linked worktree${worktrees.length === 1 ? "" : "s"}`,
268
+ ];
260
269
  if (branchCount > 0) {
261
- messageParts.push(`delete ${branchCount} branch${branchCount === 1 ? '' : 'es'}`);
270
+ messageParts.push(`delete ${branchCount} branch${branchCount === 1 ? "" : "es"}`);
262
271
  }
263
272
  if (detachedCount > 0) {
264
- messageParts.push(`remove ${detachedCount} detached worktree${detachedCount === 1 ? '' : 's'}`);
273
+ messageParts.push(`remove ${detachedCount} detached worktree${detachedCount === 1 ? "" : "s"}`);
265
274
  }
266
275
  const choice = await confirm({
267
- active: 'Yes',
268
- inactive: 'No',
276
+ active: "Yes",
277
+ inactive: "No",
269
278
  initialValue: true,
270
- message: `${messageParts.join(', ')}?`,
279
+ message: `${messageParts.join(", ")}?`,
271
280
  });
272
281
  return !isCancel(choice) && choice;
273
282
  }
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Command } from 'commander';
1
+ import { Command } from "commander";
2
2
  export interface RunCliOptions {
3
3
  cwd?: string;
4
4
  stderr?: (chunk: string) => void;