@solaqua/gji 0.13.0 → 0.14.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 (39) 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 +3 -2
  5. package/dist/cli/commands/clean.js +299 -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/domain/shared/concurrency.d.ts +1 -1
  11. package/dist/domain/shared/concurrency.js +6 -1
  12. package/dist/gji-bundle.mjs +492 -225
  13. package/dist/infrastructure/git/refs.js +3 -11
  14. package/dist/presentation/worktree/picker.d.ts +9 -6
  15. package/dist/presentation/worktree/picker.js +19 -2
  16. package/man/man1/gji-back.1 +1 -1
  17. package/man/man1/gji-clean.1 +1 -1
  18. package/man/man1/gji-completion.1 +1 -1
  19. package/man/man1/gji-config.1 +1 -1
  20. package/man/man1/gji-doctor.1 +1 -1
  21. package/man/man1/gji-done.1 +1 -1
  22. package/man/man1/gji-go.1 +1 -1
  23. package/man/man1/gji-history.1 +1 -1
  24. package/man/man1/gji-init.1 +1 -1
  25. package/man/man1/gji-ls.1 +1 -1
  26. package/man/man1/gji-new.1 +1 -1
  27. package/man/man1/gji-open.1 +1 -1
  28. package/man/man1/gji-pr.1 +1 -1
  29. package/man/man1/gji-remove.1 +1 -1
  30. package/man/man1/gji-root.1 +1 -1
  31. package/man/man1/gji-run-hook.1 +1 -1
  32. package/man/man1/gji-status.1 +1 -1
  33. package/man/man1/gji-sync-files.1 +1 -1
  34. package/man/man1/gji-sync.1 +1 -1
  35. package/man/man1/gji-task.1 +1 -1
  36. package/man/man1/gji-undo.1 +1 -1
  37. package/man/man1/gji-warp.1 +1 -1
  38. package/man/man1/gji.1 +1 -1
  39. package/package.json +1 -1
@@ -46,10 +46,12 @@ export async function resolveGoBranch(options) {
46
46
  return finish({ kind: "existing", source: pullRequestMatches[0] }, false);
47
47
  }
48
48
  }
