@solaqua/gji 0.9.0 → 0.11.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 (100) hide show
  1. package/README.md +93 -20
  2. package/dist/back.d.ts +3 -0
  3. package/dist/back.js +19 -7
  4. package/dist/bootstrap-output.d.ts +3 -0
  5. package/dist/bootstrap-output.js +27 -0
  6. package/dist/bootstrap-preview.d.ts +10 -0
  7. package/dist/bootstrap-preview.js +21 -0
  8. package/dist/clean.js +25 -3
  9. package/dist/cli.js +105 -19
  10. package/dist/clone-failure-store.d.ts +18 -0
  11. package/dist/clone-failure-store.js +230 -0
  12. package/dist/command-runner.d.ts +6 -0
  13. package/dist/command-runner.js +38 -0
  14. package/dist/completion.js +7 -2
  15. package/dist/config-command.d.ts +1 -0
  16. package/dist/config-command.js +7 -1
  17. package/dist/config.d.ts +26 -1
  18. package/dist/config.js +110 -6
  19. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  20. package/dist/dependency-bootstrap-prompt.js +103 -0
  21. package/dist/dependency-bootstrap.d.ts +129 -0
  22. package/dist/dependency-bootstrap.js +566 -0
  23. package/dist/dir-clone.d.ts +33 -0
  24. package/dist/dir-clone.js +505 -0
  25. package/dist/done.d.ts +10 -0
  26. package/dist/done.js +208 -0
  27. package/dist/file-sync.js +71 -22
  28. package/dist/format-bytes.d.ts +1 -0
  29. package/dist/format-bytes.js +14 -0
  30. package/dist/fs-utils.d.ts +4 -0
  31. package/dist/fs-utils.js +26 -0
  32. package/dist/git.d.ts +5 -0
  33. package/dist/git.js +50 -0
  34. package/dist/gji-bundle.mjs +5489 -1206
  35. package/dist/go-resolver.d.ts +35 -0
  36. package/dist/go-resolver.js +185 -0
  37. package/dist/go.d.ts +5 -2
  38. package/dist/go.js +262 -62
  39. package/dist/hooks.d.ts +1 -1
  40. package/dist/hooks.js +13 -7
  41. package/dist/init.js +30 -22
  42. package/dist/install-prompt.d.ts +1 -0
  43. package/dist/install-prompt.js +3 -25
  44. package/dist/navigation-output.d.ts +11 -0
  45. package/dist/navigation-output.js +6 -0
  46. package/dist/new.d.ts +14 -2
  47. package/dist/new.js +434 -36
  48. package/dist/open.d.ts +1 -0
  49. package/dist/open.js +14 -2
  50. package/dist/pr.d.ts +6 -2
  51. package/dist/pr.js +135 -30
  52. package/dist/remove.js +20 -0
  53. package/dist/repo.d.ts +3 -0
  54. package/dist/repo.js +57 -6
  55. package/dist/safe-destination.d.ts +15 -0
  56. package/dist/safe-destination.js +166 -0
  57. package/dist/shell-completion.js +47 -14
  58. package/dist/sync-directories.d.ts +33 -0
  59. package/dist/sync-directories.js +97 -0
  60. package/dist/sync-plan.d.ts +16 -0
  61. package/dist/sync-plan.js +128 -0
  62. package/dist/sync.js +7 -13
  63. package/dist/undo.d.ts +36 -0
  64. package/dist/undo.js +392 -0
  65. package/dist/warp.d.ts +3 -1
  66. package/dist/warp.js +32 -125
  67. package/dist/worktree-bootstrap.d.ts +35 -0
  68. package/dist/worktree-bootstrap.js +93 -0
  69. package/dist/worktree-management.d.ts +1 -0
  70. package/dist/worktree-management.js +23 -4
  71. package/dist/worktree-picker.d.ts +21 -8
  72. package/dist/worktree-picker.js +107 -15
  73. package/dist/worktree-prompts.js +1 -1
  74. package/dist/worktree-source.d.ts +6 -0
  75. package/dist/worktree-source.js +1 -0
  76. package/dist/worktree-sources.d.ts +3 -0
  77. package/dist/worktree-sources.js +31 -0
  78. package/man/man1/gji-back.1 +1 -1
  79. package/man/man1/gji-clean.1 +1 -1
  80. package/man/man1/gji-completion.1 +1 -1
  81. package/man/man1/gji-config.1 +1 -1
  82. package/man/man1/gji-doctor.1 +1 -1
  83. package/man/man1/gji-done.1 +19 -0
  84. package/man/man1/gji-go.1 +9 -3
  85. package/man/man1/gji-history.1 +1 -1
  86. package/man/man1/gji-init.1 +1 -1
  87. package/man/man1/gji-ls.1 +1 -1
  88. package/man/man1/gji-new.1 +21 -3
  89. package/man/man1/gji-open.1 +4 -1
  90. package/man/man1/gji-pr.1 +7 -1
  91. package/man/man1/gji-remove.1 +3 -3
  92. package/man/man1/gji-root.1 +1 -1
  93. package/man/man1/gji-run-hook.1 +1 -1
  94. package/man/man1/gji-status.1 +1 -1
  95. package/man/man1/gji-sync-files.1 +1 -1
  96. package/man/man1/gji-sync.1 +1 -1
  97. package/man/man1/gji-undo.1 +16 -0
  98. package/man/man1/gji-warp.1 +3 -6
  99. package/man/man1/gji.1 +13 -5
  100. package/package.json +3 -1
