@solaqua/gji 0.13.0 → 0.15.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 (45) hide show
  1. package/dist/application/worktree/go-resolution.js +5 -3
  2. package/dist/application/worktree/sources.d.ts +13 -4
  3. package/dist/application/worktree/sources.js +34 -13
  4. package/dist/cli/commands/clean.d.ts +4 -2
  5. package/dist/cli/commands/clean.js +338 -65
  6. package/dist/cli/commands/go.d.ts +2 -1
  7. package/dist/cli/commands/go.js +17 -13
  8. package/dist/cli/commands/hub.js +4 -1
  9. package/dist/cli/commands/warp.js +6 -4
  10. package/dist/cli/program.js +5 -0
  11. package/dist/domain/shared/concurrency.d.ts +1 -1
  12. package/dist/domain/shared/concurrency.js +6 -1
  13. package/dist/domain/worktree/path-filter.d.ts +13 -0
  14. package/dist/domain/worktree/path-filter.js +72 -0
  15. package/dist/domain/worktree/policy.d.ts +1 -0
  16. package/dist/domain/worktree/policy.js +1 -1
  17. package/dist/gji-bundle.mjs +746 -368
  18. package/dist/infrastructure/git/refs.js +3 -11
  19. package/dist/presentation/shell/completion.js +7 -2
  20. package/dist/presentation/worktree/picker.d.ts +9 -6
  21. package/dist/presentation/worktree/picker.js +19 -2
  22. package/man/man1/gji-back.1 +1 -1
  23. package/man/man1/gji-clean.1 +4 -1
  24. package/man/man1/gji-completion.1 +1 -1
  25. package/man/man1/gji-config.1 +1 -1
  26. package/man/man1/gji-doctor.1 +1 -1
  27. package/man/man1/gji-done.1 +1 -1
  28. package/man/man1/gji-go.1 +1 -1
  29. package/man/man1/gji-history.1 +1 -1
  30. package/man/man1/gji-init.1 +1 -1
  31. package/man/man1/gji-ls.1 +1 -1
  32. package/man/man1/gji-new.1 +1 -1
  33. package/man/man1/gji-open.1 +1 -1
  34. package/man/man1/gji-pr.1 +1 -1
  35. package/man/man1/gji-remove.1 +1 -1
  36. package/man/man1/gji-root.1 +1 -1
  37. package/man/man1/gji-run-hook.1 +1 -1
  38. package/man/man1/gji-status.1 +1 -1
  39. package/man/man1/gji-sync-files.1 +1 -1
  40. package/man/man1/gji-sync.1 +1 -1
  41. package/man/man1/gji-task.1 +1 -1
  42. package/man/man1/gji-undo.1 +1 -1
  43. package/man/man1/gji-warp.1 +1 -1
  44. package/man/man1/gji.1 +1 -1
  45. package/package.json +1 -1
@@ -1,10 +1,25 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
1
3
  import { confirm, isCancel } from "@clack/prompts";
2
- import { loadLinkedWorktrees } from "../../application/worktree/catalog.js";
4
+ import { loadLinkedWorktrees, } from "../../application/worktree/catalog.js";
5
+ import { deduplicateWorktreeSources, listRegisteredWorktreeSources, } from "../../application/worktree/sources.js";
6
+ import { mapWithConcurrency } from "../../domain/shared/concurrency.js";
7
+ import { createWorktreePathFilter, escapePathPattern, findGlobIndex, unescapePathPattern, } from "../../domain/worktree/path-filter.js";
8
+ import { expandTildeInPath } from "../../domain/worktree/policy.js";
3
9
  import { buildWorktreePromptEntries, promptForMultipleWorktrees, } from "../../presentation/worktree/picker.js";
4
10
  import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "../../presentation/worktree/prompts.js";
5
11
  import { defaultCliDependencies, } from "../dependencies.js";
6
12
  import { isHeadless } from "../runtime/headless.js";
