@solaqua/gji 0.7.0 → 0.7.2

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 (52) hide show
  1. package/dist/back.js +1 -1
  2. package/dist/clean.d.ts +2 -1
  3. package/dist/clean.js +8 -16
  4. package/dist/cli.js +7 -6
  5. package/dist/gji-bundle.mjs +568 -273
  6. package/dist/go.d.ts +2 -4
  7. package/dist/go.js +19 -48
  8. package/dist/history.d.ts +1 -0
  9. package/dist/history.js +12 -5
  10. package/dist/hooks.d.ts +3 -3
  11. package/dist/hooks.js +3 -3
  12. package/dist/init.d.ts +3 -3
  13. package/dist/init.js +12 -12
  14. package/dist/install-prompt.js +22 -21
  15. package/dist/new.js +72 -10
  16. package/dist/open.d.ts +2 -2
  17. package/dist/open.js +22 -18
  18. package/dist/pr.js +4 -4
  19. package/dist/remove.d.ts +3 -2
  20. package/dist/remove.js +8 -13
  21. package/dist/repo.d.ts +0 -1
  22. package/dist/repo.js +0 -9
  23. package/dist/run-hook.d.ts +6 -0
  24. package/dist/{trigger-hook.js → run-hook.js} +13 -7
  25. package/dist/shell-completion.js +5 -5
  26. package/dist/warp.js +24 -53
  27. package/dist/worktree-info.d.ts +0 -1
  28. package/dist/worktree-info.js +17 -11
  29. package/dist/worktree-picker.d.ts +14 -0
  30. package/dist/worktree-picker.js +228 -0
  31. package/man/man1/gji-back.1 +1 -1
  32. package/man/man1/gji-clean.1 +1 -1
  33. package/man/man1/gji-completion.1 +1 -1
  34. package/man/man1/gji-config.1 +1 -1
  35. package/man/man1/gji-go.1 +1 -1
  36. package/man/man1/gji-history.1 +1 -1
  37. package/man/man1/gji-init.1 +1 -1
  38. package/man/man1/gji-ls.1 +1 -1
  39. package/man/man1/gji-new.1 +1 -1
  40. package/man/man1/gji-open.1 +1 -1
  41. package/man/man1/gji-pr.1 +1 -1
  42. package/man/man1/gji-remove.1 +1 -1
  43. package/man/man1/gji-root.1 +1 -1
  44. package/man/man1/gji-run-hook.1 +9 -0
  45. package/man/man1/gji-status.1 +1 -1
  46. package/man/man1/gji-sync-files.1 +1 -1
  47. package/man/man1/gji-sync.1 +1 -1
  48. package/man/man1/gji-warp.1 +1 -1
  49. package/man/man1/gji.1 +6 -4
  50. package/package.json +3 -7
  51. package/dist/trigger-hook.d.ts +0 -6
  52. package/man/man1/gji-trigger-hook.1 +0 -9
package/dist/remove.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { basename } from "node:path";
2
- import { confirm, isCancel, select } from "@clack/prompts";
2
+ import { confirm, isCancel } from "@clack/prompts";
3
3
  import { loadEffectiveConfig } from "./config.js";
4
4
  import { isHeadless } from "./headless.js";
5
5
  import { extractHooks, runHook } from "./hooks.js";
6
- import { sortByCurrentFirst } from "./repo.js";
7
6
  import { writeShellOutput } from "./shell-handoff.js";
8
7
  import { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isWorktreeDirtyError, loadLinkedWorktrees, removeWorktree, } from "./worktree-management.js";
8
+ import { buildWorktreePromptEntries, promptForSingleWorktree, } from "./worktree-picker.js";
9
9
  import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "./worktree-prompts.js";
10
10
  const REMOVE_OUTPUT_FILE_ENV = "GJI_REMOVE_OUTPUT_FILE";
