@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.
- package/dist/application/worktree/go-resolution.js +5 -3
- package/dist/application/worktree/sources.d.ts +13 -4
- package/dist/application/worktree/sources.js +34 -13
- package/dist/cli/commands/clean.d.ts +4 -2
- package/dist/cli/commands/clean.js +338 -65
- package/dist/cli/commands/go.d.ts +2 -1
- package/dist/cli/commands/go.js +17 -13
- package/dist/cli/commands/hub.js +4 -1
- package/dist/cli/commands/warp.js +6 -4
- package/dist/cli/program.js +5 -0
- package/dist/domain/shared/concurrency.d.ts +1 -1
- package/dist/domain/shared/concurrency.js +6 -1
- package/dist/domain/worktree/path-filter.d.ts +13 -0
- package/dist/domain/worktree/path-filter.js +72 -0
- package/dist/domain/worktree/policy.d.ts +1 -0
- package/dist/domain/worktree/policy.js +1 -1
- package/dist/gji-bundle.mjs +746 -368
- package/dist/infrastructure/git/refs.js +3 -11
- package/dist/presentation/shell/completion.js +7 -2
- package/dist/presentation/worktree/picker.d.ts +9 -6
- package/dist/presentation/worktree/picker.js +19 -2
- package/man/man1/gji-back.1 +1 -1
- package/man/man1/gji-clean.1 +4 -1
- package/man/man1/gji-completion.1 +1 -1
- package/man/man1/gji-config.1 +1 -1
- package/man/man1/gji-doctor.1 +1 -1
- package/man/man1/gji-done.1 +1 -1
- package/man/man1/gji-go.1 +1 -1
- package/man/man1/gji-history.1 +1 -1
- package/man/man1/gji-init.1 +1 -1
- package/man/man1/gji-ls.1 +1 -1
- package/man/man1/gji-new.1 +1 -1
- package/man/man1/gji-open.1 +1 -1
- package/man/man1/gji-pr.1 +1 -1
- package/man/man1/gji-remove.1 +1 -1
- package/man/man1/gji-root.1 +1 -1
- package/man/man1/gji-run-hook.1 +1 -1
- package/man/man1/gji-status.1 +1 -1
- package/man/man1/gji-sync-files.1 +1 -1
- package/man/man1/gji-sync.1 +1 -1
- package/man/man1/gji-task.1 +1 -1
- package/man/man1/gji-undo.1 +1 -1
- package/man/man1/gji-warp.1 +1 -1
- package/man/man1/gji.1 +1 -1
- 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,
|
|
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
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
?
|
|
57
|
-
: await promptForWorktrees(await buildWorktreePromptEntries(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
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
|
|
66
|
-
if (
|
|
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
|
|
201
|
+
const candidatesByRepository = groupCandidatesByRepository(selectedCandidates);
|
|
202
|
+
const journals = new Map();
|
|
91
203
|
const failures = [];
|
|
92
|
-
let journal;
|
|
93
204
|
try {
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
267
|
+
removedCandidates.push(candidate);
|
|
150
268
|
if (worktree.branch) {
|
|
151
269
|
try {
|
|
152
|
-
await deleteBranch(
|
|
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(
|
|
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
|
-
|
|
179
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
195
|
-
options.stdout(`${
|
|
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
|
|
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
|
|
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(
|
|
209
|
-
const
|
|
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
|
|
217
|
-
return
|
|
443
|
+
const remoteBase = await dependencies.resolveRemoteBase(repoRoot, remote, configuredDefaultBranch ?? undefined);
|
|
444
|
+
return remoteBase === null
|
|
218
445
|
? null
|
|
219
|
-
:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
}
|
package/dist/cli/commands/go.js
CHANGED
|
@@ -49,13 +49,16 @@ export function createGoCommand(dependencies = {}) {
|
|
|
49
49
|
let promptSources = currentSources;
|
|
50
50
|
const loadAllSources = async () => {
|
|
51
51
|
if (registeredSources === null) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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, {
|
|
308
|
+
async function promptForWorktree(worktrees, scope, catalog) {
|
|
309
|
+
return promptForSingleWorktree("Choose a worktree", worktrees, {
|
|
310
|
+
catalog,
|
|
311
|
+
scope,
|
|
312
|
+
});
|
|
309
313
|
}
|
package/dist/cli/commands/hub.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
})
|
|
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"
|