@solaqua/gji 0.7.1 → 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.
package/dist/go.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { type WorktreeHealth } from "./git.js";
2
- import { type WorktreeEntry } from "./repo.js";
1
+ import { type WorktreePromptEntry } from "./worktree-picker.js";
3
2
  export interface GoCommandOptions {
4
3
  branch?: string;
5
4
  cwd: string;
@@ -8,8 +7,7 @@ export interface GoCommandOptions {
8
7
  stdout: (chunk: string) => void;
9
8
  }
10
9
  export interface GoCommandDependencies {
11
- promptForWorktree: (worktrees: WorktreeEntry[]) => Promise<string | null>;
10
+ promptForWorktree: (worktrees: WorktreePromptEntry[]) => Promise<string | null>;
12
11
  }
13
12
  export declare function createGoCommand(dependencies?: Partial<GoCommandDependencies>): (options: GoCommandOptions) => Promise<number>;
14
13
  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,13 +1,12 @@
1
1
  import { basename } from "node:path";
2
- import { isCancel, select } from "@clack/prompts";
3
2
  import { loadEffectiveConfig } from "./config.js";
4
- import { readWorktreeHealth } from "./git.js";
5
3
  import { isHeadless } from "./headless.js";
6
- import { appendHistory } from "./history.js";
4
+ import { recordWorktreeUsage } from "./history.js";
7
5
  import { extractHooks, runHook } from "./hooks.js";
8
- import { detectRepository, listWorktrees, sortByCurrentFirst, } from "./repo.js";
6
+ import { detectRepository, listWorktrees } from "./repo.js";
9
7
  import { writeShellOutput } from "./shell-handoff.js";
10
8
  import { resolveWarpTarget } from "./warp.js";
9
+ import { buildWorktreePromptEntries, promptForSingleWorktree, resolveWorktreeQuery, } from "./worktree-picker.js";
11
10
  const GO_OUTPUT_FILE_ENV = "GJI_GO_OUTPUT_FILE";
12
11
  export function createGoCommand(dependencies = {}) {
13
12
  const prompt = dependencies.promptForWorktree ?? promptForWorktree;
@@ -32,7 +31,7 @@ export function createGoCommand(dependencies = {}) {
32
31
  });
33
32
  if (!target)
34
33
  return 1;
35
- appendHistory(target.path, target.branch).catch(() => undefined);
34
+ await recordWorktreeUsage(target.path, target.branch);
36
35
  await writeShellOutput(GO_OUTPUT_FILE_ENV, target.path, options.stdout);
37
36
  return 0;
38
37
  }
@@ -40,11 +39,19 @@ export function createGoCommand(dependencies = {}) {
40
39
  options.stderr("gji go: branch argument is required in non-interactive mode (GJI_NO_TUI=1)\n");
41
40
  return 1;
42
41
  }
43
- const prompted = options.branch
44
- ? null
45
- : await prompt(sortByCurrentFirst(worktrees));
42
+ const promptSources = worktrees.map((worktree) => ({
43
+ repoName: repository.repoName,
44
+ worktree,
45
+ }));
46
+ const promptEntries = options.branch
47
+ ? []
48
+ : await buildWorktreePromptEntries(promptSources);
49
+ const queried = options.branch
50
+ ? resolveWorktreeQuery(promptSources, options.branch)
51
+ : null;
52
+ const prompted = options.branch ? null : await prompt(promptEntries);
46
53
  const resolvedPath = options.branch
47
- ? worktrees.find((entry) => entry.branch === options.branch)?.path
54
+ ? queried?.worktree.path
48
55
  : (prompted ?? undefined);
49
56
  if (!resolvedPath) {
50
57
  if (options.branch) {
@@ -64,48 +71,12 @@ export function createGoCommand(dependencies = {}) {
64
71
  path: resolvedPath,
65
72
  repo: basename(repository.repoRoot),
66
73
  }, options.stderr);
67
- appendHistory(resolvedPath, chosenWorktree?.branch ?? null).catch(() => undefined);
74
+ await recordWorktreeUsage(resolvedPath, chosenWorktree?.branch ?? null);
68
75
  await writeShellOutput(GO_OUTPUT_FILE_ENV, resolvedPath, options.stdout);
69
76
  return 0;
70
77
  };