11
11
  export function createRemoveCommand(dependencies = {}) {
@@ -31,7 +31,10 @@ export function createRemoveCommand(dependencies = {}) {
31
31
  return 1;
32
32
  }
33
33
  const selection = options.branch ??
34
- (await promptForWorktree(sortByCurrentFirst(linkedWorktrees)));
34
+ (await promptForWorktree(await buildWorktreePromptEntries(linkedWorktrees.map((worktree) => ({
35
+ repoName: repository.repoName,
36
+ worktree,
37
+ })))));
35
38
  if (!selection) {
36
39
  options.stderr("Aborted\n");
37
40
  return 1;
@@ -71,7 +74,7 @@ export function createRemoveCommand(dependencies = {}) {
71
74
  }
72
75
  const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
73
76
  const hooks = extractHooks(config);
74
- await runHook(hooks.beforeRemove, worktree.path, {
77
+ await runHook(hooks["before-remove"], worktree.path, {
75
78
  branch: worktree.branch ?? undefined,
76
79
  path: worktree.path,
77
80
  repo: basename(repository.repoRoot),
@@ -129,15 +132,7 @@ export function createRemoveCommand(dependencies = {}) {
129
132
  }
130
133
  export const runRemoveCommand = createRemoveCommand();
131
134
  async function defaultPromptForWorktree(worktrees) {
132
- const choice = await select({
133
- message: "Choose a worktree to finish",
134
- options: worktrees.map((worktree) => ({
135
- hint: worktree.isCurrent ? `${worktree.path} (current)` : worktree.path,
136
- label: worktree.branch ?? "(detached)",
137
- value: worktree.path,
138
- })),
139
- });
140
- return isCancel(choice) ? null : choice;
135
+ return promptForSingleWorktree("Choose a worktree to finish", worktrees);
141
136
  }
142
137
  async function defaultConfirmRemoval(worktree) {
143
138
  const choice = await confirm({
package/dist/repo.d.ts CHANGED
@@ -14,4 +14,3 @@ export declare function detectRepository(cwd: string): Promise<RepositoryContext
14
14
  export declare function resolveWorktreePath(repoRoot: string, branch: string, basePath?: string): string;
15
15
  export declare function validateBranchName(name: string): string | null;
16
16
  export declare function listWorktrees(cwd: string): Promise<WorktreeEntry[]>;
17
- export declare function sortByCurrentFirst(worktrees: WorktreeEntry[]): WorktreeEntry[];
package/dist/repo.js CHANGED
@@ -88,15 +88,6 @@ export async function listWorktrees(cwd) {
88
88
  };
89
89
  });
90
90
  }
91
- export function sortByCurrentFirst(worktrees) {
92
- return [...worktrees].sort((a, b) => {
93
- if (a.isCurrent && !b.isCurrent)
94
- return -1;
95
- if (!a.isCurrent && b.isCurrent)
96
- return 1;
97
- return 0;
98
- });
99
- }
100
91
  function findPorcelainValue(block, key) {
101
92
  const value = findOptionalPorcelainValue(block, key);
102
93
  if (!value) {
@@ -0,0 +1,6 @@
1
+ export interface RunHookCommandOptions {
2
+ cwd: string;
3
+ hook: string;
4
+ stderr: (chunk: string) => void;
5
+ }
6
+ export declare function runHookCommand(options: RunHookCommandOptions): Promise<number>;
@@ -2,19 +2,25 @@ import { loadEffectiveConfig } from "./config.js";
2
2
  import { extractHooks, runHook } from "./hooks.js";
3
3
  import { detectRepository, listWorktrees } from "./repo.js";
4
4
  const VALID_HOOKS = [
5
- "afterCreate",
6
- "afterEnter",
7
- "beforeRemove",
5
+ "after-create",
6
+ "after-enter",
7
+ "before-remove",
8
8
  ];
9
+ const CAMEL_ALIASES = {
10
+ afterCreate: "after-create",
11
+ afterEnter: "after-enter",
12
+ beforeRemove: "before-remove",
13
+ };
9
14
  function isValidHook(hook) {
10
15
  return VALID_HOOKS.includes(hook);
11
16
  }
12
- export async function runTriggerHookCommand(options) {
13
- if (!isValidHook(options.hook)) {
14
- options.stderr(`gji trigger-hook: unknown hook '${options.hook}'. Valid hooks: ${VALID_HOOKS.join(", ")}\n`);
17
+ export async function runHookCommand(options) {
18
+ const normalized = CAMEL_ALIASES[options.hook] ?? options.hook;
19
+ if (!isValidHook(normalized)) {
20
+ options.stderr(`gji run-hook: unknown hook '${options.hook}'. Valid hooks: ${VALID_HOOKS.join(", ")}\n`);
15
21
  return 1;
16
22
  }
17
- const hookName = options.hook;
23
+ const hookName = normalized;
18
24
  const repository = await detectRepository(options.cwd);
19
25
  const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
20
26
  const hooks = extractHooks(config);
@@ -27,14 +27,14 @@ const TOP_LEVEL_COMMANDS = [
27
27
  },
28
28
  { name: "rm", description: "alias of remove" },
29
29
  {
30
- name: "trigger-hook",
30
+ name: "run-hook",
31
31
  description: "run a named hook in the current worktree",
32
32
  },
33
33
  { name: "warp", description: "jump to any worktree across all known repos" },
34
34
  { name: "config", description: "manage global config defaults" },
35
35
  ];
36
36
  const SHELL_NAMES = ["bash", "fish", "zsh"];
37
- const HOOK_NAMES = ["afterCreate", "afterEnter", "beforeRemove"];
37
+ const HOOK_NAMES = ["after-create", "after-enter", "before-remove"];
38
38
  const CONFIG_KEYS = Array.from(KNOWN_GLOBAL_CONFIG_KEYS);
39
39
  export function renderShellCompletion(shell) {
40
40
  switch (shell) {
@@ -116,7 +116,7 @@ _gji_completion() {
116
116
  remove|rm)
117
117
  COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) -f --force --dry-run --json --help" -- "$cur") )
118
118
  ;;
119
- trigger-hook)
119
+ run-hook)
120
120
  COMPREPLY=( $(compgen -W "${hooks} --help" -- "$cur") )
121
121
  ;;
122
122
  warp)
@@ -149,7 +149,7 @@ complete -F _gji_completion gji`;
149
149
  function renderFishCompletion() {
150
150
  const commandLines = TOP_LEVEL_COMMANDS.map((command) => `complete -c gji -n '__fish_use_subcommand' -a '${command.name}' -d '${escapeSingleQuotes(command.description)}'`).join("\n");
151
151
  const shellLines = SHELL_NAMES.map((shell) => `complete -c gji -n '__fish_seen_subcommand_from init' -a '${shell}' -d 'shell'`).join("\n");
152
- const hookLines = HOOK_NAMES.map((hook) => `complete -c gji -n '__fish_seen_subcommand_from trigger-hook' -a '${hook}' -d 'hook'`).join("\n");
152
+ const hookLines = HOOK_NAMES.map((hook) => `complete -c gji -n '__fish_seen_subcommand_from run-hook' -a '${hook}' -d 'hook'`).join("\n");
153
153
  const configKeyLines = CONFIG_KEYS.map((key) => `complete -c gji -n '__gji_should_complete_config_key' -a '${key}' -d 'config key'`).join("\n");
154
154
  return `function __gji_worktree_branches
155
155
  command gji ls --compact 2>/dev/null | awk 'NR > 1 { branch = ($1 == "*" ? $2 : $1); if (branch != "(detached)") print branch }'
@@ -309,7 +309,7 @@ case "\${words[2]}" in
309
309
  remove|rm)
310
310
  _arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch]' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch:->worktrees'
311
311
  ;;
312
- trigger-hook)
312
+ run-hook)
313
313
  _arguments "2:hook:(${hooks})"
314
314
  ;;
315
315
  warp)
package/dist/warp.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { basename, resolve } from "node:path";
3
3
  import { isCancel, select } from "@clack/prompts";
4
- import { readWorktreeHealth } from "./git.js";
5
4
  import { isHeadless } from "./headless.js";
6
- import { appendHistory } from "./history.js";
5
+ import { recordWorktreeUsage } from "./history.js";
7
6
  import { runNewCommand } from "./new.js";
8
- import { listWorktrees } from "./repo.js";
7
+ import { detectRepository, listWorktrees } from "./repo.js";
9
8
  import { loadRegistry } from "./repo-registry.js";
10
9
  import { writeShellOutput } from "./shell-handoff.js";
10
+ import { buildWorktreePromptEntries, promptForSingleWorktree, resolveWorktreeQuery, } from "./worktree-picker.js";
11
11
  const WARP_OUTPUT_FILE_ENV = "GJI_WARP_OUTPUT_FILE";
12
12
  export async function runWarpCommand(options) {
13
13
  if (options.newWorktree) {
@@ -44,7 +44,7 @@ async function runWarpNavigate(options) {
44
44
  options.stdout(`${JSON.stringify({ branch: target.branch, path: target.path }, null, 2)}\n`);
45
45
  return 0;
46
46
  }
47
- appendHistory(target.path, target.branch).catch(() => undefined);
47
+ await recordWorktreeUsage(target.path, target.branch);
48
48
  await writeShellOutput(WARP_OUTPUT_FILE_ENV, target.path, options.stdout);
49
49
  return 0;
50
50
  }
@@ -130,17 +130,6 @@ async function canonicalizeRepoPath(repoPath) {
130
130
  return resolve(repoPath);
131
131
  }
132
132
  }
133
- function findByQuery(items, query) {
134
- const slashIdx = query.indexOf("/");
135
- if (slashIdx !== -1) {
136
- const repoQuery = query.slice(0, slashIdx);
137
- const branchQuery = query.slice(slashIdx + 1);
138
- const match = items.find((item) => item.repoName === repoQuery && item.worktree.branch === branchQuery);
139
- if (match)
140
- return match;
141
- }
142
- return items.find((item) => item.worktree.branch === query) ?? null;
143
- }
144
133
  export async function resolveWarpTarget(options) {
145
134
  const cmd = options.commandName ?? "gji";
146
135
  const emitError = (message, hint) => {
@@ -154,6 +143,9 @@ export async function resolveWarpTarget(options) {
154
143
  }
155
144
  };
156
145
  const registry = await loadRegistry();
146
+ const currentRoot = await detectRepository(options.cwd)
147
+ .then((repository) => repository.currentRoot)
148
+ .catch(() => null);
157
149
  if (registry.length === 0) {
158
150
  emitError("not in a git repository and no repos registered yet.", "Use any gji command inside a repository to register it.\n");
159
151
  return null;
@@ -168,61 +160,40 @@ export async function resolveWarpTarget(options) {
168
160
  continue;
169
161
  const { repoName, worktrees } = result.value;
170
162
  for (const worktree of worktrees) {
171
- allItems.push({ repoName, worktree });
163
+ allItems.push({
164
+ repoName,
165
+ worktree: {
166
+ ...worktree,
167
+ isCurrent: currentRoot !== null && worktree.path === currentRoot,
168
+ },
169
+ });
172
170
  }
173
171
  }
174
172
  if (allItems.length === 0) {
175
173
  emitError("no accessible worktrees found in any registered repo.");
176
174
  return null;
177
175
  }
176
+ const promptSources = allItems.map((item) => ({
177
+ repoName: item.repoName,
178
+ worktree: item.worktree,
179
+ }));
178
180
  if (options.branch) {
179
- const match = findByQuery(allItems, options.branch);
181
+ const match = resolveWorktreeQuery(promptSources, options.branch);
180
182
  if (!match) {
181
183
  emitError(`no worktree found matching: ${options.branch}`);
182
184
  return null;
183
185
  }
184
186
  return { branch: match.worktree.branch, path: match.worktree.path };
185
187
  }
186
- const path = await promptForWarpTarget(allItems);
188
+ const promptEntries = await buildWorktreePromptEntries(promptSources);
189
+ const path = await promptForWarpTarget(promptEntries);
187
190
  if (!path) {
188
191
  options.stderr("Aborted\n");
189
192
  return null;
190
193
  }
191
- const chosen = allItems.find((item) => item.worktree.path === path);
192
- return { branch: chosen?.worktree.branch ?? null, path };
194
+ const chosen = promptEntries.find((item) => item.path === path);
195
+ return { branch: chosen?.branch ?? null, path };
193
196
  }
194
197
  async function promptForWarpTarget(items) {
195
- const healthResults = await Promise.allSettled(items.map((item) => readWorktreeHealth(item.worktree.path)));
196
- const choice = await select({
197
- message: "Warp to a worktree",
198
- options: items.map((item, i) => {
199
- const health = healthResults[i].status === "fulfilled" ? healthResults[i].value : null;
200
- const upstream = health ? formatHint(item.worktree.branch, health) : null;
201
- const label = `${item.repoName} › ${item.worktree.branch ?? "(detached)"}`;
202
- const pathHint = item.worktree.isCurrent
203
- ? `${item.worktree.path} (current)`
204
- : item.worktree.path;
205
- const hint = upstream ? `${upstream} · ${pathHint}` : pathHint;
206
- return { hint, label, value: item.worktree.path };
207
- }),
208
- });
209
- if (isCancel(choice)) {
210
- return null;
211
- }
212
- return choice;
213
- }
214
- function formatHint(branch, health) {
215
- if (branch === null)
216
- return null;
217
- if (!health.hasUpstream)
218
- return "no upstream";
219
- if (health.upstreamGone)
220
- return "upstream gone";
221
- if (health.ahead === 0 && health.behind === 0)
222
- return "up to date";
223
- if (health.ahead === 0)
224
- return `behind ${health.behind}`;
225
- if (health.behind === 0)
226
- return `ahead ${health.ahead}`;
227
- return `ahead ${health.ahead}, behind ${health.behind}`;
198
+ return promptForSingleWorktree("Warp to a worktree", items);
228
199
  }
@@ -27,7 +27,6 @@ export type UpstreamState = {
27
27
  };
28
28
  export declare function readWorktreeInfos(worktrees: WorktreeEntry[]): Promise<WorktreeInfo[]>;
29
29
  export declare function serializeWorktreeInfo(info: WorktreeInfo): SerializedWorktreeInfo;
30
- export declare function formatWorktreeHint(info: WorktreeInfo): string;
31
30
  export declare function formatUpstreamState(upstream: UpstreamState): string;
32
31
  export declare function formatLastCommit(timestampSeconds: number | null): string;
33
32
  export declare function formatRelativeAge(timestampSeconds: number): string;
@@ -1,6 +1,22 @@
1
1
  import { readBranchLastCommitTimestamp, readWorktreeHealth, } from "./git.js";
2
+ const MAX_WORKTREE_INFO_READ_CONCURRENCY = 8;
2
3
  export async function readWorktreeInfos(worktrees) {
3
- return Promise.all(worktrees.map((worktree) => readWorktreeInfo(worktree)));
4
+ return mapWithConcurrency(worktrees, MAX_WORKTREE_INFO_READ_CONCURRENCY, readWorktreeInfo);
5
+ }
6
+ async function mapWithConcurrency(items, limit, mapper) {
7
+ const results = new Array(items.length);
8
+ let nextIndex = 0;
9
+ async function readNext() {
10
+ for (;;) {
11
+ const index = nextIndex;
12
+ nextIndex += 1;
13
+ if (index >= items.length)
14
+ return;
15
+ results[index] = await mapper(items[index]);
16
+ }
17
+ }
18
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => readNext()));
19
+ return results;
4
20
  }
5
21
  async function readWorktreeInfo(worktree) {
6
22
  const [healthResult, lastCommitResult] = await Promise.allSettled([
@@ -46,16 +62,6 @@ export function serializeWorktreeInfo(info) {
46
62
  upstream: info.upstream,
47
63
  };
48
64
  }
49
- export function formatWorktreeHint(info) {
50
- const details = [
51
- `status: ${info.status}`,
52
- `upstream: ${formatUpstreamState(info.upstream)}`,
53
- ];
54
- if (info.lastCommitTimestamp !== null) {
55
- details.push(`last: ${formatRelativeAge(info.lastCommitTimestamp)}`);
56
- }
57
- return `${info.path} (${details.join(", ")})`;
58
- }
59
65
  export function formatUpstreamState(upstream) {
60
66
  if (upstream.kind === "detached") {
61
67
  return "n/a";
@@ -0,0 +1,14 @@
1
+ import type { WorktreeEntry } from "./repo.js";
2
+ export interface WorktreePromptSource {
3
+ repoName: string;
4
+ worktree: WorktreeEntry;
5
+ }
6
+ export interface WorktreePromptEntry extends WorktreeEntry {
7
+ group: "recent" | "other";
8
+ label: string;
9
+ repoName: string;
10
+ }
11
+ export declare function buildWorktreePromptEntries(sources: WorktreePromptSource[]): Promise<WorktreePromptEntry[]>;
12
+ export declare function resolveWorktreeQuery(sources: WorktreePromptSource[], query: string): WorktreePromptSource | null;
13
+ export declare function promptForSingleWorktree(message: string, worktrees: WorktreePromptEntry[]): Promise<string | null>;
14
+ export declare function promptForMultipleWorktrees(message: string, worktrees: WorktreePromptEntry[]): Promise<string[] | null>;
@@ -0,0 +1,228 @@
1
+ import { groupMultiselect, isCancel, select } from "@clack/prompts";
2
+ import { loadHistory } from "./history.js";
3
+ import { readWorktreeInfos, } from "./worktree-info.js";
4
+ export async function buildWorktreePromptEntries(sources) {
5
+ const [history, infos] = await Promise.all([
6
+ loadHistory(),
7
+ readWorktreeInfos(sources.map((source) => source.worktree)),
8
+ ]);
9
+ const historyByPath = new Map(history.map((entry) => [entry.path, entry]));
10
+ const entries = sources.map((source, index) => buildWorktreePromptEntry(source, infos[index], historyByPath.get(source.worktree.path)?.timestamp ?? null, Date.now()));
11
+ return entries
12
+ .sort(comparePromptEntries)
13
+ .map(({ lastActivityTimestamp: _lastActivityTimestamp, ...entry }) => entry);
14
+ }
15
+ export function resolveWorktreeQuery(sources, query) {
16
+ const normalizedQuery = normalizeQuery(query);
17
+ if (normalizedQuery === null)
18
+ return null;
19
+ const matches = findWorktreePromptSourceMatches(sources, normalizedQuery);
20
+ if (isAmbiguousRepoOnlyQuery(matches, normalizedQuery))
21
+ return null;
22
+ return matches[0]?.source ?? null;
23
+ }
24
+ function findWorktreePromptSourceMatches(sources, normalizedQuery) {
25
+ return sources
26
+ .flatMap((source) => {
27
+ const matchScore = scoreWorktreeMatch({
28
+ ...source.worktree,
29
+ repoName: source.repoName,
30
+ }, normalizedQuery);
31
+ return matchScore === null ? [] : [{ matchScore, source }];
32
+ })
33
+ .sort(compareQueryMatches);
34
+ }
35
+ function isAmbiguousRepoOnlyQuery(matches, query) {
36
+ if (matches[0]?.matchScore === 1000)
37
+ return false;
38
+ return (matches.filter((match) => match.source.repoName.toLowerCase() === query)
39
+ .length > 1);
40
+ }
41
+ export async function promptForSingleWorktree(message, worktrees) {
42
+ const choice = await select({
43
+ message,
44
+ options: worktrees.map((worktree) => ({
45
+ label: worktree.label,
46
+ value: worktree.path,
47
+ })),
48
+ maxItems: 12,
49
+ });
50
+ return isCancel(choice) ? null : choice;
51
+ }
52
+ export async function promptForMultipleWorktrees(message, worktrees) {
53
+ const choice = await groupMultiselect({
54
+ message,
55
+ options: groupPromptEntries(worktrees),
56
+ required: true,
57
+ selectableGroups: false,
58
+ });
59
+ return isCancel(choice) ? null : choice;
60
+ }
61
+ function compareQueryMatches(a, b) {
62
+ if (a.matchScore !== b.matchScore) {
63
+ return b.matchScore - a.matchScore;
64
+ }
65
+ if (a.source.worktree.isCurrent && !b.source.worktree.isCurrent)
66
+ return -1;
67
+ if (!a.source.worktree.isCurrent && b.source.worktree.isCurrent)
68
+ return 1;
69
+ return (a.source.repoName.localeCompare(b.source.repoName) ||
70
+ (a.source.worktree.branch ?? "").localeCompare(b.source.worktree.branch ?? "") ||
71
+ a.source.worktree.path.localeCompare(b.source.worktree.path));
72
+ }
73
+ function groupPromptEntries(worktrees) {
74
+ const groups = {};
75
+ for (const worktree of worktrees) {
76
+ const group = worktree.group === "recent" ? "Recent worktrees" : "Other worktrees";
77
+ groups[group] ??= [];
78
+ groups[group].push({
79
+ label: worktree.label,
80
+ value: worktree.path,
81
+ });
82
+ }
83
+ return groups;
84
+ }
85
+ function buildWorktreePromptEntry(source, info, lastUsedTimestamp, now) {
86
+ const lastWorkedTimestamp = info.lastCommitTimestamp === null ? null : info.lastCommitTimestamp * 1000;
87
+ const lastActivityTimestamp = lastUsedTimestamp ?? lastWorkedTimestamp;
88
+ const lastActivityType = lastUsedTimestamp !== null
89
+ ? "used"
90
+ : lastWorkedTimestamp !== null
91
+ ? "worked"
92
+ : null;
93
+ const branch = source.worktree.branch ?? "(detached)";
94
+ const badges = buildStatusBadges(info);
95
+ const recency = formatPromptRecency(lastActivityTimestamp, lastActivityType, now);
96
+ const status = badges.length > 0 ? badges.map((badge) => `[${badge}]`).join(" ") : null;
97
+ const path = middleEllipsize(source.worktree.path, 76);
98
+ const label = [
99
+ middleEllipsize(source.repoName, 22),
100
+ middleEllipsize(branch, 34),
101
+ status,
102
+ recency,
103
+ path,
104
+ ]
105
+ .filter((part) => part !== null && part.length > 0)
106
+ .join(" · ");
107
+ return {
108
+ ...source.worktree,
109
+ group: lastUsedTimestamp !== null ? "recent" : "other",
110
+ label,
111
+ lastActivityTimestamp,
112
+ repoName: source.repoName,
113
+ };
114
+ }
115
+ function buildStatusBadges(info) {
116
+ const badges = [];
117
+ if (info.isCurrent) {
118
+ badges.push("current");
119
+ }
120
+ if (info.branch === null) {
121
+ badges.push("detached");
122
+ }
123
+ if (info.status === "dirty") {
124
+ badges.push("dirty");
125
+ }
126
+ if (info.upstream.kind === "stale") {
127
+ badges.push("stale", "gone");
128
+ }
129
+ if (isUpToDate(info.upstream)) {
130
+ badges.push("up to date");
131
+ }
132
+ return badges;
133
+ }
134
+ function isUpToDate(upstream) {
135
+ return (upstream.kind === "tracked" && upstream.ahead === 0 && upstream.behind === 0);
136
+ }
137
+ function formatPromptRecency(timestamp, type, now) {
138
+ if (timestamp === null || type === null) {
139
+ return "last used: never";
140
+ }
141
+ const label = type === "used" ? "last used" : "last worked";
142
+ return `${label}: ${formatPickerAge(timestamp, now)}`;
143
+ }
144
+ function formatPickerAge(timestamp, now) {
145
+ const ageSeconds = Math.max(0, Math.floor((now - timestamp) / 1000));
146
+ if (ageSeconds < 60) {
147
+ return "now";
148
+ }
149
+ if (ageSeconds < 60 * 60) {
150
+ return `${Math.floor(ageSeconds / 60)}m ago`;
151
+ }
152
+ if (ageSeconds < 24 * 60 * 60) {
153
+ return `${Math.floor(ageSeconds / (60 * 60))}h ago`;
154
+ }
155
+ if (isYesterday(timestamp, now)) {
156
+ return "yesterday";
157
+ }
158
+ return new Intl.DateTimeFormat("en-US", {
159
+ day: "numeric",
160
+ month: "short",
161
+ }).format(new Date(timestamp));
162
+ }
163
+ function isYesterday(timestamp, now) {
164
+ const date = new Date(timestamp);
165
+ const yesterday = new Date(now);
166
+ yesterday.setDate(yesterday.getDate() - 1);
167
+ return (date.getFullYear() === yesterday.getFullYear() &&
168
+ date.getMonth() === yesterday.getMonth() &&
169
+ date.getDate() === yesterday.getDate());
170
+ }
171
+ function buildSearchText(repoName, worktree) {
172
+ return [
173
+ repoName,
174
+ worktree.branch ?? "detached",
175
+ worktree.path,
176
+ `${repoName}/${worktree.branch ?? "detached"}`,
177
+ ]
178
+ .join(" ")
179
+ .toLowerCase();
180
+ }
181
+ function normalizeQuery(query) {
182
+ const normalized = query?.trim().toLowerCase();
183
+ return normalized && normalized.length > 0 ? normalized : null;
184
+ }
185
+ function comparePromptEntries(a, b) {
186
+ if (a.isCurrent && !b.isCurrent)
187
+ return -1;
188
+ if (!a.isCurrent && b.isCurrent)
189
+ return 1;
190
+ if (a.group !== b.group) {
191
+ return groupRank(a.group) - groupRank(b.group);
192
+ }
193
+ const aRecent = a.lastActivityTimestamp ?? 0;
194
+ const bRecent = b.lastActivityTimestamp ?? 0;
195
+ if (aRecent !== bRecent) {
196
+ return bRecent - aRecent;
197
+ }
198
+ return (a.repoName.localeCompare(b.repoName) ||
199
+ (a.branch ?? "").localeCompare(b.branch ?? "") ||
200
+ a.path.localeCompare(b.path));
201
+ }
202
+ function groupRank(group) {
203
+ return group === "recent" ? 0 : 1;
204
+ }
205
+ function scoreWorktreeMatch(entry, query) {
206
+ const branch = entry.branch ?? "detached";
207
+ const exactCandidates = [
208
+ branch,
209
+ entry.path,
210
+ `${entry.repoName}/${branch}`,
211
+ ].map((candidate) => candidate.toLowerCase());
212
+ if (exactCandidates.includes(query)) {
213
+ return 1000;
214
+ }
215
+ return buildSearchText(entry.repoName, entry).includes(query) ? 1 : null;
216
+ }
217
+ function middleEllipsize(value, maxLength) {
218
+ if (value.length <= maxLength) {
219
+ return value;
220
+ }
221
+ if (maxLength <= 1) {
222
+ return "…";
223
+ }
224
+ const keep = maxLength - 1;
225
+ const start = Math.ceil(keep / 2);
226
+ const end = Math.floor(keep / 2);
227
+ return `${value.slice(0, start)}…${value.slice(value.length - end)}`;
228
+ }
@@ -1,4 +1,4 @@
1
- .TH GJI\-BACK 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-BACK 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-back \- navigate to the previously visited worktree, optionally N steps back
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-CLEAN 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-CLEAN 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-clean \- interactively prune linked worktrees
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-COMPLETION 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-COMPLETION 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-completion \- print shell completion definitions
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-CONFIG 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-CONFIG 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-config \- manage global config defaults
4
4
  .SH SYNOPSIS
package/man/man1/gji-go.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH GJI\-GO 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-GO 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-go \- print or select a worktree path
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-HISTORY 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-HISTORY 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-history \- show navigation history
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-INIT 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-INIT 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-init \- print or install shell integration
4
4
  .SH SYNOPSIS
package/man/man1/gji-ls.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH GJI\-LS 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-LS 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-ls \- list active worktrees
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-NEW 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-NEW 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-new \- create a new branch or detached linked worktree
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-OPEN 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-OPEN 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-open \- open the worktree in an editor
4
4
  .SH SYNOPSIS
package/man/man1/gji-pr.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH GJI\-PR 1 "May 2026" "gji 0.7.0" "User Commands"
1
+ .TH GJI\-PR 1 "June 2026" "gji 0.7.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-pr \- fetch a pull request by number, #number, or URL into a linked worktree
4
4
  .SH SYNOPSIS