@solaqua/gji 0.14.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/cli/commands/clean.d.ts +1 -0
- package/dist/cli/commands/clean.js +55 -16
- package/dist/cli/program.js +5 -0
- 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 +272 -161
- package/dist/presentation/shell/completion.js +7 -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
|
@@ -7,6 +7,7 @@ export interface CleanCommandOptions {
|
|
|
7
7
|
dryRun?: boolean;
|
|
8
8
|
force?: boolean;
|
|
9
9
|
json?: boolean;
|
|
10
|
+
paths?: string[];
|
|
10
11
|
stale?: boolean;
|
|
11
12
|
runtime?: CliRuntime<"git" | "config" | "configStore" | "slots" | "repositoryContext" | "repositoryRegistry" | "worktrees" | "worktreeInfo" | "worktreeLifecycle" | "worktreeCatalog">;
|
|
12
13
|
stderr: (chunk: string) => void;
|
|
@@ -1,7 +1,11 @@
|
|
|
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
4
|
import { loadLinkedWorktrees, } from "../../application/worktree/catalog.js";
|
|
3
5
|
import { deduplicateWorktreeSources, listRegisteredWorktreeSources, } from "../../application/worktree/sources.js";
|
|
4
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";
|
|
5
9
|
import { buildWorktreePromptEntries, promptForMultipleWorktrees, } from "../../presentation/worktree/picker.js";
|
|
6
10
|
import { defaultConfirmForceDeleteBranch, defaultConfirmForceRemoveWorktree, } from "../../presentation/worktree/prompts.js";
|
|
7
11
|
import { defaultCliDependencies, } from "../dependencies.js";
|
|
@@ -35,8 +39,27 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
35
39
|
const { formatLastCommit, formatUpstreamState, readWorktreeInfos, serializeWorktreeInfo, } = runtime.worktreeInfo;
|
|
36
40
|
const { deleteBranch, forceDeleteBranch, forceRemoveWorktree, isBranchUnmergedError, isSubmoduleWorktreeRemovalError, isWorktreeDeletionError, isWorktreeForceRemovalError, removeWorktree, } = runtime.worktreeLifecycle;
|
|
37
41
|
const { linkedWorktrees, repository } = await loadLinkedWorktrees(options.cwd, sourceDependencies);
|
|
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
|
+
}
|
|
53
|
+
if (options.stale) {
|
|
54
|
+
emitNoStaleCandidates(options);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
emitError(options, "No linked worktrees to clean");
|
|
58
|
+
return 1;
|
|
59
|
+
};
|
|
38
60
|
const currentSources = linkedWorktrees
|
|
39
|
-
.filter((worktree) => worktree.path !== repository.currentRoot
|
|
61
|
+
.filter((worktree) => worktree.path !== repository.currentRoot &&
|
|
62
|
+
matchesPathFilter(worktree.path))
|
|
40
63
|
.map((worktree) => ({
|
|
41
64
|
repoName: repository.repoName,
|
|
42
65
|
repoRoot: repository.repoRoot,
|
|
@@ -77,7 +100,9 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
77
100
|
...registered.sources,
|
|
78
101
|
]).filter((source) => source.repoRoot !== undefined &&
|
|
79
102
|
source.worktree.path !== source.repoRoot &&
|
|
80
|
-
source.worktree.path !== repository.currentRoot
|
|
103
|
+
source.worktree.path !== repository.currentRoot &&
|
|
104
|
+
matchesPathFilter(source.worktree.path));
|
|
105
|
+
allSourceCount = sources.length;
|
|
81
106
|
const otherRepositorySources = sources.filter((source) => source.repoRoot !== repository.repoRoot);
|
|
82
107
|
const otherRepositoryCandidates = await resolveCleanupCandidates({
|
|
83
108
|
sources: otherRepositorySources,
|
|
@@ -110,28 +135,19 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
110
135
|
};
|
|
111
136
|
},
|
|
112
137
|
};
|
|
113
|
-
if (
|
|
138
|
+
if (searchAllRepositories ||
|
|
139
|
+
(interactiveSelection && currentCandidates.length === 0)) {
|
|
114
140
|
const loadedAllCandidates = await loadAllCandidates();
|
|
115
141
|
if (loadedAllCandidates.length === 0) {
|
|
116
|
-
|
|
117
|
-
emitNoStaleCandidates(options);
|
|
118
|
-
return 0;
|
|
119
|
-
}
|
|
120
|
-
emitError(options, "No linked worktrees to clean");
|
|
121
|
-
return 1;
|
|
142
|
+
return emitNoCandidates(allSourceCount);
|
|
122
143
|
}
|
|
123
144
|
currentScope = false;
|
|
124
145
|
activeCandidates = loadedAllCandidates;
|
|
125
146
|
scope.label = "all repositories";
|
|
126
147
|
scope.toggleLabel = "current repository";
|
|
127
148
|
}
|
|
128
|
-
if (currentCandidates.length === 0
|
|
129
|
-
|
|
130
|
-
emitNoStaleCandidates(options);
|
|
131
|
-
return 0;
|
|
132
|
-
}
|
|
133
|
-
emitError(options, "No linked worktrees to clean");
|
|
134
|
-
return 1;
|
|
149
|
+
else if (currentCandidates.length === 0) {
|
|
150
|
+
return emitNoCandidates(currentSources.length);
|
|
135
151
|
}
|
|
136
152
|
if (!options.dryRun && !options.force && (options.json || isHeadless())) {
|
|
137
153
|
const message = "--force is required";
|
|
@@ -314,6 +330,29 @@ export function createCleanCommand(dependencies = {}) {
|
|
|
314
330
|
};
|
|
315
331
|
}
|
|
316
332
|
export const runCleanCommand = createCleanCommand();
|
|
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
|
+
}
|
|
317
356
|
async function resolveCleanupCandidates(options, dependencies) {
|
|
318
357
|
const { sources, stale, stderr, signal } = options;
|
|
319
358
|
const grouped = new Map();
|
package/dist/cli/program.js
CHANGED
|
@@ -295,6 +295,7 @@ function registerCommands(program) {
|
|
|
295
295
|
.description("interactively prune linked worktrees")
|
|
296
296
|
.option("-f, --force", "bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches")
|
|
297
297
|
.option("--stale", "only target clean worktrees whose upstream is gone and branch is merged into the default branch")
|
|
298
|
+
.option("--path <pattern>", "only target worktrees under a directory or matching a path glob (repeatable)", collectOption)
|
|
298
299
|
.option("--dry-run", "show what would be deleted without removing anything")
|
|
299
300
|
.option("--json", "emit JSON on success or error instead of human-readable output")
|
|
300
301
|
.action(notImplemented("clean"));
|
|
@@ -638,6 +639,7 @@ function attachCommandActions(program, options) {
|
|
|
638
639
|
dryRun: commandOptions.dryRun,
|
|
639
640
|
force: commandOptions.force,
|
|
640
641
|
json: commandOptions.json,
|
|
642
|
+
paths: commandOptions.path,
|
|
641
643
|
stale: commandOptions.stale,
|
|
642
644
|
runtime: options.dependencies,
|
|
643
645
|
stderr: options.stderr,
|
|
@@ -752,6 +754,9 @@ function requireCommand(parent, name) {
|
|
|
752
754
|
}
|
|
753
755
|
return command;
|
|
754
756
|
}
|
|
757
|
+
function collectOption(value, previous = []) {
|
|
758
|
+
return [...previous, value];
|
|
759
|
+
}
|
|
755
760
|
function notImplemented(commandName) {
|
|
756
761
|
return () => {
|
|
757
762
|
throw new Error(`'${commandName}' is not implemented yet.`);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a predicate that matches worktree paths against any of the absolute
|
|
3
|
+
* patterns. A pattern without glob characters matches that directory and
|
|
4
|
+
* anything inside it. A pattern with `*` (within one segment), `**` (across
|
|
5
|
+
* segments), or `?` (one character) must match the whole path. A backslash
|
|
6
|
+
* makes the next character literal, so `\*` and `\?` match themselves. No
|
|
7
|
+
* patterns match every path.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createWorktreePathFilter(patterns: readonly string[]): (worktreePath: string) => boolean;
|
|
10
|
+
/** Returns the index of the first unescaped glob character, or -1. */
|
|
11
|
+
export declare function findGlobIndex(pattern: string): number;
|
|
12
|
+
export declare function escapePathPattern(path: string): string;
|
|
13
|
+
export declare function unescapePathPattern(pattern: string): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a predicate that matches worktree paths against any of the absolute
|
|
3
|
+
* patterns. A pattern without glob characters matches that directory and
|
|
4
|
+
* anything inside it. A pattern with `*` (within one segment), `**` (across
|
|
5
|
+
* segments), or `?` (one character) must match the whole path. A backslash
|
|
6
|
+
* makes the next character literal, so `\*` and `\?` match themselves. No
|
|
7
|
+
* patterns match every path.
|
|
8
|
+
*/
|
|
9
|
+
export function createWorktreePathFilter(patterns) {
|
|
10
|
+
const matchers = patterns.map(createPathMatcher);
|
|
11
|
+
if (matchers.length === 0)
|
|
12
|
+
return () => true;
|
|
13
|
+
return (worktreePath) => matchers.some((matches) => matches(worktreePath));
|
|
14
|
+
}
|
|
15
|
+
/** Returns the index of the first unescaped glob character, or -1. */
|
|
16
|
+
export function findGlobIndex(pattern) {
|
|
17
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
18
|
+
const character = pattern[index];
|
|
19
|
+
if (character === "\\")
|
|
20
|
+
index += 1;
|
|
21
|
+
else if (character === "*" || character === "?")
|
|
22
|
+
return index;
|
|
23
|
+
}
|
|
24
|
+
return -1;
|
|
25
|
+
}
|
|
26
|
+
export function escapePathPattern(path) {
|
|
27
|
+
return path.replace(/[\\*?]/g, "\\$&");
|
|
28
|
+
}
|
|
29
|
+
export function unescapePathPattern(pattern) {
|
|
30
|
+
return pattern.replace(/\\(.)/g, "$1");
|
|
31
|
+
}
|
|
32
|
+
function createPathMatcher(pattern) {
|
|
33
|
+
if (findGlobIndex(pattern) !== -1) {
|
|
34
|
+
const regExp = globToRegExp(pattern);
|
|
35
|
+
return (path) => regExp.test(path);
|
|
36
|
+
}
|
|
37
|
+
const literal = unescapePathPattern(pattern);
|
|
38
|
+
const prefix = literal === "/" ? "/" : `${literal}/`;
|
|
39
|
+
return (path) => path === literal || path.startsWith(prefix);
|
|
40
|
+
}
|
|
41
|
+
function globToRegExp(pattern) {
|
|
42
|
+
let source = "";
|
|
43
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
44
|
+
let character = pattern[index];
|
|
45
|
+
if (character === "*") {
|
|
46
|
+
if (pattern[index + 1] === "*") {
|
|
47
|
+
index += 1;
|
|
48
|
+
if (pattern[index + 1] === "/") {
|
|
49
|
+
index += 1;
|
|
50
|
+
source += "(?:.*/)?";
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
source += ".*";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
source += "[^/]*";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (character === "?") {
|
|
61
|
+
source += "[^/]";
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
if (character === "\\" && index + 1 < pattern.length) {
|
|
65
|
+
index += 1;
|
|
66
|
+
character = pattern[index];
|
|
67
|
+
}
|
|
68
|
+
source += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return new RegExp(`^${source}$`);
|
|
72
|
+
}
|