71
78
  }
72
79
  export const runGoCommand = createGoCommand();
73
80
  async function promptForWorktree(worktrees) {
74
- const healthResults = await Promise.allSettled(worktrees.map((w) => readWorktreeHealth(w.path)));
75
- const choice = await select({
76
- message: "Choose a worktree",
77
- options: worktrees.map((worktree, i) => {
78
- const health = healthResults[i].status === "fulfilled" ? healthResults[i].value : null;
79
- const pathHint = worktree.isCurrent
80
- ? `${worktree.path} (current)`
81
- : worktree.path;
82
- const upstream = health
83
- ? formatUpstreamHint(worktree.branch, health)
84
- : null;
85
- return {
86
- value: worktree.path,
87
- label: worktree.branch ?? "(detached)",
88
- hint: upstream ? `${upstream} · ${pathHint}` : pathHint,
89
- };
90
- }),
91
- });
92
- if (isCancel(choice)) {
93
- return null;
94
- }
95
- return choice;
96
- }
97
- export function formatUpstreamHint(branch, health) {
98
- if (branch === null)
99
- return null;
100
- if (!health.hasUpstream)
101
- return "no upstream";
102
- if (health.upstreamGone)
103
- return "upstream gone";
104
- if (health.ahead === 0 && health.behind === 0)
105
- return "up to date";
106
- if (health.ahead === 0)
107
- return `behind ${health.behind}`;
108
- if (health.behind === 0)
109
- return `ahead ${health.ahead}`;
110
- return `ahead ${health.ahead}, behind ${health.behind}`;
81
+ return promptForSingleWorktree("Choose a worktree", worktrees);
111
82
  }