7
- import { finalizeUndoOperation, recordUndoOperation } from "./undo.js";
13
+ import { finalizeUndoOperation, recordUndoOperation, } from "./undo.js";
14
+ function toPromptSource(candidate) {
15
+ return {
16
+ repoName: candidate.repoName,
17
+ repoRoot: candidate.repoRoot,
18
+ worktree: candidate.worktree,
19
+ };
20
+ }
21
+ const MAX_CLEAN_REPOSITORY_CONCURRENCY = 4;
22
+ const MAX_CLEAN_WORKTREE_CONCURRENCY = 8;
8
23
  export function createCleanCommand(dependencies = {}) {
9
24
  const promptForWorktrees = dependencies.promptForWorktrees ?? defaultPromptForWorktrees;
10
25
  const confirmRemoval = dependencies.confirmRemoval ?? defaultConfirmRemoval;
@@ -13,7 +28,7 @@ export function createCleanCommand(dependencies = {}) {
13
28
  const confirmForceDeleteBranch = dependencies.confirmForceDeleteBranch ?? defaultConfirmForceDeleteBranch;
14
29
  return async function runCleanCommand(options) {
15
30
  const runtime = options.runtime ?? defaultCliDependencies;
16
- const { readWorktreeHealth, isBranchMergedInto, resolveRemoteDefaultBranch, runGit, } = runtime.git;
31
+ const { readWorktreeHealth, isBranchMergedInto, resolveRemoteBase, runGit, } = runtime.git;
17
32
  const { loadEffectiveConfig } = runtime.config;
18
33
  const { releaseWorktreeSlot } = runtime.slots;
19
34
  const sourceDependencies = {
@@ -24,20 +39,115 @@ export function createCleanCommand(dependencies = {}) {
24
39
  const { formatLastCommit, formatUpstreamState, readWorktreeInfos, serializeWorktreeInfo, } = runtime.worktreeInfo;
25
40
  const { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeWorktree, } = runtime.worktreeLifecycle;
26
41
  const { linkedWorktrees, repository } = await loadLinkedWorktrees(options.cwd, sourceDependencies);
27
- const linkedCleanupCandidates = linkedWorktrees.filter((worktree) => worktree.path !== repository.currentRoot);
28
- const staleBaseRef = options.stale
29
- ? await resolveStaleBaseRef(repository.repoRoot, options.stderr, loadEffectiveConfig, resolveRemoteDefaultBranch, runGit)
30
- : null;
31
- const cleanupCandidates = options.stale
32
- ? await filterStaleCleanupCandidates(repository.repoRoot, linkedCleanupCandidates, staleBaseRef, readWorktreeHealth, isBranchMergedInto)
33
- : linkedCleanupCandidates;
34
- if (cleanupCandidates.length === 0) {
42
+ const pathPatterns = await canonicalizePathPatterns(options.cwd, options.paths ?? []);
43
+ const matchesPathFilter = createWorktreePathFilter(pathPatterns);
44
+ // An explicit path may point at another registered repository, so
45
+ // --path starts in the all-repositories scope.
46
+ const searchAllRepositories = pathPatterns.length > 0;
47
+ let allSourceCount = 0;
48
+ const emitNoCandidates = (sourceCount) => {
49
+ if (pathPatterns.length > 0 && sourceCount === 0) {
50
+ emitError(options, "No linked worktrees match --path");
51
+ return 1;
52
+ }
35
53
  if (options.stale) {
36
54
  emitNoStaleCandidates(options);
37
55
  return 0;
38
56
  }
39
57
  emitError(options, "No linked worktrees to clean");
40
58
  return 1;
59
+ };
60
+ const currentSources = linkedWorktrees
61
+ .filter((worktree) => worktree.path !== repository.currentRoot &&
62
+ matchesPathFilter(worktree.path))
63
+ .map((worktree) => ({
64
+ repoName: repository.repoName,
65
+ repoRoot: repository.repoRoot,
66
+ worktree,
67
+ }));
68
+ const cleanupDependencies = {
69
+ isBranchMergedInto,
70
+ loadEffectiveConfig,
71
+ readWorktreeHealth,
72
+ resolveRemoteBase,
73
+ runGit,
74
+ };
75
+ const currentCandidates = await resolveCleanupCandidates({
76
+ sources: currentSources,
77
+ stale: options.stale,
78
+ stderr: options.stderr,
79
+ }, cleanupDependencies);
80
+ let activeCandidates = currentCandidates;
81
+ let allCandidates = null;
82
+ let currentScope = true;
83
+ const interactiveSelection = !options.force && !options.json && !isHeadless();
84
+ const skippedRepositories = [];
85
+ const loadAllCandidates = async (signal) => {
86
+ throwIfAborted(signal);
87
+ if (allCandidates !== null)
88
+ return allCandidates;
89
+ const registered = await listRegisteredWorktreeSources({
90
+ cwd: options.cwd,
91
+ repositoryPort: sourceDependencies,
92
+ signal,
93
+ });
94
+ skippedRepositories.push(...registered.skipped);
95
+ if (registered.skipped.length > 0) {
96
+ reportSkippedRepositories(skippedRepositories, options.stderr);
97
+ }
98
+ const sources = deduplicateWorktreeSources([
99
+ ...currentSources,
100
+ ...registered.sources,
101
+ ]).filter((source) => source.repoRoot !== undefined &&
102
+ source.worktree.path !== source.repoRoot &&
103
+ source.worktree.path !== repository.currentRoot &&
104
+ matchesPathFilter(source.worktree.path));
105
+ allSourceCount = sources.length;
106
+ const otherRepositorySources = sources.filter((source) => source.repoRoot !== repository.repoRoot);
107
+ const otherRepositoryCandidates = await resolveCleanupCandidates({
108
+ sources: otherRepositorySources,
109
+ stale: options.stale,
110
+ stderr: options.stderr,
111
+ signal,
112
+ }, cleanupDependencies);
113
+ allCandidates = [...currentCandidates, ...otherRepositoryCandidates];
114
+ throwIfAborted(signal);
115
+ return allCandidates;
116
+ };
117
+ const scope = {
118
+ label: "current repository",
119
+ toggleLabel: "all repositories",
120
+ toggle: async (signal) => {
121
+ const nextCurrentScope = !currentScope;
122
+ const nextCandidates = nextCurrentScope
123
+ ? currentCandidates
124
+ : await loadAllCandidates(signal);
125
+ throwIfAborted(signal);
126
+ currentScope = nextCurrentScope;
127
+ activeCandidates = nextCandidates;
128
+ return {
129
+ sources: nextCandidates.map(toPromptSource),
130
+ metadata: nextCurrentScope ? "full" : "fast",
131
+ label: nextCurrentScope ? "current repository" : "all repositories",
132
+ toggleLabel: nextCurrentScope
133
+ ? "all repositories"
134
+ : "current repository",
135
+ };
136
+ },
137
+ };
138
+ if (searchAllRepositories ||
139
+ (interactiveSelection && currentCandidates.length === 0)) {
140
+ const loadedAllCandidates = await loadAllCandidates();
141
+ if (loadedAllCandidates.length === 0) {
142
+ return emitNoCandidates(allSourceCount);
143
+ }
144
+ currentScope = false;
145
+ activeCandidates = loadedAllCandidates;
146
+ scope.label = "all repositories";
147
+ scope.toggleLabel = "current repository";
148
+ }
149
+ else if (currentCandidates.length === 0) {
150
+ return emitNoCandidates(currentSources.length);
41
151
  }
42
152
  if (!options.dryRun && !options.force && (options.json || isHeadless())) {
43
153
  const message = "--force is required";
@@ -53,20 +163,21 @@ export function createCleanCommand(dependencies = {}) {
53
163
  const shouldSelectAll = options.force ||
54
164
  (options.dryRun && (options.stale || options.json || isHeadless()));
55
165
  const selections = shouldSelectAll
56
- ? cleanupCandidates.map((w) => w.path)
57
- : await promptForWorktrees(await buildWorktreePromptEntries(cleanupCandidates.map((worktree) => ({
58
- repoName: repository.repoName,
59
- worktree,
60
- })), { catalog: runtime.worktreeCatalog }));
166
+ ? activeCandidates.map(({ worktree }) => worktree.path)
167
+ : await promptForWorktrees(await buildWorktreePromptEntries(activeCandidates, {
168
+ metadata: currentScope ? "full" : "fast",
169
+ catalog: runtime.worktreeCatalog,
170
+ }), scope, runtime.worktreeCatalog);
61
171
  if (!selections || selections.length === 0) {
62
172
  options.stderr("Aborted\n");
63
173
  return 1;
64
174
  }
65
- const selectedWorktrees = resolveSelectedWorktrees(cleanupCandidates, selections);
66
- if (selectedWorktrees.length !== selections.length) {
175
+ const selectedCandidates = resolveSelectedCandidates(activeCandidates, selections);
176
+ if (selectedCandidates.length !== selections.length) {
67
177
  options.stderr("Selected worktree no longer exists\n");
68
178
  return 1;
69
179
  }
180
+ const selectedWorktrees = selectedCandidates.map(({ worktree }) => worktree);
70
181
  const selectedWorktreeInfos = await readWorktreeInfos(selectedWorktrees);
71
182
  const selectedInfoByPath = new Map(selectedWorktreeInfos.map((info) => [info.path, info]));
72
183
  if (!options.dryRun &&
@@ -87,28 +198,35 @@ export function createCleanCommand(dependencies = {}) {
87
198
  }
88
199
  return 0;
89
200
  }
90
- const removedWorktrees = [];
201
+ const candidatesByRepository = groupCandidatesByRepository(selectedCandidates);
202
+ const journals = new Map();
91
203
  const failures = [];
92
- let journal;
93
204
  try {
94
- journal = await recordUndoOperation("clean", repository.repoRoot, selectedWorktrees, undefined, runtime);
205
+ for (const [repoRoot, candidates] of candidatesByRepository) {
206
+ const journal = await recordUndoOperation("clean", repoRoot, candidates.map(({ worktree }) => worktree), undefined, runtime);
207
+ if (!journal) {
208
+ await discardUndoRecords(journals.values(), runtime.configStore.GLOBAL_CONFIG_DIRECTORY);
209
+ emitError(options, "could not capture undo state; no worktrees were removed");
210
+ return 1;
211
+ }
212
+ journals.set(repoRoot, journal);
213
+ }
95
214
  }
96
215
  catch (error) {
216
+ await discardUndoRecords(journals.values(), runtime.configStore.GLOBAL_CONFIG_DIRECTORY);
97
217
  emitError(options, `could not write undo journal; no worktrees were removed: ${toMessage(error)}`);
98
218
  return 1;
99
219
  }
100
- if (!journal) {
101
- emitError(options, "could not capture undo state; no worktrees were removed");
102
- return 1;
103
- }
104
- for (const worktree of selectedWorktrees) {
220
+ const removedCandidates = [];
221
+ for (const candidate of selectedCandidates) {
222
+ const { repoRoot, staleBaseRef, worktree } = candidate;
105
223
  if (options.stale &&
106
- !(await isStaleCleanupCandidate(repository.repoRoot, worktree, staleBaseRef, readWorktreeHealth, isBranchMergedInto))) {
224
+ !(await isStaleCleanupCandidate({ repoRoot, worktree, baseBranch: staleBaseRef }, cleanupDependencies))) {
107
225
  options.stderr(`Skipped ${worktree.path}: no longer a safe stale cleanup candidate\n`);
108
226
  continue;
109
227
  }
110
228
  try {
111
- await removeWorktree(repository.repoRoot, worktree.path);
229
+ await removeWorktree(repoRoot, worktree.path);
112
230
  }
113
231
  catch (error) {
114
232
  if (!isWorktreeForceRemovalError(error)) {
@@ -135,7 +253,7 @@ export function createCleanCommand(dependencies = {}) {
135
253
  continue;
136
254
  }
137
255
  try {
138
- await forceRemoveWorktree(repository.repoRoot, worktree.path);
256
+ await forceRemoveWorktree(repoRoot, worktree.path);
139
257
  }
140
258
  catch (forceError) {
141
259
  failures.push({
@@ -146,10 +264,10 @@ export function createCleanCommand(dependencies = {}) {
146
264
  continue;
147
265
  }
148
266
  }
149
- removedWorktrees.push(worktree);
267
+ removedCandidates.push(candidate);
150
268
  if (worktree.branch) {
151
269
  try {
152
- await deleteBranch(repository.repoRoot, worktree.branch);
270
+ await deleteBranch(repoRoot, worktree.branch);
153
271
  }
154
272
  catch (error) {
155
273
  if (!isBranchUnmergedError(error)) {
@@ -163,7 +281,7 @@ export function createCleanCommand(dependencies = {}) {
163
281
  if (options.force ||
164
282
  (await confirmForceDeleteBranch(worktree.branch))) {
165
283
  try {
166
- await forceDeleteBranch(repository.repoRoot, worktree.branch);
284
+ await forceDeleteBranch(repoRoot, worktree.branch);
167
285
  }
168
286
  catch (forceError) {
169
287
  options.stderr(`Failed to delete branch ${worktree.branch}: ${toMessage(forceError)}\n`);
@@ -175,56 +293,172 @@ export function createCleanCommand(dependencies = {}) {
175
293
  }
176
294
  }
177
295
  }
178
- await finalizeUndoOperation(journal, removedWorktrees, undefined, runtime.configStore.GLOBAL_CONFIG_DIRECTORY);
179
- await Promise.all(removedWorktrees.map((worktree) => releaseWorktreeSlot(worktree.path)));
296
+ for (const [repoRoot, journal] of journals) {
297
+ await finalizeUndoOperation(journal, removedCandidates
298
+ .filter((candidate) => candidate.repoRoot === repoRoot)
299
+ .map(({ worktree }) => worktree), undefined, runtime.configStore.GLOBAL_CONFIG_DIRECTORY);
300
+ }
301
+ const undoRecords = remainingUndoRecords(journals.values(), removedCandidates);
302
+ await Promise.all(removedCandidates.map(({ worktree }) => releaseWorktreeSlot(worktree.path)));
180
303
  if (options.json) {
181
- const removed = removedWorktrees.map((worktree) => {
304
+ const removed = removedCandidates.map(({ worktree }) => {
182
305
  const info = selectedInfoByPath.get(worktree.path);
183
306
  return info === undefined
184
307
  ? { branch: worktree.branch, path: worktree.path }
185
308
  : serializeWorktreeInfo(info);
186
309
  });
187
- const payload = failures.length === 0 ? { removed } : { removed, failed: failures };
310
+ const payload = {
311
+ removed,
312
+ ...(failures.length === 0 ? {} : { failed: failures }),
313
+ ...(needsExplicitUndoIds(undoRecords, repository.repoRoot)
314
+ ? {
315
+ undo: undoRecords.map(({ id, repoRoot }) => ({ id, repoRoot })),
316
+ }
317
+ : {}),
318
+ };
188
319
  options.stdout(`${JSON.stringify(payload, null, 2)}\n`);
189
320
  }
190
321
  else if (failures.length > 0) {
191
322
  reportCleanFailures(failures, options.stderr);
323
+ emitUndoHints(undoRecords, repository.repoRoot, options.stderr);
192
324
  }
193
325
  else {
194
- options.stderr("undo: gji undo\n");
195
- options.stdout(`${repository.repoRoot}\n`);
326
+ emitUndoHints(undoRecords, repository.repoRoot, options.stderr);
327
+ options.stdout(`${[...new Set(selectedCandidates.map(({ repoRoot }) => repoRoot))].join("\n")}\n`);
196
328
  }
197
329
  return failures.length === 0 ? 0 : 1;
198
330
  };
199
331
  }
200
332
  export const runCleanCommand = createCleanCommand();
201
- async function filterStaleCleanupCandidates(repoRoot, worktrees, baseBranch, readWorktreeHealth, isBranchMergedInto) {
333
+ async function canonicalizePathPatterns(cwd, patterns) {
334
+ return Promise.all(patterns.map((pattern) => canonicalizeLiteralPrefix(resolve(cwd, expandTildeInPath(pattern)))));
335
+ }
336
+ // Git records canonical worktree paths, so resolve symlinks in the pattern's
337
+ // longest existing literal prefix before matching.
338
+ async function canonicalizeLiteralPrefix(pattern) {
339
+ const globIndex = findGlobIndex(pattern);
340
+ let prefix = globIndex === -1 ? pattern : dirname(`${pattern.slice(0, globIndex)}x`);
341
+ let suffix = pattern.slice(prefix.length);
342
+ for (;;) {
343
+ try {
344
+ const canonicalPrefix = escapePathPattern(await realpath(unescapePathPattern(prefix)));
345
+ return `${canonicalPrefix}${suffix}`.replace(/^\/\//, "/");
346
+ }
347
+ catch {
348
+ const parent = dirname(prefix);
349
+ if (parent === prefix)
350
+ return pattern;
351
+ suffix = join("/", basename(prefix), suffix);
352
+ prefix = parent;
353
+ }
354
+ }
355
+ }
356
+ async function resolveCleanupCandidates(options, dependencies) {
357
+ const { sources, stale, stderr, signal } = options;
358
+ const grouped = new Map();
359
+ for (const source of sources) {
360
+ if (source.repoRoot === undefined)
361
+ continue;
362
+ const existing = grouped.get(source.repoRoot);
363
+ if (existing === undefined) {
364
+ grouped.set(source.repoRoot, {
365
+ repoName: source.repoName,
366
+ worktrees: [source.worktree],
367
+ });
368
+ }
369
+ else {
370
+ existing.worktrees.push(source.worktree);
371
+ }
372
+ }
373
+ const results = await mapWithConcurrency([...grouped.entries()], MAX_CLEAN_REPOSITORY_CONCURRENCY, async ([repoRoot, group]) => {
374
+ throwIfAborted(signal);
375
+ const staleBaseRef = stale
376
+ ? await resolveStaleBaseRef({ repoRoot, stderr }, dependencies)
377
+ : null;
378
+ const worktrees = stale
379
+ ? await filterStaleCleanupCandidates({
380
+ repoRoot,
381
+ worktrees: group.worktrees,
382
+ baseBranch: staleBaseRef,
383
+ signal,
384
+ }, dependencies)
385
+ : group.worktrees;
386
+ return worktrees.map((worktree) => ({
387
+ repoName: group.repoName,
388
+ repoRoot,
389
+ staleBaseRef,
390
+ worktree,
391
+ }));
392
+ }, signal);
393
+ return results.flat();
394
+ }
395
+ function resolveSelectedCandidates(candidates, selections) {
396
+ const selected = [];
397
+ const seenPaths = new Set();
398
+ for (const selection of selections) {
399
+ const candidate = candidates.find((entry) => entry.worktree.path === selection ||
400
+ entry.worktree.branch === selection);
401
+ if (candidate === undefined || seenPaths.has(candidate.worktree.path)) {
402
+ continue;
403
+ }
404
+ selected.push(candidate);
405
+ seenPaths.add(candidate.worktree.path);
406
+ }
407
+ return selected;
408
+ }
409
+ function groupCandidatesByRepository(candidates) {
410
+ const grouped = new Map();
411
+ for (const candidate of candidates) {
412
+ const existing = grouped.get(candidate.repoRoot);
413
+ if (existing === undefined)
414
+ grouped.set(candidate.repoRoot, [candidate]);
415
+ else
416
+ existing.push(candidate);
417
+ }
418
+ return grouped;
419
+ }
420
+ function remainingUndoRecords(records, removedCandidates) {
421
+ return [...records].filter((record) => removedCandidates.some((candidate) => candidate.repoRoot === record.repoRoot &&
422
+ record.entries.some((entry) => entry.path === candidate.worktree.path)));
423
+ }
424
+ async function discardUndoRecords(records, globalConfigDirectory) {
425
+ await Promise.all([...records].map((record) => finalizeUndoOperation(record, [], undefined, globalConfigDirectory)));
426
+ }
427
+ async function filterStaleCleanupCandidates(options, dependencies) {
428
+ const { repoRoot, worktrees, baseBranch, signal } = options;
429
+ throwIfAborted(signal);
202
430
  if (baseBranch === null) {
203
431
  return [];
204
432
  }
205
- const results = await Promise.all(worktrees.map((worktree) => isStaleCleanupCandidate(repoRoot, worktree, baseBranch, readWorktreeHealth, isBranchMergedInto)));
433
+ const results = await mapWithConcurrency(worktrees, MAX_CLEAN_WORKTREE_CONCURRENCY, (worktree) => isStaleCleanupCandidate({ repoRoot, worktree, baseBranch }, dependencies), signal);
434
+ throwIfAborted(signal);
206
435
  return worktrees.filter((_, index) => results[index]);
207
436
  }
208
- async function resolveStaleBaseRef(repoRoot, stderr, loadEffectiveConfig, resolveRemoteDefaultBranch, runGit) {
209
- const config = await loadEffectiveConfig(repoRoot, undefined, stderr);
437
+ async function resolveStaleBaseRef(options, dependencies) {
438
+ const { repoRoot, stderr } = options;
439
+ const config = await dependencies.loadEffectiveConfig(repoRoot, undefined, stderr);
210
440
  const remote = resolveConfiguredString(config.syncRemote) ?? "origin";
211
441
  const configuredDefaultBranch = resolveConfiguredString(config.syncDefaultBranch);
212
- if (configuredDefaultBranch) {
213
- return await resolveFetchedRemoteRef(repoRoot, remote, configuredDefaultBranch, runGit);
214
- }
215
442
  try {
216
- const remoteDefaultBranch = await resolveRemoteDefaultBranch(repoRoot, remote);
217
- return remoteDefaultBranch === null
443
+ const remoteBase = await dependencies.resolveRemoteBase(repoRoot, remote, configuredDefaultBranch ?? undefined);
444
+ return remoteBase === null
218
445
  ? null
219
- : await resolveFetchedRemoteRef(repoRoot, remote, remoteDefaultBranch, runGit);
446
+ : resolveFetchedRemoteRef({ branch: remoteBase.branch, remote, repoRoot }, dependencies.runGit);
220
447
  }
221
448
  catch {
222
449
  return null;
223
450
  }
224
451
  }
225
- async function resolveFetchedRemoteRef(repoRoot, remote, branch, runGit) {
452
+ async function resolveFetchedRemoteRef(options, runGit) {
453
+ const { repoRoot, remote, branch } = options;
226
454
  try {
227
455
  await runGit(repoRoot, ["fetch", "--prune", remote]);
456
+ await runGit(repoRoot, [
457
+ "rev-parse",
458
+ "--verify",
459
+ "--quiet",
460
+ `${remote}/${branch}`,
461
+ ]);
228
462
  return `${remote}/${branch}`;
229
463
  }
230
464
  catch {
@@ -234,31 +468,37 @@ async function resolveFetchedRemoteRef(repoRoot, remote, branch, runGit) {
234
468
  function resolveConfiguredString(value) {
235
469
  return typeof value === "string" && value.length > 0 ? value : null;
236
470
  }
237
- async function isStaleCleanupCandidate(repoRoot, worktree, baseBranch, readWorktreeHealth, isBranchMergedInto) {
471
+ async function isStaleCleanupCandidate(options, dependencies) {
472
+ const { repoRoot, worktree, baseBranch } = options;
238
473
  if (baseBranch === null) {
239
474
  return false;
240
475
  }
241
476
  if (worktree.branch === null) {
242
477
  return false;
243
478
  }
244
- const health = await readWorktreeHealth(worktree.path);
479
+ const health = await dependencies.readWorktreeHealth(worktree.path);
245
480
  if (health.status !== "clean" || !health.upstreamGone) {
246
481
  return false;
247
482
  }
248
- return isBranchMergedInto(repoRoot, worktree.branch, baseBranch);
483
+ if (await dependencies.isBranchMergedInto(repoRoot, worktree.branch, baseBranch)) {
484
+ return true;
485
+ }
486
+ return isBranchPatchEquivalentInto({ baseBranch, repoRoot, worktreeBranch: worktree.branch }, dependencies.runGit);
249
487
  }
250
- function resolveSelectedWorktrees(worktrees, selections) {
251
- const selectedWorktrees = [];
252
- const seenPaths = new Set();
253
- for (const selection of selections) {
254
- const worktree = worktrees.find((entry) => entry.path === selection || entry.branch === selection);
255
- if (!worktree || seenPaths.has(worktree.path)) {
256
- continue;
257
- }
258
- selectedWorktrees.push(worktree);
259
- seenPaths.add(worktree.path);
488
+ async function isBranchPatchEquivalentInto(options, runGit) {
489
+ const { repoRoot, worktreeBranch, baseBranch } = options;
490
+ try {
491
+ const output = await runGit(repoRoot, [
492
+ "cherry",
493
+ baseBranch,
494
+ worktreeBranch,
495
+ ]);
496
+ const lines = output.split("\n").filter(Boolean);
497
+ return lines.length > 0 && lines.every((line) => line.startsWith("-"));
498
+ }
499
+ catch {
500
+ return false;
260
501
  }
261
- return selectedWorktrees;
262
502
  }
263
503
  function reportCleanFailures(failures, stderr) {
264
504
  const noun = failures.length === 1 ? "worktree" : "worktrees";
@@ -268,6 +508,36 @@ function reportCleanFailures(failures, stderr) {
268
508
  stderr(`- ${failure.path} (${branch}): ${failure.message}\n`);
269
509
  }
270
510
  }
511
+ function emitUndoHints(records, currentRepositoryRoot, stderr) {
512
+ if (records.length === 0)
513
+ return;
514
+ if (!needsExplicitUndoIds(records, currentRepositoryRoot)) {
515
+ stderr("undo: gji undo\n");
516
+ return;
517
+ }
518
+ const message = records.length === 1
519
+ ? "undo: restore with:"
520
+ : "undo: restore each repository with:";
521
+ stderr(`${message}\n`);
522
+ for (const record of records) {
523
+ stderr(` gji undo --id ${record.id} (${record.repoRoot})\n`);
524
+ }
525
+ }
526
+ function needsExplicitUndoIds(records, currentRepositoryRoot) {
527
+ return (records.length > 1 ||
528
+ records.some((record) => record.repoRoot !== currentRepositoryRoot));
529
+ }
530
+ function reportSkippedRepositories(repositories, stderr) {
531
+ const noun = repositories.length === 1 ? "repository" : "repositories";
532
+ stderr(`Skipped ${repositories.length} registered ${noun} because worktrees could not be listed:\n`);
533
+ for (const repository of repositories) {
534
+ stderr(`- ${repository.name} (${repository.path})\n`);
535
+ }
536
+ }
537
+ function throwIfAborted(signal) {
538
+ if (signal?.aborted)
539
+ throw new Error("Operation cancelled");
540
+ }
271
541
  function formatCleanInfo(info, formatLastCommit, formatUpstreamState) {
272
542
  const branch = info.branch === null ? "detached" : `branch: ${info.branch}`;
273
543
  const status = `status: ${info.status}`;
@@ -296,8 +566,11 @@ function emitNoStaleCandidates(options) {
296
566
  function toMessage(error) {
297
567
  return error instanceof Error ? error.message : String(error);
298
568
  }
299
- async function defaultPromptForWorktrees(worktrees) {
300
- return promptForMultipleWorktrees("Choose worktrees to clean", worktrees);
569
+ async function defaultPromptForWorktrees(worktrees, scope, catalog) {
570
+ return promptForMultipleWorktrees("Choose worktrees to clean", worktrees, {
571
+ catalog,
572
+ scope,
573
+ });
301
574
  }
302
575
  async function defaultConfirmRemoval(worktrees) {
303
576
  const branchCount = worktrees.filter((worktree) => worktree.branch !== null).length;
@@ -1,3 +1,4 @@
1
+ import type { WorktreeCatalogDependencies } from "../../application/worktree/catalog.js";
1
2
  import { type QueryWorktreePullRequests, type WorktreePromptEntry, type WorktreePromptScope } from "../../presentation/worktree/picker.js";
2
3
  import { type CliRuntime } from "../dependencies.js";
3
4
  export interface GoCommandOptions {
@@ -14,7 +15,7 @@ export interface GoCommandOptions {
14
15
  type GoRuntime = CliRuntime<"bootstrap" | "config" | "contextCard" | "git" | "history" | "historyStore" | "hooks" | "integrations" | "repositoryContext" | "repositoryRefs" | "repositoryRegistry" | "slots" | "tasks" | "worktrees" | "worktreeCatalog">;
15
16
  export interface GoCommandDependencies {
16
17
  confirmBranchCreation: (branch: string) => Promise<boolean>;
17
- promptForWorktree: (worktrees: WorktreePromptEntry[], scope?: WorktreePromptScope) => Promise<string | null>;
18
+ promptForWorktree: (worktrees: WorktreePromptEntry[], scope?: WorktreePromptScope, catalog?: WorktreeCatalogDependencies) => Promise<string | null>;
18
19
  queryPullRequests: QueryWorktreePullRequests;
19
20
  runtime: GoRuntime;
20
21
  }
@@ -49,13 +49,16 @@ export function createGoCommand(dependencies = {}) {
49
49
  let promptSources = currentSources;
50
50
  const loadAllSources = async () => {
51
51
  if (registeredSources === null) {
52
- registeredSources = await listRegisteredWorktreeSources(options.cwd, {
53
- ...runtime.repositoryContext,
54
- ...runtime.repositoryRegistry,
55
- ...runtime.worktrees,
56
- }, () => {
57
- skippedRegisteredRepos++;
52
+ const registered = await listRegisteredWorktreeSources({
53
+ cwd: options.cwd,
54
+ repositoryPort: {
55
+ ...runtime.repositoryContext,
56
+ ...runtime.repositoryRegistry,
57
+ ...runtime.worktrees,
58
+ },
58
59
  });
60
+ registeredSources = registered.sources;
61
+ skippedRegisteredRepos = registered.skipped.length;
59
62
  }
60
63
  return deduplicateSources([...currentSources, ...registeredSources]);
61
64
  };
@@ -79,10 +82,8 @@ export function createGoCommand(dependencies = {}) {
79
82
  currentRepositoryScope = nextCurrentRepositoryScope;
80
83
  promptSources = nextSources;
81
84
  return {
82
- entries: await buildWorktreePromptEntries(promptSources, {
83
- metadata: currentRepositoryScope ? "full" : "fast",
84
- catalog: withPullRequestQueries(runtime, dependencies.queryPullRequests),
85
- }),
85
+ sources: promptSources,
86
+ metadata: currentRepositoryScope ? "full" : "fast",
86
87
  label: currentRepositoryScope
87
88
  ? "current repository"
88
89
  : "all repositories",
@@ -97,7 +98,7 @@ export function createGoCommand(dependencies = {}) {
97
98
  metadata: repository ? "full" : "fast",
98
99
  catalog: withPullRequestQueries(runtime, dependencies.queryPullRequests),
99
100
  });
100
- const selectedPath = await prompt(promptEntries, scope);
101
+ const selectedPath = await prompt(promptEntries, scope, withPullRequestQueries(runtime, dependencies.queryPullRequests));
101
102
  if (!selectedPath) {
102
103
  options.stderr("Aborted\n");
103
104
  return 1;
@@ -304,6 +305,9 @@ async function defaultConfirmBranchCreation(branch) {
304
305
  });
305
306
  return !isCancel(choice) && choice;
306
307
  }
307
- async function promptForWorktree(worktrees, scope) {
308
- return promptForSingleWorktree("Choose a worktree", worktrees, { scope });
308
+ async function promptForWorktree(worktrees, scope, catalog) {
309
+ return promptForSingleWorktree("Choose a worktree", worktrees, {
310
+ catalog,
311
+ scope,
312
+ });
309
313
  }
@@ -18,7 +18,10 @@ export async function buildHubData(cwd, dependencies = {}, runtime = defaultCliD
18
18
  const queryRepositoryPullRequests = dependencies.queryRepositoryPullRequests ??
19
19
  runtime.pullRequests.listOpenPullRequestsForRepository;
20
20
  const currentRepository = await detectRepository(cwd).catch(() => null);
21
- const sources = await listDiscoverableWorktreeSources(cwd, sourceDependencies);
21
+ const { sources } = await listDiscoverableWorktreeSources({
22
+ cwd,
23
+ repositoryPort: sourceDependencies,
24
+ });
22
25
  const groups = new Map();
23
26
  for (const source of sources) {
24
27
  if (!source.repoRoot)
@@ -65,10 +65,12 @@ export async function resolveWarpTarget(options) {
65
65
  emitError("not in a git repository and no repos registered yet.", "Use any gji command inside a repository to register it.\n");
66
66
  return null;
67
67
  }
68
- let skippedRegisteredRepos = 0;
69
- const allItems = (await listRegisteredWorktreeSources(options.cwd, sourceDependencies, () => {
70
- skippedRegisteredRepos++;
71
- })).filter((item) => item.repoRoot !== options.excludeRepoRoot);
68
+ const registered = await listRegisteredWorktreeSources({
69
+ cwd: options.cwd,
70
+ repositoryPort: sourceDependencies,
71
+ });
72
+ const skippedRegisteredRepos = registered.skipped.length;
73
+ const allItems = registered.sources.filter((item) => item.repoRoot !== options.excludeRepoRoot);
72
74
  if (allItems.length === 0) {
73
75
  emitError("no accessible worktrees found in any registered repo.", skippedRegisteredRepos > 0
74
76
  ? "Hint: Run 'gji doctor' to inspect stale repository entries.\n"