package/dist/undo.js ADDED
@@ -0,0 +1,392 @@
1
+ import { access, mkdir, open, readFile, rename, stat, unlink, writeFile, } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { confirm, isCancel } from "@clack/prompts";
5
+ import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
6
+ import { isDirtyWorktree, runGit } from "./git.js";
7
+ import { isHeadless } from "./headless.js";
8
+ import { detectRepository } from "./repo.js";
9
+ const MAX_UNDO_RECORDS = 20;
10
+ const UNDO_LOCK_STALE_AFTER_MS = 60_000;
11
+ const UNDO_LOCK_RETRY_DELAY_MS = 5;
12
+ const UNDO_LOCK_WAIT_TIMEOUT_MS = UNDO_LOCK_STALE_AFTER_MS + UNDO_LOCK_RETRY_DELAY_MS;
13
+ const undoLogQueues = new Map();
14
+ export function undoLogPath(home = homedir()) {
15
+ const configured = process.env.GJI_CONFIG_DIR;
16
+ return join(configured ? resolve(configured) : join(home, GLOBAL_CONFIG_DIRECTORY), "undo-log.json");
17
+ }
18
+ export async function loadUndoRecords(home = homedir()) {
19
+ try {
20
+ const parsed = JSON.parse(await readFile(undoLogPath(home), "utf8"));
21
+ return Array.isArray(parsed) ? parsed.filter(isUndoRecord) : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ export async function recordUndoOperation(op, repoRoot, worktrees, home = homedir()) {
28
+ const entries = [];
29
+ for (const worktree of worktrees) {
30
+ try {
31
+ const headSha = await runGit(worktree.path, ["rev-parse", "HEAD"]);
32
+ let upstream = null;
33
+ try {
34
+ upstream = await runGit(worktree.path, [
35
+ "rev-parse",
36
+ "--abbrev-ref",
37
+ "--symbolic-full-name",
38
+ "@{u}",
39
+ ]);
40
+ }
41
+ catch {
42
+ /* no upstream */
43
+ }
44
+ entries.push({
45
+ branch: worktree.branch,
46
+ headSha,
47
+ path: worktree.path,
48
+ upstream,
49
+ wasDirty: await isDirtyWorktree(worktree.path).catch(() => false),
50
+ });
51
+ }
52
+ catch {
53
+ /* candidate disappeared before the destructive operation */
54
+ }
55
+ }
56
+ if (entries.length === 0)
57
+ return null;
58
+ const timestamp = Date.now();
59
+ const record = {
60
+ id: `u-${formatTimestamp(timestamp)}-${Math.random().toString(36).slice(2, 6)}`,
61
+ op,
62
+ repoRoot,
63
+ timestamp,
64
+ entries,
65
+ };
66
+ return enqueueUndoLogWrite(home, async () => {
67
+ const existing = await loadUndoRecords(home);
68
+ await writeUndoRecords(home, [record, ...existing].slice(0, MAX_UNDO_RECORDS));
69
+ return record;
70
+ });
71
+ }
72
+ export async function finalizeUndoOperation(record, removedWorktrees, home = homedir()) {
73
+ const removedPaths = new Set(removedWorktrees.map((worktree) => worktree.path));
74
+ await enqueueUndoLogWrite(home, async () => {
75
+ const records = await loadUndoRecords(home);
76
+ const remaining = records.flatMap((candidate) => {
77
+ if (candidate.id !== record.id)
78
+ return [candidate];
79
+ const entries = candidate.entries.filter((entry) => removedPaths.has(entry.path));
80
+ return entries.length === 0 ? [] : [{ ...candidate, entries }];
81
+ });
82
+ await writeUndoRecords(home, remaining);
83
+ });
84
+ }
85
+ export async function restoreUndoRecord(record, home = homedir()) {
86
+ const restored = [];
87
+ const failed = [];
88
+ const unresolved = [];
89
+ for (const entry of record.entries) {
90
+ try {
91
+ const upstreamFailure = await restoreUndoEntry(record.repoRoot, entry);
92
+ restored.push(entry);
93
+ if (upstreamFailure) {
94
+ failed.push({ ...entry, message: upstreamFailure });
95
+ unresolved.push(entry);
96
+ }
97
+ }
98
+ catch (error) {
99
+ failed.push({ ...entry, message: toMessage(error) });
100
+ unresolved.push(entry);
101
+ }
102
+ }
103
+ await enqueueUndoLogWrite(home, async () => {
104
+ const records = await loadUndoRecords(home);
105
+ const remaining = records.flatMap((candidate) => {
106
+ if (candidate.id !== record.id)
107
+ return [candidate];
108
+ if (unresolved.length === 0)
109
+ return [];
110
+ return [{ ...candidate, entries: unresolved }];
111
+ });
112
+ await writeUndoRecords(home, remaining);
113
+ });
114
+ return { restored, failed };
115
+ }
116
+ export async function runUndoCommand(options) {
117
+ if (await hasMalformedUndoLog())
118
+ options.stderr("Warning: undo journal is invalid; starting with an empty journal\n");
119
+ const records = await loadUndoRecords();
120
+ if (options.list) {
121
+ if (options.json)
122
+ options.stdout(`${JSON.stringify(records, null, 2)}\n`);
123
+ else if (records.length === 0)
124
+ options.stdout("nothing to undo\n");
125
+ else
126
+ for (const record of records)
127
+ options.stdout(`${record.id} ${record.op} ${record.entries.length} worktree${record.entries.length === 1 ? "" : "s"} ${new Date(record.timestamp).toISOString()}\n`);
128
+ return 0;
129
+ }
130
+ if (records.length === 0) {
131
+ if (options.json)
132
+ options.stdout(`${JSON.stringify({ restored: [], failed: [] }, null, 2)}\n`);
133
+ else
134
+ options.stdout("nothing to undo\n");
135
+ return 0;
136
+ }
137
+ let repoRoot = null;
138
+ try {
139
+ repoRoot = (await detectRepository(options.cwd)).repoRoot;
140
+ }
141
+ catch {
142
+ /* use latest globally */
143
+ }
144
+ let record;
145
+ if (options.id) {
146
+ record = records.find((candidate) => candidate.id === options.id);
147
+ }
148
+ else if (repoRoot !== null && records[0]?.repoRoot !== repoRoot) {
149
+ const latest = records[0];
150
+ const message = `latest undo record belongs to ${latest.repoRoot}; use --id to select it`;
151
+ if (options.json || isHeadless())
152
+ return emitUndoError(options, message);
153
+ const choice = await confirm({
154
+ message: `${message}. Restore it anyway?`,
155
+ active: "Yes",
156
+ inactive: "No",
157
+ initialValue: false,
158
+ });
159
+ if (isCancel(choice) || !choice)
160
+ return emitUndoError(options, "Aborted");
161
+ record = latest;
162
+ }
163
+ else {
164
+ record = repoRoot
165
+ ? records.find((candidate) => candidate.repoRoot === repoRoot)
166
+ : records[0];
167
+ }
168
+ if (!record)
169
+ return emitUndoError(options, `undo record not found: ${options.id}`);
170
+ const result = await restoreUndoRecord(record);
171
+ if (options.json)
172
+ options.stdout(`${JSON.stringify({ restored: result.restored.map((entry) => ({ branch: entry.branch, path: entry.path })), failed: result.failed.map((entry) => ({ branch: entry.branch, path: entry.path, error: entry.message })) }, null, 2)}\n`);
173
+ else {
174
+ for (const entry of result.restored) {
175
+ options.stdout(`✓ restored ${entry.branch ?? "detached worktree"} at ${entry.path}\n`);
176
+ if (entry.wasDirty)
177
+ options.stderr("note: uncommitted changes at deletion time were not preserved\n");
178
+ }
179
+ for (const entry of result.failed)
180
+ options.stderr(`Failed to restore ${entry.branch ?? entry.path}: ${entry.message}\n`);
181
+ }
182
+ return result.failed.length === 0 ? 0 : 1;
183
+ }
184
+ async function hasMalformedUndoLog() {
185
+ try {
186
+ const parsed = JSON.parse(await readFile(undoLogPath(), "utf8"));
187
+ return (!Array.isArray(parsed) || parsed.some((entry) => !isUndoRecord(entry)));
188
+ }
189
+ catch (error) {
190
+ return (error instanceof Error &&
191
+ "code" in error &&
192
+ error.code !== "ENOENT");
193
+ }
194
+ }
195
+ async function restoreUndoEntry(repoRoot, entry) {
196
+ try {
197
+ await access(entry.path);
198
+ throw new Error(`path already exists: ${entry.path}`);
199
+ }
200
+ catch (error) {
201
+ if (error instanceof Error &&
202
+ error.message.startsWith("path already exists:"))
203
+ throw error;
204
+ }
205
+ if (entry.branch === null) {
206
+ await assertCommitExists(repoRoot, entry.headSha);
207
+ await runGit(repoRoot, [
208
+ "worktree",
209
+ "add",
210
+ "--detach",
211
+ entry.path,
212
+ entry.headSha,
213
+ ]);
214
+ return null;
215
+ }
216
+ let branchExists = false;
217
+ let existingUpstream = null;
218
+ try {
219
+ await runGit(repoRoot, [
220
+ "show-ref",
221
+ "--verify",
222
+ `refs/heads/${entry.branch}`,
223
+ ]);
224
+ branchExists = true;
225
+ }
226
+ catch {
227
+ /* branch was deleted */
228
+ }
229
+ if (branchExists) {
230
+ const currentSha = await runGit(repoRoot, ["rev-parse", entry.branch]);
231
+ if (currentSha !== entry.headSha)
232
+ throw new Error(`branch ${entry.branch} already exists at a different commit`);
233
+ existingUpstream = await runGit(repoRoot, [
234
+ "rev-parse",
235
+ "--abbrev-ref",
236
+ "--symbolic-full-name",
237
+ `${entry.branch}@{u}`,
238
+ ]).catch(() => null);
239
+ if (entry.upstream &&
240
+ existingUpstream &&
241
+ existingUpstream !== entry.upstream)
242
+ throw new Error(`branch ${entry.branch} already tracks ${existingUpstream}; refusing to overwrite it`);
243
+ }
244
+ else {
245
+ await assertCommitExists(repoRoot, entry.headSha);
246
+ await runGit(repoRoot, ["branch", entry.branch, entry.headSha]);
247
+ }
248
+ try {
249
+ await runGit(repoRoot, ["worktree", "add", entry.path, entry.branch]);
250
+ }
251
+ catch (error) {
252
+ if (!branchExists) {
253
+ try {
254
+ await runGit(repoRoot, ["branch", "-D", entry.branch]);
255
+ }
256
+ catch {
257
+ /* best effort */
258
+ }
259
+ }
260
+ throw error;
261
+ }
262
+ if (entry.upstream) {
263
+ if (existingUpstream === entry.upstream)
264
+ return null;
265
+ try {
266
+ await runGit(repoRoot, [
267
+ "branch",
268
+ "--set-upstream-to",
269
+ entry.upstream,
270
+ entry.branch,
271
+ ]);
272
+ }
273
+ catch (error) {
274
+ return `could not restore upstream ${entry.upstream}: ${toRestoreMessage(error)}`;
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+ async function assertCommitExists(repoRoot, headSha) {
280
+ try {
281
+ await runGit(repoRoot, ["cat-file", "-e", `${headSha}^{commit}`]);
282
+ }
283
+ catch {
284
+ throw new Error("commit no longer exists (gc'd) — cannot restore");
285
+ }
286
+ }
287
+ async function enqueueUndoLogWrite(home, operation) {
288
+ const path = undoLogPath(home);
289
+ await mkdir(dirname(path), { recursive: true });
290
+ const previous = undoLogQueues.get(path) ?? Promise.resolve();
291
+ const runLocked = async () => {
292
+ const release = await acquireUndoLogLock(path);
293
+ try {
294
+ return await operation();
295
+ }
296
+ finally {
297
+ await release();
298
+ }
299
+ };
300
+ const current = previous.then(runLocked, runLocked);
301
+ const settled = current.then(() => undefined, () => undefined);
302
+ undoLogQueues.set(path, settled);
303
+ try {
304
+ return await current;
305
+ }
306
+ finally {
307
+ if (undoLogQueues.get(path) === settled)
308
+ undoLogQueues.delete(path);
309
+ }
310
+ }
311
+ async function acquireUndoLogLock(path) {
312
+ const lockPath = `${path}.lock`;
313
+ const deadline = Date.now() + UNDO_LOCK_WAIT_TIMEOUT_MS;
314
+ while (Date.now() < deadline) {
315
+ try {
316
+ const handle = await open(lockPath, "wx");
317
+ return async () => {
318
+ await handle.close();
319
+ await unlink(lockPath).catch(() => undefined);
320
+ };
321
+ }
322
+ catch (error) {
323
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
324
+ throw error;
325
+ const lockStat = await stat(lockPath).catch(() => null);
326
+ if (lockStat && Date.now() - lockStat.mtimeMs > UNDO_LOCK_STALE_AFTER_MS)
327
+ await unlink(lockPath).catch(() => undefined);
328
+ const remainingMs = deadline - Date.now();
329
+ if (remainingMs > 0)
330
+ await new Promise((resolve) => setTimeout(resolve, Math.min(UNDO_LOCK_RETRY_DELAY_MS, remainingMs)));
331
+ }
332
+ }
333
+ throw new Error("timed out waiting for the undo journal lock");
334
+ }
335
+ async function writeUndoRecords(home, records) {
336
+ const path = undoLogPath(home);
337
+ await mkdir(dirname(path), { recursive: true });
338
+ const temporaryPath = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
339
+ try {
340
+ await writeFile(temporaryPath, `${JSON.stringify(records, null, 2)}\n`, {
341
+ encoding: "utf8",
342
+ flag: "wx",
343
+ });
344
+ await rename(temporaryPath, path);
345
+ }
346
+ finally {
347
+ await unlink(temporaryPath).catch(() => undefined);
348
+ }
349
+ }
350
+ function isUndoRecord(value) {
351
+ if (!value || typeof value !== "object")
352
+ return false;
353
+ const c = value;
354
+ return (typeof c.id === "string" &&
355
+ (c.op === "remove" || c.op === "clean" || c.op === "done") &&
356
+ typeof c.repoRoot === "string" &&
357
+ typeof c.timestamp === "number" &&
358
+ Array.isArray(c.entries) &&
359
+ c.entries.every(isUndoEntry));
360
+ }
361
+ function isUndoEntry(value) {
362
+ if (!value || typeof value !== "object")
363
+ return false;
364
+ const c = value;
365
+ return ((c.branch === null || typeof c.branch === "string") &&
366
+ typeof c.headSha === "string" &&
367
+ typeof c.path === "string" &&
368
+ (c.upstream === null || typeof c.upstream === "string") &&
369
+ typeof c.wasDirty === "boolean");
370
+ }
371
+ function formatTimestamp(timestamp) {
372
+ return new Date(timestamp)
373
+ .toISOString()
374
+ .replace(/[-:TZ.]/g, "")
375
+ .slice(0, 12);
376
+ }
377
+ function toMessage(error) {
378
+ return error instanceof Error ? error.message : String(error);
379
+ }
380
+ function toRestoreMessage(error) {
381
+ const message = toMessage(error);
382
+ return /bad object|unknown revision|not a valid object name/i.test(message)
383
+ ? "commit no longer exists (gc'd) — cannot restore"
384
+ : message;
385
+ }
386
+ function emitUndoError(options, message) {
387
+ if (options.json)
388
+ options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
389
+ else
390
+ options.stderr(`${message}\n`);
391
+ return 1;
392
+ }
package/dist/warp.d.ts CHANGED
@@ -1,9 +1,9 @@
1
+ import { type NavigationRepository } from "./navigation-output.js";
1
2
  import { type QueryWorktreePullRequests } from "./worktree-picker.js";
2
3
  export interface WarpCommandOptions {
3
4
  branch?: string;
4
5
  cwd: string;
5
6
  json?: boolean;
6
- newWorktree?: boolean;
7
7
  queryPullRequests?: QueryWorktreePullRequests;
8
8
  stderr: (chunk: string) => void;
9
9
  stdout: (chunk: string) => void;
@@ -12,11 +12,13 @@ export declare function runWarpCommand(options: WarpCommandOptions): Promise<num
12
12
  export interface WarpTarget {
13
13
  branch: string | null;
14
14
  path: string;
15
+ repository: NavigationRepository;
15
16
  }
16
17
  export declare function resolveWarpTarget(options: {
17
18
  branch?: string;
18
19
  commandName?: string;
19
20
  cwd: string;
21
+ excludeRepoRoot?: string;
20
22
  json?: boolean;
21
23
  queryPullRequests?: QueryWorktreePullRequests;
22
24
  stderr: (chunk: string) => void;
package/dist/warp.js CHANGED
@@ -1,24 +1,13 @@
1
- import { realpath } from "node:fs/promises";
2
- import { basename, resolve } from "node:path";
3
- import { isCancel, select } from "@clack/prompts";
4
1
  import { isHeadless } from "./headless.js";
5
2
  import { recordWorktreeUsage } from "./history.js";
6
- import { runNewCommand } from "./new.js";
7
- import { detectRepository, listWorktrees } from "./repo.js";
3
+ import { createNavigationRepository, createNavigationTarget, } from "./navigation-output.js";
4
+ import { detectRepository } from "./repo.js";
8
5
  import { loadRegistry } from "./repo-registry.js";
9
6
  import { writeShellOutput } from "./shell-handoff.js";
10
7
  import { buildWorktreePromptEntries, promptForSingleWorktree, resolveWorktreeQuery, } from "./worktree-picker.js";
8
+ import { listRegisteredWorktreeSources } from "./worktree-sources.js";
11
9
  const WARP_OUTPUT_FILE_ENV = "GJI_WARP_OUTPUT_FILE";
12
10
  export async function runWarpCommand(options) {
13
- if (options.newWorktree) {
14
- const registry = await loadRegistry();
15
- if (registry.length === 0) {
16
- options.stderr("gji warp: no repos registered yet.\n" +
17
- "Use any gji command in a repository to register it automatically.\n");
18
- return 1;
19
- }
20
- return runWarpNew(options, registry);
21
- }
22
11
  return runWarpNavigate(options);
23
12
  }
24
13
  async function runWarpNavigate(options) {
@@ -42,95 +31,13 @@ async function runWarpNavigate(options) {
42
31
  return 1;
43
32
  if (options.json) {
44
33
  // json callers use the output programmatically; skip history and shell handoff.
45
- options.stdout(`${JSON.stringify({ branch: target.branch, path: target.path }, null, 2)}\n`);
34
+ options.stdout(`${JSON.stringify(createNavigationTarget(target.repository, target.path, target.branch), null, 2)}\n`);
46
35
  return 0;
47
36
  }
48
37
  await recordWorktreeUsage(target.path, target.branch);
49
38
  await writeShellOutput(WARP_OUTPUT_FILE_ENV, target.path, options.stdout);
50
39
  return 0;
51
40
  }
52
- async function runWarpNew(options, registry) {
53
- const deduplicatedRegistry = await deduplicateRegistryForNew(registry);
54
- let targetRepoRoot;
55
- if (deduplicatedRegistry.length === 1) {
56
- targetRepoRoot = deduplicatedRegistry[0].path;
57
- }
58
- else {
59
- if (isHeadless()) {
60
- options.stderr("gji warp: repo argument is required in non-interactive mode (GJI_NO_TUI=1)\n");
61
- return 1;
62
- }
63
- const choice = await select({
64
- message: "Create worktree in which repo?",
65
- options: deduplicatedRegistry.map((entry) => ({
66
- value: entry.path,
67
- label: entry.name,
68
- hint: entry.path,
69
- })),
70
- });
71
- if (isCancel(choice)) {
72
- options.stderr("Aborted\n");
73
- return 1;
74
- }
75
- targetRepoRoot = choice;
76
- }
77
- if (options.json) {
78
- return runNewCommand({
79
- branch: options.branch,
80
- cwd: targetRepoRoot,
81
- json: true,
82
- stderr: options.stderr,
83
- stdout: options.stdout,
84
- });
85
- }
86
- // runNewCommand writes the created path to options.stdout via writeShellOutput.
87
- // Since GJI_NEW_OUTPUT_FILE is not set in the warp shell context, it falls
88
- // through to our captured stdout, giving us the path to hand off.
89
- let capturedPath = "";
90
- const captureStdout = (chunk) => {
91
- capturedPath = chunk.trim();
92
- };
93
- const exitCode = await runNewCommand({
94
- branch: options.branch,
95
- cwd: targetRepoRoot,
96
- stderr: options.stderr,
97
- stdout: captureStdout,
98
- });
99
- if (exitCode !== 0) {
100
- return exitCode;
101
- }
102
- if (!capturedPath) {
103
- options.stderr("gji warp: could not determine new worktree path\n");
104
- return 1;
105
- }
106
- await writeShellOutput(WARP_OUTPUT_FILE_ENV, capturedPath, options.stdout);
107
- return 0;
108
- }
109
- async function deduplicateRegistryForNew(registry) {
110
- const deduplicated = [];
111
- const seenPaths = new Set();
112
- for (const entry of registry) {
113
- const canonicalPath = await canonicalizeRepoPath(entry.path);
114
- if (seenPaths.has(canonicalPath)) {
115
- continue;
116
- }
117
- seenPaths.add(canonicalPath);
118
- deduplicated.push({
119
- ...entry,
120
- name: basename(canonicalPath),
121
- path: canonicalPath,
122
- });
123
- }
124
- return deduplicated;
125
- }
126
- async function canonicalizeRepoPath(repoPath) {
127
- try {
128
- return await realpath(repoPath);
129
- }
130
- catch {
131
- return resolve(repoPath);
132
- }
133
- }
134
41
  export async function resolveWarpTarget(options) {
135
42
  const cmd = options.commandName ?? "gji";
136
43
  const emitError = (message, hint) => {
@@ -144,35 +51,18 @@ export async function resolveWarpTarget(options) {
144
51
  }
145
52
  };
146
53
  const registry = await loadRegistry();
147
- const currentRoot = await detectRepository(options.cwd)
148
- .then((repository) => repository.currentRoot)
149
- .catch(() => null);
150
54
  if (registry.length === 0) {
151
55
  emitError("not in a git repository and no repos registered yet.", "Use any gji command inside a repository to register it.\n");
152
56
  return null;
153
57
  }
154
- const results = await Promise.allSettled(registry.map(async (entry) => {
155
- const worktrees = await listWorktrees(entry.path);
156
- return { repoName: entry.name, repoRoot: entry.path, worktrees };
157
- }));
158
- const allItems = [];
159
- for (const result of results) {
160
- if (result.status === "rejected")
161
- continue;
162
- const { repoName, repoRoot, worktrees } = result.value;
163
- for (const worktree of worktrees) {
164
- allItems.push({
165
- repoRoot,
166
- repoName,
167
- worktree: {
168
- ...worktree,
169
- isCurrent: currentRoot !== null && worktree.path === currentRoot,
170
- },
171
- });
172
- }
173
- }
58
+ let skippedRegisteredRepos = 0;
59
+ const allItems = (await listRegisteredWorktreeSources(options.cwd, () => {
60
+ skippedRegisteredRepos++;
61
+ })).filter((item) => item.repoRoot !== options.excludeRepoRoot);
174
62
  if (allItems.length === 0) {
175
- emitError("no accessible worktrees found in any registered repo.");
63
+ emitError("no accessible worktrees found in any registered repo.", skippedRegisteredRepos > 0
64
+ ? "Hint: Run 'gji doctor' to inspect stale repository entries.\n"
65
+ : undefined);
176
66
  return null;
177
67
  }
178
68
  const promptSources = allItems.map((item) => ({
@@ -183,12 +73,20 @@ export async function resolveWarpTarget(options) {
183
73
  if (options.branch) {
184
74
  const match = resolveWorktreeQuery(promptSources, options.branch);
185
75
  if (!match) {
186
- emitError(`no worktree found matching: ${options.branch}`);
76
+ emitError(`no worktree found matching: ${options.branch}`, skippedRegisteredRepos > 0
77
+ ? "Hint: Run 'gji doctor' to inspect stale repository entries.\n"
78
+ : undefined);
187
79
  return null;
188
80
  }
189
- return { branch: match.worktree.branch, path: match.worktree.path };
81
+ return {
82
+ branch: match.worktree.branch,
83
+ path: match.worktree.path,
84
+ repository: createNavigationRepository(match.repoName, match.repoRoot ??
85
+ (await detectRepository(match.worktree.path)).repoRoot),
86
+ };
190
87
  }
191
88
  const promptEntries = await buildWorktreePromptEntries(promptSources, {
89
+ metadata: "fast",
192
90
  queryPullRequests: options.queryPullRequests,
193
91
  });
194
92
  const path = await promptForWarpTarget(promptEntries);
@@ -196,8 +94,17 @@ export async function resolveWarpTarget(options) {
196
94
  options.stderr("Aborted\n");
197
95
  return null;
198
96
  }
199
- const chosen = promptEntries.find((item) => item.path === path);
200
- return { branch: chosen?.branch ?? null, path };
97
+ const chosenSource = promptSources.find((item) => item.worktree.path === path);
98
+ if (chosenSource === undefined) {
99
+ emitError("selected worktree is no longer registered");
100
+ return null;
101
+ }
102
+ return {
103
+ branch: chosenSource.worktree.branch,
104
+ path,
105
+ repository: createNavigationRepository(chosenSource.repoName, chosenSource.repoRoot ??
106
+ (await detectRepository(chosenSource.worktree.path)).repoRoot),
107
+ };
201
108
  }
202
109
  async function promptForWarpTarget(items) {
203
110
  return promptForSingleWorktree("Warp to a worktree", items);
@@ -0,0 +1,35 @@
1
+ import type { EffectiveGjiConfig } from "./config.js";
2
+ import { type BootstrapCommandRunner, type BootstrapEvent, type DependencyBootstrapReport, type DependencyBootstrapReporter } from "./dependency-bootstrap.js";
3
+ import type { DependencyBootstrapPolicyResolution } from "./dependency-bootstrap-prompt.js";
4
+ import { type CloneDirectory } from "./dir-clone.js";
5
+ import { type InstallPromptDependencies } from "./install-prompt.js";
6
+ import { type ClonedDirectory, type SyncDirectoryReporter } from "./sync-directories.js";
7
+ export type { ClonedDirectory as ClonedDir } from "./sync-directories.js";
8
+ export interface WorktreeBootstrapOptions {
9
+ branch: string;
10
+ cloneDirectory?: CloneDirectory;
11
+ config: EffectiveGjiConfig;
12
+ currentRoot?: string;
13
+ dependencyDetectionRoot?: string;
14
+ installDependencies?: InstallPromptDependencies;
15
+ runCommand?: BootstrapCommandRunner;
16
+ commandStdout?: (chunk: string) => void;
17
+ commandStderr?: (chunk: string) => void;
18
+ dependencyBootstrapPolicy?: DependencyBootstrapPolicyResolution;
19
+ json?: boolean;
20
+ nonInteractive: boolean;
21
+ repoRoot: string;
22
+ reporter: SyncDirectoryReporter & DependencyBootstrapReporter;
23
+ worktreePath: string;
24
+ }
25
+ export interface WorktreeBootstrapResult {
26
+ clonedDirs: readonly ClonedDirectory[];
27
+ dependencyBootstrap: DependencyBootstrapReport;
28
+ ready: boolean;
29
+ syncFileFailures: readonly BootstrapEvent[];
30
+ skippedDirs: readonly {
31
+ dir: string;
32
+ reason: string;
33
+ }[];
34
+ }
35
+ export declare function bootstrapWorktree(options: WorktreeBootstrapOptions): Promise<WorktreeBootstrapResult>;