49
- let skippedRegisteredRepos = 0;
50
- const registeredSources = await listRegisteredWorktreeSources(cwd, sourceDependencies, () => {
51
- skippedRegisteredRepos++;
49
+ const registered = await listRegisteredWorktreeSources({
50
+ cwd,
51
+ repositoryPort: sourceDependencies,
52
52
  });
53
+ const registeredSources = registered.sources;
54
+ const skippedRegisteredRepos = registered.skipped.length;
53
55
  const crossRepoSources = registeredSources.filter((source) => source.repoRoot !== repository?.repoRoot);
54
56
  const crossMatches = await resolveExistingWorktreeMatches(crossRepoSources, branch, (!localBranch && !remoteBranch) || isPullRequestUrl(branch), repositoryPort);
55
57
  if (crossMatches.length === 1) {
@@ -1,7 +1,16 @@
1
1
  import type { RepoRegistryEntry } from "../../domain/repository/registry.js";
2
2
  import type { WorktreeSource } from "../../domain/worktree/source.js";
3
3
  import type { RepositoryContextPort, RepositoryRegistryPort, WorktreePort } from "../../ports/repository.js";
4
- type WorktreeSourceDependencies = RepositoryContextPort & RepositoryRegistryPort & WorktreePort;
5
- export declare function listRegisteredWorktreeSources(cwd: string, repositoryPort: WorktreeSourceDependencies, onSkipped?: (entry: RepoRegistryEntry) => void): Promise<WorktreeSource[]>;
6
- export declare function listDiscoverableWorktreeSources(cwd: string, repositoryPort: WorktreeSourceDependencies, onSkipped?: (entry: RepoRegistryEntry) => void): Promise<WorktreeSource[]>;
7
- export {};
4
+ export type WorktreeSourceDependencies = RepositoryContextPort & RepositoryRegistryPort & WorktreePort;
5
+ export interface WorktreeSourceDiscovery {
6
+ skipped: RepoRegistryEntry[];
7
+ sources: WorktreeSource[];
8
+ }
9
+ export interface ListWorktreeSourcesOptions {
10
+ cwd: string;
11
+ repositoryPort: WorktreeSourceDependencies;
12
+ signal?: AbortSignal;
13
+ }
14
+ export declare function listRegisteredWorktreeSources(options: ListWorktreeSourcesOptions): Promise<WorktreeSourceDiscovery>;
15
+ export declare function listDiscoverableWorktreeSources(options: ListWorktreeSourcesOptions): Promise<WorktreeSourceDiscovery>;
16
+ export declare function deduplicateWorktreeSources(sources: WorktreeSource[]): WorktreeSource[];
@@ -1,25 +1,31 @@
1
1
  import { mapWithConcurrency } from "../../domain/shared/concurrency.js";
2
2
  const MAX_REPOSITORY_DISCOVERY_CONCURRENCY = 4;
3
- export async function listRegisteredWorktreeSources(cwd, repositoryPort, onSkipped) {
3
+ export async function listRegisteredWorktreeSources(options) {
4
+ const { cwd, repositoryPort, signal } = options;
5
+ throwIfAborted(signal);
4
6
  const registry = await repositoryPort.loadRegistry();
5
7
  const currentRoot = await repositoryPort
6
8
  .detectRepository(cwd)
7
9
  .then((repository) => repository.currentRoot)
8
10
  .catch(() => null);
11
+ throwIfAborted(signal);
9
12
  const results = await mapWithConcurrency(registry, MAX_REPOSITORY_DISCOVERY_CONCURRENCY, async (entry) => {
13
+ throwIfAborted(signal);
10
14
  try {
11
15
  const worktrees = await repositoryPort.listWorktrees(entry.path);
12
- return { entry, worktrees };
16
+ return { entry, skipped: false, worktrees };
13
17
  }
14
18
  catch {
15
- onSkipped?.(entry);
16
- return null;
19
+ return { entry, skipped: true, worktrees: [] };
17
20
  }
18
- });
21
+ }, signal);
19
22
  const allItems = [];
23
+ const skipped = [];
20
24
  for (const result of results) {
21
- if (result === null)
25
+ if (result.skipped) {
26
+ skipped.push(result.entry);
22
27
  continue;
28
+ }
23
29
  const { entry, worktrees } = result;
24
30
  for (const worktree of worktrees) {
25
31
  allItems.push({
@@ -32,15 +38,20 @@ export async function listRegisteredWorktreeSources(cwd, repositoryPort, onSkipp
32
38
  });
33
39
  }
34
40
  }
35
- return allItems;
41
+ return { skipped, sources: allItems };
36
42
  }
37
- export async function listDiscoverableWorktreeSources(cwd, repositoryPort, onSkipped) {
43
+ export async function listDiscoverableWorktreeSources(options) {
44
+ const { cwd, repositoryPort } = options;
38
45
  const currentRepository = await repositoryPort
39
46
  .detectRepository(cwd)
40
47
  .catch(() => null);
41
- const registeredSources = await listRegisteredWorktreeSources(cwd, repositoryPort, onSkipped);
42
- if (currentRepository === null)
43
- return dedupeSources(registeredSources);
48
+ const registered = await listRegisteredWorktreeSources(options);
49
+ if (currentRepository === null) {
50
+ return {
51
+ skipped: registered.skipped,
52
+ sources: deduplicateWorktreeSources(registered.sources),
53
+ };
54
+ }
44
55
  let currentSources = [];
45
56
  try {
46
57
  currentSources = (await repositoryPort.listWorktrees(cwd)).map((worktree) => ({
@@ -52,9 +63,15 @@ export async function listDiscoverableWorktreeSources(cwd, repositoryPort, onSki
52
63
  catch {
53
64
  // Registered repositories remain discoverable when the current checkout is transiently unavailable.
54
65
  }
55
- return dedupeSources([...currentSources, ...registeredSources]);
66
+ return {
67
+ skipped: registered.skipped,
68
+ sources: deduplicateWorktreeSources([
69
+ ...currentSources,
70
+ ...registered.sources,
71
+ ]),
72
+ };
56
73
  }
57
- function dedupeSources(sources) {
74
+ export function deduplicateWorktreeSources(sources) {
58
75
  const seen = new Set();
59
76
  return sources.filter((source) => {
60
77
  if (seen.has(source.worktree.path))
@@ -63,3 +80,7 @@ function dedupeSources(sources) {
63
80
  return true;
64
81
  });
65
82
  }
83
+ function throwIfAborted(signal) {
84
+ if (signal?.aborted)
85
+ throw new Error("Operation cancelled");
86
+ }
@@ -1,5 +1,6 @@
1
+ import { type WorktreeCatalogDependencies } from "../../application/worktree/catalog.js";
1
2
  import type { WorktreeEntry } from "../../domain/worktree/types.js";
2
- import { type WorktreePromptEntry } from "../../presentation/worktree/picker.js";
3
+ import { type WorktreePromptEntry, type WorktreePromptScope } from "../../presentation/worktree/picker.js";
3
4
  import { type CliRuntime } from "../dependencies.js";
4
5
  export interface CleanCommandOptions {
5
6
  cwd: string;
@@ -15,7 +16,7 @@ export interface CleanCommandDependencies {
15
16
  confirmForceDeleteBranch: (branch: string) => Promise<boolean>;
16
17
  confirmForceRemoveWorktree: (worktreePath: string) => Promise<boolean>;
17
18
  confirmRemoval: (worktrees: WorktreeEntry[]) => Promise<boolean>;
18
- promptForWorktrees: (worktrees: WorktreePromptEntry[]) => Promise<string[] | null>;
19
+ promptForWorktrees: (worktrees: WorktreePromptEntry[], scope?: WorktreePromptScope, catalog?: WorktreeCatalogDependencies) => Promise<string[] | null>;
19
20
  }
20
21
  export declare function createCleanCommand(dependencies?: Partial<CleanCommandDependencies>): (options: CleanCommandOptions) => Promise<number>;
21
22
  export declare const runCleanCommand: (options: CleanCommandOptions) => Promise<number>;
@@ -1,10 +1,21 @@
1
1
  import { confirm, isCancel } from "@clack/prompts";
2
- import { loadLinkedWorktrees } from "../../application/worktree/catalog.js";
2
+ import { loadLinkedWorktrees, } from "../../application/worktree/catalog.js";
3
+ import { deduplicateWorktreeSources, listRegisteredWorktreeSources, } from "../../application/worktree/sources.js";
4
+ import { mapWithConcurrency } from "../../domain/shared/concurrency.js";
3
5
  import { buildWorktreePromptEntries, promptForMultipleWorktrees, } from "../../presentation/worktree/picker.js";
4
6
  import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "../../presentation/worktree/prompts.js";
5
7
  import { defaultCliDependencies, } from "../dependencies.js";
6
8
  import { isHeadless } from "../runtime/headless.js";
7
- import { finalizeUndoOperation, recordUndoOperation } from "./undo.js";
9
+ import { finalizeUndoOperation, recordUndoOperation, } from "./undo.js";
10
+ function toPromptSource(candidate) {
11
+ return {
12
+ repoName: candidate.repoName,
13
+ repoRoot: candidate.repoRoot,
14
+ worktree: candidate.worktree,
15
+ };
16
+ }
17
+ const MAX_CLEAN_REPOSITORY_CONCURRENCY = 4;
18
+ const MAX_CLEAN_WORKTREE_CONCURRENCY = 8;
8
19
  export function createCleanCommand(dependencies = {}) {
9
20
  const promptForWorktrees = dependencies.promptForWorktrees ?? defaultPromptForWorktrees;
10
21
  const confirmRemoval = dependencies.confirmRemoval ?? defaultConfirmRemoval;
@@ -13,7 +24,7 @@ export function createCleanCommand(dependencies = {}) {
13
24
  const confirmForceDeleteBranch = dependencies.confirmForceDeleteBranch ?? defaultConfirmForceDeleteBranch;
14
25
  return async function runCleanCommand(options) {
15
26
  const runtime = options.runtime ?? defaultCliDependencies;
16
- const { readWorktreeHealth, isBranchMergedInto, resolveRemoteDefaultBranch, runGit, } = runtime.git;
27
+ const { readWorktreeHealth, isBranchMergedInto, resolveRemoteBase, runGit, } = runtime.git;
17
28
  const { loadEffectiveConfig } = runtime.config;
18
29
  const { releaseWorktreeSlot } = runtime.slots;
19
30
  const sourceDependencies = {
@@ -24,14 +35,97 @@ export function createCleanCommand(dependencies = {}) {
24
35
  const { formatLastCommit, formatUpstreamState, readWorktreeInfos, serializeWorktreeInfo, } = runtime.worktreeInfo;
25
36
  const { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeWorktree, } = runtime.worktreeLifecycle;
26
37
  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) {
38
+ const currentSources = linkedWorktrees
39
+ .filter((worktree) => worktree.path !== repository.currentRoot)
40
+ .map((worktree) => ({
41
+ repoName: repository.repoName,
42
+ repoRoot: repository.repoRoot,
43
+ worktree,
44
+ }));
45
+ const cleanupDependencies = {
46
+ isBranchMergedInto,
47
+ loadEffectiveConfig,
48
+ readWorktreeHealth,
49
+ resolveRemoteBase,
50
+ runGit,
51
+ };
52
+ const currentCandidates = await resolveCleanupCandidates({
53
+ sources: currentSources,
54
+ stale: options.stale,
55
+ stderr: options.stderr,
56
+ }, cleanupDependencies);
57
+ let activeCandidates = currentCandidates;
58
+ let allCandidates = null;
59
+ let currentScope = true;
60
+ const interactiveSelection = !options.force && !options.json && !isHeadless();
61
+ const skippedRepositories = [];
62
+ const loadAllCandidates = async (signal) => {
63
+ throwIfAborted(signal);
64
+ if (allCandidates !== null)
65
+ return allCandidates;
66
+ const registered = await listRegisteredWorktreeSources({
67
+ cwd: options.cwd,
68
+ repositoryPort: sourceDependencies,
69
+ signal,
70
+ });
71
+ skippedRepositories.push(...registered.skipped);
72
+ if (registered.skipped.length > 0) {
73
+ reportSkippedRepositories(skippedRepositories, options.stderr);
74
+ }
75
+ const sources = deduplicateWorktreeSources([
76
+ ...currentSources,
77
+ ...registered.sources,
78
+ ]).filter((source) => source.repoRoot !== undefined &&
79
+ source.worktree.path !== source.repoRoot &&
80
+ source.worktree.path !== repository.currentRoot);
81
+ const otherRepositorySources = sources.filter((source) => source.repoRoot !== repository.repoRoot);
82
+ const otherRepositoryCandidates = await resolveCleanupCandidates({
83
+ sources: otherRepositorySources,
84
+ stale: options.stale,
85
+ stderr: options.stderr,
86
+ signal,
87
+ }, cleanupDependencies);
88
+ allCandidates = [...currentCandidates, ...otherRepositoryCandidates];
89
+ throwIfAborted(signal);
90
+ return allCandidates;
91
+ };
92
+ const scope = {
93
+ label: "current repository",
94
+ toggleLabel: "all repositories",
95
+ toggle: async (signal) => {
96
+ const nextCurrentScope = !currentScope;
97
+ const nextCandidates = nextCurrentScope
98
+ ? currentCandidates
99
+ : await loadAllCandidates(signal);
100
+ throwIfAborted(signal);
101
+ currentScope = nextCurrentScope;
102
+ activeCandidates = nextCandidates;
103
+ return {
104
+ sources: nextCandidates.map(toPromptSource),
105
+ metadata: nextCurrentScope ? "full" : "fast",
106
+ label: nextCurrentScope ? "current repository" : "all repositories",
107
+ toggleLabel: nextCurrentScope
108
+ ? "all repositories"
109
+ : "current repository",
110
+ };
111
+ },
112
+ };
113
+ if (currentCandidates.length === 0 && interactiveSelection) {
114
+ const loadedAllCandidates = await loadAllCandidates();
115
+ if (loadedAllCandidates.length === 0) {
116
+ if (options.stale) {
117
+ emitNoStaleCandidates(options);
118
+ return 0;
119
+ }
120
+ emitError(options, "No linked worktrees to clean");
121
+ return 1;
122
+ }
123
+ currentScope = false;
124
+ activeCandidates = loadedAllCandidates;
125
+ scope.label = "all repositories";
126
+ scope.toggleLabel = "current repository";
127
+ }
128
+ if (currentCandidates.length === 0 && !interactiveSelection) {
35
129
  if (options.stale) {
36
130
  emitNoStaleCandidates(options);
37
131
  return 0;
@@ -53,20 +147,21 @@ export function createCleanCommand(dependencies = {}) {
53
147
  const shouldSelectAll = options.force ||
54
148
  (options.dryRun && (options.stale || options.json || isHeadless()));
55
149
  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 }));
150
+ ? activeCandidates.map(({ worktree }) => worktree.path)
151
+ : await promptForWorktrees(await buildWorktreePromptEntries(activeCandidates, {
152
+ metadata: currentScope ? "full" : "fast",
153
+ catalog: runtime.worktreeCatalog,
154
+ }), scope, runtime.worktreeCatalog);
61
155
  if (!selections || selections.length === 0) {
62
156
  options.stderr("Aborted\n");
63
157
  return 1;
64
158
  }
65
- const selectedWorktrees = resolveSelectedWorktrees(cleanupCandidates, selections);
66
- if (selectedWorktrees.length !== selections.length) {
159
+ const selectedCandidates = resolveSelectedCandidates(activeCandidates, selections);
160
+ if (selectedCandidates.length !== selections.length) {
67
161
  options.stderr("Selected worktree no longer exists\n");
68
162
  return 1;
69
163
  }
164
+ const selectedWorktrees = selectedCandidates.map(({ worktree }) => worktree);
70
165
  const selectedWorktreeInfos = await readWorktreeInfos(selectedWorktrees);
71
166
  const selectedInfoByPath = new Map(selectedWorktreeInfos.map((info) => [info.path, info]));
72
167
  if (!options.dryRun &&
@@ -87,28 +182,35 @@ export function createCleanCommand(dependencies = {}) {
87
182
  }
88
183
  return 0;
89
184
  }
90
- const removedWorktrees = [];
185
+ const candidatesByRepository = groupCandidatesByRepository(selectedCandidates);
186
+ const journals = new Map();
91
187
  const failures = [];
92
- let journal;
93
188
  try {
94
- journal = await recordUndoOperation("clean", repository.repoRoot, selectedWorktrees, undefined, runtime);
189
+ for (const [repoRoot, candidates] of candidatesByRepository) {
190
+ const journal = await recordUndoOperation("clean", repoRoot, candidates.map(({ worktree }) => worktree), undefined, runtime);
191
+ if (!journal) {
192
+ await discardUndoRecords(journals.values(), runtime.configStore.GLOBAL_CONFIG_DIRECTORY);
193
+ emitError(options, "could not capture undo state; no worktrees were removed");
194
+ return 1;
195
+ }
196
+ journals.set(repoRoot, journal);
197
+ }
95
198
  }
96
199
  catch (error) {
200
+ await discardUndoRecords(journals.values(), runtime.configStore.GLOBAL_CONFIG_DIRECTORY);
97
201
  emitError(options, `could not write undo journal; no worktrees were removed: ${toMessage(error)}`);
98
202
  return 1;
99
203
  }
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) {
204
+ const removedCandidates = [];
205
+ for (const candidate of selectedCandidates) {
206
+ const { repoRoot, staleBaseRef, worktree } = candidate;
105
207
  if (options.stale &&
106
- !(await isStaleCleanupCandidate(repository.repoRoot, worktree, staleBaseRef, readWorktreeHealth, isBranchMergedInto))) {
208
+ !(await isStaleCleanupCandidate({ repoRoot, worktree, baseBranch: staleBaseRef }, cleanupDependencies))) {
107
209
  options.stderr(`Skipped ${worktree.path}: no longer a safe stale cleanup candidate\n`);
108
210
  continue;
109
211
  }
110
212
  try {
111
- await removeWorktree(repository.repoRoot, worktree.path);
213
+ await removeWorktree(repoRoot, worktree.path);
112
214
  }
113
215
  catch (error) {
114
216
  if (!isWorktreeForceRemovalError(error)) {
@@ -135,7 +237,7 @@ export function createCleanCommand(dependencies = {}) {
135
237
  continue;
136
238
  }
137
239
  try {
138
- await forceRemoveWorktree(repository.repoRoot, worktree.path);
240
+ await forceRemoveWorktree(repoRoot, worktree.path);
139
241
  }
140
242
  catch (forceError) {
141
243
  failures.push({
@@ -146,10 +248,10 @@ export function createCleanCommand(dependencies = {}) {
146
248
  continue;
147
249
  }
148
250
  }
149
- removedWorktrees.push(worktree);
251
+ removedCandidates.push(candidate);
150
252
  if (worktree.branch) {
151
253
  try {
152
- await deleteBranch(repository.repoRoot, worktree.branch);
254
+ await deleteBranch(repoRoot, worktree.branch);
153
255
  }
154
256
  catch (error) {
155
257
  if (!isBranchUnmergedError(error)) {
@@ -163,7 +265,7 @@ export function createCleanCommand(dependencies = {}) {
163
265
  if (options.force ||
164
266
  (await confirmForceDeleteBranch(worktree.branch))) {
165
267
  try {
166
- await forceDeleteBranch(repository.repoRoot, worktree.branch);
268
+ await forceDeleteBranch(repoRoot, worktree.branch);
167
269
  }
168
270
  catch (forceError) {
169
271
  options.stderr(`Failed to delete branch ${worktree.branch}: ${toMessage(forceError)}\n`);
@@ -175,56 +277,149 @@ export function createCleanCommand(dependencies = {}) {
175
277
  }
176
278
  }
177
279
  }
178
- await finalizeUndoOperation(journal, removedWorktrees, undefined, runtime.configStore.GLOBAL_CONFIG_DIRECTORY);
179
- await Promise.all(removedWorktrees.map((worktree) => releaseWorktreeSlot(worktree.path)));
280
+ for (const [repoRoot, journal] of journals) {
281
+ await finalizeUndoOperation(journal, removedCandidates
282
+ .filter((candidate) => candidate.repoRoot === repoRoot)
283
+ .map(({ worktree }) => worktree), undefined, runtime.configStore.GLOBAL_CONFIG_DIRECTORY);
284
+ }
285
+ const undoRecords = remainingUndoRecords(journals.values(), removedCandidates);
286
+ await Promise.all(removedCandidates.map(({ worktree }) => releaseWorktreeSlot(worktree.path)));
180
287
  if (options.json) {
181
- const removed = removedWorktrees.map((worktree) => {
288
+ const removed = removedCandidates.map(({ worktree }) => {
182
289
  const info = selectedInfoByPath.get(worktree.path);
183
290
  return info === undefined
184
291
  ? { branch: worktree.branch, path: worktree.path }
185
292
  : serializeWorktreeInfo(info);
186
293
  });
187
- const payload = failures.length === 0 ? { removed } : { removed, failed: failures };
294
+ const payload = {
295
+ removed,
296
+ ...(failures.length === 0 ? {} : { failed: failures }),
297
+ ...(needsExplicitUndoIds(undoRecords, repository.repoRoot)
298
+ ? {
299
+ undo: undoRecords.map(({ id, repoRoot }) => ({ id, repoRoot })),
300
+ }
301
+ : {}),
302
+ };
188
303
  options.stdout(`${JSON.stringify(payload, null, 2)}\n`);
189
304
  }
190
305
  else if (failures.length > 0) {
191
306
  reportCleanFailures(failures, options.stderr);
307
+ emitUndoHints(undoRecords, repository.repoRoot, options.stderr);
192
308
  }
193
309
  else {
194
- options.stderr("undo: gji undo\n");
195
- options.stdout(`${repository.repoRoot}\n`);
310
+ emitUndoHints(undoRecords, repository.repoRoot, options.stderr);
311
+ options.stdout(`${[...new Set(selectedCandidates.map(({ repoRoot }) => repoRoot))].join("\n")}\n`);
196
312
  }
197
313
  return failures.length === 0 ? 0 : 1;
198
314
  };
199
315
  }
200
316
  export const runCleanCommand = createCleanCommand();
201
- async function filterStaleCleanupCandidates(repoRoot, worktrees, baseBranch, readWorktreeHealth, isBranchMergedInto) {
317
+ async function resolveCleanupCandidates(options, dependencies) {
318
+ const { sources, stale, stderr, signal } = options;
319
+ const grouped = new Map();
320
+ for (const source of sources) {
321
+ if (source.repoRoot === undefined)
322
+ continue;
323
+ const existing = grouped.get(source.repoRoot);
324
+ if (existing === undefined) {
325
+ grouped.set(source.repoRoot, {
326
+ repoName: source.repoName,
327
+ worktrees: [source.worktree],
328
+ });
329
+ }
330
+ else {
331
+ existing.worktrees.push(source.worktree);
332
+ }
333
+ }
334
+ const results = await mapWithConcurrency([...grouped.entries()], MAX_CLEAN_REPOSITORY_CONCURRENCY, async ([repoRoot, group]) => {
335
+ throwIfAborted(signal);
336
+ const staleBaseRef = stale
337
+ ? await resolveStaleBaseRef({ repoRoot, stderr }, dependencies)
338
+ : null;
339
+ const worktrees = stale
340
+ ? await filterStaleCleanupCandidates({
341
+ repoRoot,
342
+ worktrees: group.worktrees,
343
+ baseBranch: staleBaseRef,
344
+ signal,
345
+ }, dependencies)
346
+ : group.worktrees;
347
+ return worktrees.map((worktree) => ({
348
+ repoName: group.repoName,
349
+ repoRoot,
350
+ staleBaseRef,
351
+ worktree,
352
+ }));
353
+ }, signal);
354
+ return results.flat();
355
+ }
356
+ function resolveSelectedCandidates(candidates, selections) {
357
+ const selected = [];
358
+ const seenPaths = new Set();
359
+ for (const selection of selections) {
360
+ const candidate = candidates.find((entry) => entry.worktree.path === selection ||
361
+ entry.worktree.branch === selection);
362
+ if (candidate === undefined || seenPaths.has(candidate.worktree.path)) {
363
+ continue;
364
+ }
365
+ selected.push(candidate);
366
+ seenPaths.add(candidate.worktree.path);
367
+ }
368
+ return selected;
369
+ }
370
+ function groupCandidatesByRepository(candidates) {
371
+ const grouped = new Map();
372
+ for (const candidate of candidates) {
373
+ const existing = grouped.get(candidate.repoRoot);
374
+ if (existing === undefined)
375
+ grouped.set(candidate.repoRoot, [candidate]);
376
+ else
377
+ existing.push(candidate);
378
+ }
379
+ return grouped;
380
+ }
381
+ function remainingUndoRecords(records, removedCandidates) {
382
+ return [...records].filter((record) => removedCandidates.some((candidate) => candidate.repoRoot === record.repoRoot &&
383
+ record.entries.some((entry) => entry.path === candidate.worktree.path)));
384
+ }
385
+ async function discardUndoRecords(records, globalConfigDirectory) {
386
+ await Promise.all([...records].map((record) => finalizeUndoOperation(record, [], undefined, globalConfigDirectory)));
387
+ }
388
+ async function filterStaleCleanupCandidates(options, dependencies) {
389
+ const { repoRoot, worktrees, baseBranch, signal } = options;
390
+ throwIfAborted(signal);
202
391
  if (baseBranch === null) {
203
392
  return [];
204
393
  }
205
- const results = await Promise.all(worktrees.map((worktree) => isStaleCleanupCandidate(repoRoot, worktree, baseBranch, readWorktreeHealth, isBranchMergedInto)));
394
+ const results = await mapWithConcurrency(worktrees, MAX_CLEAN_WORKTREE_CONCURRENCY, (worktree) => isStaleCleanupCandidate({ repoRoot, worktree, baseBranch }, dependencies), signal);
395
+ throwIfAborted(signal);
206
396
  return worktrees.filter((_, index) => results[index]);
207
397
  }
208
- async function resolveStaleBaseRef(repoRoot, stderr, loadEffectiveConfig, resolveRemoteDefaultBranch, runGit) {
209
- const config = await loadEffectiveConfig(repoRoot, undefined, stderr);
398
+ async function resolveStaleBaseRef(options, dependencies) {
399
+ const { repoRoot, stderr } = options;
400
+ const config = await dependencies.loadEffectiveConfig(repoRoot, undefined, stderr);
210
401
  const remote = resolveConfiguredString(config.syncRemote) ?? "origin";
211
402
  const configuredDefaultBranch = resolveConfiguredString(config.syncDefaultBranch);
212
- if (configuredDefaultBranch) {
213
- return await resolveFetchedRemoteRef(repoRoot, remote, configuredDefaultBranch, runGit);
214
- }
215
403
  try {
216
- const remoteDefaultBranch = await resolveRemoteDefaultBranch(repoRoot, remote);
217
- return remoteDefaultBranch === null
404
+ const remoteBase = await dependencies.resolveRemoteBase(repoRoot, remote, configuredDefaultBranch ?? undefined);
405
+ return remoteBase === null
218
406
  ? null
219
- : await resolveFetchedRemoteRef(repoRoot, remote, remoteDefaultBranch, runGit);
407
+ : resolveFetchedRemoteRef({ branch: remoteBase.branch, remote, repoRoot }, dependencies.runGit);
220
408
  }
221
409
  catch {
222
410
  return null;
223
411
  }
224
412
  }
225
- async function resolveFetchedRemoteRef(repoRoot, remote, branch, runGit) {
413
+ async function resolveFetchedRemoteRef(options, runGit) {
414
+ const { repoRoot, remote, branch } = options;
226
415
  try {
227
416
  await runGit(repoRoot, ["fetch", "--prune", remote]);
417
+ await runGit(repoRoot, [
418
+ "rev-parse",
419
+ "--verify",
420
+ "--quiet",
421
+ `${remote}/${branch}`,
422
+ ]);
228
423
  return `${remote}/${branch}`;
229
424
  }
230
425
  catch {
@@ -234,31 +429,37 @@ async function resolveFetchedRemoteRef(repoRoot, remote, branch, runGit) {
234
429
  function resolveConfiguredString(value) {
235
430
  return typeof value === "string" && value.length > 0 ? value : null;
236
431
  }
237
- async function isStaleCleanupCandidate(repoRoot, worktree, baseBranch, readWorktreeHealth, isBranchMergedInto) {
432
+ async function isStaleCleanupCandidate(options, dependencies) {
433
+ const { repoRoot, worktree, baseBranch } = options;
238
434
  if (baseBranch === null) {
239
435
  return false;
240
436
  }
241
437
  if (worktree.branch === null) {
242
438
  return false;
243
439
  }
244
- const health = await readWorktreeHealth(worktree.path);
440
+ const health = await dependencies.readWorktreeHealth(worktree.path);
245
441
  if (health.status !== "clean" || !health.upstreamGone) {
246
442
  return false;
247
443
  }
248
- return isBranchMergedInto(repoRoot, worktree.branch, baseBranch);
444
+ if (await dependencies.isBranchMergedInto(repoRoot, worktree.branch, baseBranch)) {
445
+ return true;
446
+ }
447
+ return isBranchPatchEquivalentInto({ baseBranch, repoRoot, worktreeBranch: worktree.branch }, dependencies.runGit);
249
448
  }
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);
449
+ async function isBranchPatchEquivalentInto(options, runGit) {
450
+ const { repoRoot, worktreeBranch, baseBranch } = options;
451
+ try {
452
+ const output = await runGit(repoRoot, [
453
+ "cherry",
454
+ baseBranch,
455
+ worktreeBranch,
456
+ ]);
457
+ const lines = output.split("\n").filter(Boolean);
458
+ return lines.length > 0 && lines.every((line) => line.startsWith("-"));
459
+ }
460
+ catch {
461
+ return false;
260
462
  }
261
- return selectedWorktrees;
262
463
  }
263
464
  function reportCleanFailures(failures, stderr) {
264
465
  const noun = failures.length === 1 ? "worktree" : "worktrees";
@@ -268,6 +469,36 @@ function reportCleanFailures(failures, stderr) {
268
469
  stderr(`- ${failure.path} (${branch}): ${failure.message}\n`);
269
470
  }
270
471
  }
472
+ function emitUndoHints(records, currentRepositoryRoot, stderr) {
473
+ if (records.length === 0)
474
+ return;
475
+ if (!needsExplicitUndoIds(records, currentRepositoryRoot)) {
476
+ stderr("undo: gji undo\n");
477
+ return;
478
+ }
479
+ const message = records.length === 1
480
+ ? "undo: restore with:"
481
+ : "undo: restore each repository with:";
482
+ stderr(`${message}\n`);
483
+ for (const record of records) {
484
+ stderr(` gji undo --id ${record.id} (${record.repoRoot})\n`);
485
+ }
486
+ }
487
+ function needsExplicitUndoIds(records, currentRepositoryRoot) {
488
+ return (records.length > 1 ||
489
+ records.some((record) => record.repoRoot !== currentRepositoryRoot));
490
+ }
491
+ function reportSkippedRepositories(repositories, stderr) {
492
+ const noun = repositories.length === 1 ? "repository" : "repositories";
493
+ stderr(`Skipped ${repositories.length} registered ${noun} because worktrees could not be listed:\n`);
494
+ for (const repository of repositories) {
495
+ stderr(`- ${repository.name} (${repository.path})\n`);
496
+ }
497
+ }
498
+ function throwIfAborted(signal) {
499
+ if (signal?.aborted)
500
+ throw new Error("Operation cancelled");
501
+ }
271
502
  function formatCleanInfo(info, formatLastCommit, formatUpstreamState) {
272
503
  const branch = info.branch === null ? "detached" : `branch: ${info.branch}`;
273
504
  const status = `status: ${info.status}`;
@@ -296,8 +527,11 @@ function emitNoStaleCandidates(options) {
296
527
  function toMessage(error) {
297
528
  return error instanceof Error ? error.message : String(error);
298
529
  }
299
- async function defaultPromptForWorktrees(worktrees) {
300
- return promptForMultipleWorktrees("Choose worktrees to clean", worktrees);
530
+ async function defaultPromptForWorktrees(worktrees, scope, catalog) {
531
+ return promptForMultipleWorktrees("Choose worktrees to clean", worktrees, {
532
+ catalog,
533
+ scope,
534
+ });
301
535
  }
302
536
  async function defaultConfirmRemoval(worktrees) {
303
537
  const branchCount = worktrees.filter((worktree) => worktree.branch !== null).length;