package/dist/history.d.ts CHANGED
@@ -7,3 +7,4 @@ export interface HistoryEntry {
7
7
  export declare function HISTORY_FILE_PATH(home?: string): string;
8
8
  export declare function loadHistory(home?: string): Promise<HistoryEntry[]>;
9
9
  export declare function appendHistory(path: string, branch: string | null, home?: string): Promise<void>;
10
+ export declare function recordWorktreeUsage(path: string, branch: string | null, home?: string): Promise<void>;
package/dist/history.js CHANGED
@@ -27,15 +27,22 @@ export async function loadHistory(home = homedir()) {
27
27
  export async function appendHistory(path, branch, home = homedir()) {
28
28
  const historyPath = HISTORY_FILE_PATH(home);
29
29
  const existing = await loadHistory(home);
30
- // Skip if the most recent entry is the same path (no-op navigation)
31
- if (existing.length > 0 && existing[0].path === path) {
32
- return;
33
- }
34
30
  const entry = { branch, path, timestamp: Date.now() };
35
- const next = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES);
31
+ const next = [
32
+ entry,
33
+ ...existing.filter((existingEntry) => existingEntry.path !== path),
34
+ ].slice(0, MAX_HISTORY_ENTRIES);
36
35
  await mkdir(dirname(historyPath), { recursive: true });
37
36
  await writeFile(historyPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
38
37
  }
38
+ export async function recordWorktreeUsage(path, branch, home = homedir()) {
39
+ try {
40
+ await appendHistory(path, branch, home);
41
+ }
42
+ catch {
43
+ // Usage history is advisory metadata; primary command success should stand.
44
+ }
45
+ }
39
46
  function isHistoryEntry(value) {
40
47
  return (typeof value === "object" &&
41
48
  value !== null &&
package/dist/new.js CHANGED
@@ -8,7 +8,7 @@ import { pathExists, promptForPathConflict, } from "./conflict.js";
8
8
  import { defaultSpawnEditor, EDITORS } from "./editor.js";
9
9
  import { syncFiles } from "./file-sync.js";
10
10
  import { isHeadless } from "./headless.js";
11
- import { appendHistory } from "./history.js";
11
+ import { recordWorktreeUsage } from "./history.js";
12
12
  import { extractHooks, runHook } from "./hooks.js";
13
13
  import { maybeRunInstallPrompt, } from "./install-prompt.js";
14
14
  import { detectRepository, resolveWorktreePath, validateBranchName, } from "./repo.js";
@@ -117,7 +117,7 @@ export function createNewCommand(dependencies = {}) {
117
117
  else {
118
118
  const choice = await prompt(worktreePath);
119
119
  if (choice === "reuse") {
120
- appendHistory(worktreePath, worktreeName).catch(() => undefined);
120
+ await recordWorktreeUsage(worktreePath, worktreeName);
121
121
  await writeOutput(worktreePath, options.stdout);
122
122
  return 0;
123
123
  }
@@ -170,7 +170,7 @@ export function createNewCommand(dependencies = {}) {
170
170
  options.stdout(`${JSON.stringify({ branch: worktreeName, path: worktreePath }, null, 2)}\n`);
171
171
  }
172
172
  else {
173
- await appendHistory(worktreePath, worktreeName);
173
+ await recordWorktreeUsage(worktreePath, worktreeName);
174
174
  await writeOutput(worktreePath, options.stdout);
175
175
  }
176
176
  if (options.open) {
@@ -203,6 +203,31 @@ export function generateBranchPlaceholder(random = Math.random) {
203
203
  "lovelace",
204
204
  "nietzsche",
205
205
  "kafka",
206
+ "sappho",
207
+ "aristotle",
208
+ "pythagoras",
209
+ "artemis",
210
+ "apollo",
211
+ "minerva",
212
+ "persephone",
213
+ "icarus",
214
+ "odysseus",
215
+ "murasaki",
216
+ "shakespeare",
217
+ "frida",
218
+ "davinci",
219
+ "kepler",
220
+ "copernicus",
221
+ "faraday",
222
+ "noether",
223
+ "hopper",
224
+ "boole",
225
+ "shannon",
226
+ "gauss",
227
+ "ramanujan",
228
+ "austen",
229
+ "borges",
230
+ "zeno",
206
231
  ];
207
232
  const antics = [
208
233
  "borrowed-a-bike",
@@ -220,8 +245,49 @@ export function generateBranchPlaceholder(random = Math.random) {
220
245
  "washed-the-dishes",
221
246
  "folded-the-laundry",
222
247
  "took-a-nap",
248
+ "lost-a-sock",
249
+ "patched-the-boat",
250
+ "alphabetized-the-spoons",
251
+ "argued-with-the-calendar",
252
+ "misplaced-the-moon",
253
+ "painted-the-fence",
254
+ "overcooked-the-rice",
255
+ "packed-the-snacks",
256
+ "dropped-the-spoon",
257
+ "hid-the-remote",
258
+ "untangled-the-cables",
259
+ "rebooted-the-kettle",
260
+ "indexed-the-attic",
261
+ "forgot-the-password",
262
+ "sorted-the-buttons",
263
+ "mopped-the-ceiling",
264
+ "polished-the-doorknob",
265
+ "misread-the-map",
266
+ "reheated-the-tea",
267
+ "fixed-the-squeak",
268
+ "labeled-the-drawer",
269
+ "stacked-the-chairs",
270
+ "overslept-the-standup",
271
+ "claimed-the-last-bagel",
272
+ "debugged-the-toaster",
223
273
  ];
224
- return `${pickRandom(roots, random)}-${pickRandom(antics, random)}`;
274
+ const root = pickRandom(roots, random);
275
+ const antic = pickRandom(antics, random);
276
+ const suffix = generateBranchPlaceholderSuffix(random);
277
+ return `${root}-${antic}-${suffix}`;
278
+ }
279
+ function pickRandom(values, random) {
280
+ const index = Math.floor(random() * values.length);
281
+ return values[Math.min(index, values.length - 1)];
282
+ }
283
+ function generateBranchPlaceholderSuffix(random) {
284
+ const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
285
+ let suffix = "";
286
+ for (let index = 0; index < 3; index += 1) {
287
+ const characterIndex = Math.floor(random() * characters.length);
288
+ suffix += characters[Math.min(characterIndex, characters.length - 1)];
289
+ }
290
+ return suffix;
225
291
  }
226
292
  function applyConfiguredBranchPrefix(branch, branchPrefix) {
227
293
  if (typeof branchPrefix !== "string" || branchPrefix.length === 0) {
@@ -258,10 +324,6 @@ async function defaultPromptForBranch(placeholder) {
258
324
  }
259
325
  return choice.trim();
260
326
  }
261
- function pickRandom(values, random) {
262
- const index = Math.floor(random() * values.length);
263
- return values[Math.min(index, values.length - 1)];
264
- }
265
327
  async function localBranchExists(repoRoot, branchName) {
266
328
  try {
267
329
  await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd: repoRoot });
package/dist/open.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type EditorDefinition } from "./editor.js";
2
- import { type WorktreeEntry } from "./repo.js";
2
+ import { type WorktreePromptEntry } from "./worktree-picker.js";
3
3
  export type { EditorDefinition };
4
4
  export interface OpenCommandOptions {
5
5
  branch?: string;
@@ -13,7 +13,7 @@ export interface OpenCommandOptions {
13
13
  export interface OpenCommandDependencies {
14
14
  detectEditors: () => Promise<EditorDefinition[]>;
15
15
  promptForEditor: (editors: EditorDefinition[]) => Promise<string | null>;
16
- promptForWorktree: (worktrees: WorktreeEntry[]) => Promise<string | null>;
16
+ promptForWorktree: (worktrees: WorktreePromptEntry[]) => Promise<string | null>;
17
17
  spawnEditor: (cli: string, args: string[]) => Promise<void>;
18
18
  }
19
19
  export declare function createOpenCommand(dependencies?: Partial<OpenCommandDependencies>): (options: OpenCommandOptions) => Promise<number>;
package/dist/open.js CHANGED
@@ -6,7 +6,9 @@ import { isCancel, select } from "@clack/prompts";
6
6
  import { loadEffectiveConfig, resolveConfigString, updateGlobalConfigKey, } from "./config.js";
7
7
  import { defaultSpawnEditor, EDITORS, } from "./editor.js";
8
8
  import { isHeadless } from "./headless.js";
9
- import { detectRepository, listWorktrees, sortByCurrentFirst, } from "./repo.js";
9
+ import { recordWorktreeUsage } from "./history.js";
10
+ import { detectRepository, listWorktrees } from "./repo.js";
11
+ import { buildWorktreePromptEntries, promptForSingleWorktree, resolveWorktreeQuery, } from "./worktree-picker.js";
10
12
  const execFileAsync = promisify(execFile);
11
13
  export function createOpenCommand(dependencies = {}) {
12
14
  const detectEditors = dependencies.detectEditors ?? detectInstalledEditors;
@@ -20,25 +22,36 @@ export function createOpenCommand(dependencies = {}) {
20
22
  ]);
21
23
  // Resolve target worktree path.
22
24
  let targetPath;
25
+ let targetWorktree;
23
26
  if (options.branch) {
24
- const entry = worktrees.find((w) => w.branch === options.branch);
25
- if (!entry) {
26
- options.stderr(`gji open: no worktree found for branch: ${options.branch}\n`);
27
+ const match = resolveWorktreeQuery(worktrees.map((worktree) => ({
28
+ repoName: repository.repoName,
29
+ worktree,
30
+ })), options.branch);
31
+ if (!match) {
32
+ options.stderr(`gji open: no worktree found matching: ${options.branch}\n`);
27
33
  options.stderr(`Hint: Use 'gji ls' to see available worktrees\n`);
28
34
  return 1;
29
35
  }
30
- targetPath = entry.path;
36
+ targetPath = match.worktree.path;
37
+ targetWorktree = match.worktree;
31
38
  }
32
39
  else if (isHeadless()) {
33
- targetPath = worktrees.find((w) => w.isCurrent)?.path ?? options.cwd;
40
+ targetWorktree = worktrees.find((w) => w.isCurrent);
41
+ targetPath = targetWorktree?.path ?? options.cwd;
34
42
  }
35
43
  else {
36
- const chosen = await promptForWorktree(sortByCurrentFirst(worktrees));
44
+ const entries = await buildWorktreePromptEntries(worktrees.map((worktree) => ({
45
+ repoName: repository.repoName,
46
+ worktree,
47
+ })));
48
+ const chosen = await promptForWorktree(entries);
37
49
  if (!chosen) {
38
50
  options.stderr("Aborted\n");
39
51
  return 1;
40
52
  }
41
53
  targetPath = chosen;
54
+ targetWorktree = worktrees.find((w) => w.path === chosen);
42
55
  }
43
56
  // Resolve which editor to use.
44
57
  const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
@@ -100,6 +113,7 @@ export function createOpenCommand(dependencies = {}) {
100
113
  return 1;
101
114
  }
102
115
  const displayName = editorDef?.name ?? editorCli;
116
+ await recordWorktreeUsage(targetPath, targetWorktree?.branch ?? null);
103
117
  options.stdout(`Opened ${targetPath} in ${displayName}\n`);
104
118
  return 0;
105
119
  };
@@ -122,17 +136,7 @@ async function isCommandAvailable(command) {
122
136
  }
123
137
  }
124
138
  async function defaultPromptForWorktree(worktrees) {
125
- const choice = await select({
126
- message: "Choose a worktree to open",
127
- options: worktrees.map((w) => ({
128
- value: w.path,
129
- label: w.branch ?? "(detached)",
130
- hint: w.isCurrent ? `${w.path} (current)` : w.path,
131
- })),
132
- });
133
- if (isCancel(choice))
134
- return null;
135
- return choice;
139
+ return promptForSingleWorktree("Choose a worktree to open", worktrees);
136
140
  }
137
141
  async function defaultPromptForEditor(editors) {
138
142
  const choice = await select({
package/dist/pr.js CHANGED
@@ -6,7 +6,7 @@ import { loadEffectiveConfig, resolveConfigString } from "./config.js";
6
6
  import { pathExists, promptForPathConflict, } from "./conflict.js";
7
7
  import { syncFiles } from "./file-sync.js";
8
8
  import { isHeadless } from "./headless.js";
9
- import { appendHistory } from "./history.js";
9
+ import { recordWorktreeUsage } from "./history.js";
10
10
  import { extractHooks, runHook } from "./hooks.js";
11
11
  import { maybeRunInstallPrompt, } from "./install-prompt.js";
12
12
  import { detectRepository, resolveWorktreePath } from "./repo.js";
@@ -61,7 +61,7 @@ export function createPrCommand(dependencies = {}) {
61
61
  }
62
62
  const choice = await prompt(worktreePath);
63
63
  if (choice === "reuse") {
64
- appendHistory(worktreePath, branchName).catch(() => undefined);
64
+ await recordWorktreeUsage(worktreePath, branchName);
65
65
  await writeOutput(worktreePath, options.stdout);
66
66
  return 0;
67
67
  }
@@ -120,7 +120,7 @@ export function createPrCommand(dependencies = {}) {
120
120
  options.stdout(`${JSON.stringify({ branch: branchName, path: worktreePath }, null, 2)}\n`);
121
121
  }
122
122
  else {
123
- await appendHistory(worktreePath, branchName);
123
+ await recordWorktreeUsage(worktreePath, branchName);
124
124
  await writeOutput(worktreePath, options.stdout);
125
125
  }
126
126
  return 0;
package/dist/remove.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { type WorktreeEntry } from "./repo.js";
1
+ import type { WorktreeEntry } from "./repo.js";
2
+ import { type WorktreePromptEntry } from "./worktree-picker.js";
2
3
  export interface RemoveCommandOptions {
3
4
  branch?: string;
4
5
  cwd: string;
@@ -12,7 +13,7 @@ export interface RemoveCommandDependencies {
12
13
  confirmForceDeleteBranch: (branch: string) => Promise<boolean>;
13
14
  confirmForceRemoveWorktree: (worktreePath: string) => Promise<boolean>;
14
15
  confirmRemoval: (worktree: WorktreeEntry) => Promise<boolean>;
15
- promptForWorktree: (worktrees: WorktreeEntry[]) => Promise<string | null>;
16
+ promptForWorktree: (worktrees: WorktreePromptEntry[]) => Promise<string | null>;
16
17
  }
17
18
  export declare function createRemoveCommand(dependencies?: Partial<RemoveCommandDependencies>): (options: RemoveCommandOptions) => Promise<number>;
18
19
  export declare const runRemoveCommand: (options: RemoveCommandOptions) => Promise<number>;
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;
@@ -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) {
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";