@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.
- 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 +3 -2
- package/dist/cli/commands/clean.js +299 -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/domain/shared/concurrency.d.ts +1 -1
- package/dist/domain/shared/concurrency.js +6 -1
- package/dist/gji-bundle.mjs +492 -225
- package/dist/infrastructure/git/refs.js +3 -11
- 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 +1 -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
|
@@ -46,10 +46,12 @@ export async function resolveGoBranch(options) {
|
|
|
46
46
|
return finish({ kind: "existing", source: pullRequestMatches[0] }, false);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
42
|
-
if (currentRepository === null)
|
|
43
|
-
return
|
|
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
|
|
66
|
+
return {
|
|
67
|
+
skipped: registered.skipped,
|
|
68
|
+
sources: deduplicateWorktreeSources([
|
|
69
|
+
...currentSources,
|
|
70
|
+
...registered.sources,
|
|
71
|
+
]),
|
|
72
|
+
};
|
|
56
73
|
}
|
|
57
|
-
function
|
|
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,
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
?
|
|
57
|
-
: await promptForWorktrees(await buildWorktreePromptEntries(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
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
|
|
66
|
-
if (
|
|
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
|
|
185
|
+
const candidatesByRepository = groupCandidatesByRepository(selectedCandidates);
|
|
186
|
+
const journals = new Map();
|
|
91
187
|
const failures = [];
|
|
92
|
-
let journal;
|
|
93
188
|
try {
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
251
|
+
removedCandidates.push(candidate);
|
|
150
252
|
if (worktree.branch) {
|
|
151
253
|
try {
|
|
152
|
-
await deleteBranch(
|
|
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(
|
|
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
|
-
|
|
179
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
195
|
-
options.stdout(`${
|
|
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
|
|
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
|
|
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(
|
|
209
|
-
const
|
|
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
|
|
217
|
-
return
|
|
404
|
+
const remoteBase = await dependencies.resolveRemoteBase(repoRoot, remote, configuredDefaultBranch ?? undefined);
|
|
405
|
+
return remoteBase === null
|
|
218
406
|
? null
|
|
219
|
-
:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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;
|