@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.
@@ -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 (currentCandidates.length === 0 && interactiveSelection) {
138
+ if (searchAllRepositories ||
139
+ (interactiveSelection && currentCandidates.length === 0)) {
114
140
  const loadedAllCandidates = await loadAllCandidates();
115
141
  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;
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 && !interactiveSelection) {
129
- if (options.stale) {
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();
@@ -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
+ }
@@ -1,2 +1,3 @@
1
1
  export declare function resolveWorktreePath(repoRoot: string, branch: string, basePath?: string): string;
2
2
  export declare function validateBranchName(name: string): string | null;
3
+ export declare function expandTildeInPath(path: string): string;
@@ -49,7 +49,7 @@ export function validateBranchName(name) {
49
49
  }
50
50
  return null;
51
51
  }
52
- function expandTildeInPath(path) {
52
+ export function expandTildeInPath(path) {
53
53
  if (path === "~")
54
54
  return homedir();
55
55
  if (path.startsWith("~/"))