Skip to content

Commit 6ab8de8

Browse files
committed
Cache glob results across --cache runs
1 parent 573df54 commit 6ab8de8

4 files changed

Lines changed: 155 additions & 1 deletion

File tree

packages/knip/src/run.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ProjectPrincipal } from './ProjectPrincipal.ts';
1010
import watchReporter from './reporters/watch.ts';
1111
import type { MainOptions } from './util/create-options.ts';
1212
import { debugLogObject } from './util/debug.ts';
13+
import { flushGlobCache, initGlobCache } from './util/glob-cache.ts';
1314
import { getGitIgnoredHandler } from './util/glob-core.ts';
1415
import { getModuleSourcePathHandler } from './util/to-source-path.ts';
1516
import { getSessionHandler, type OnFileChange, type SessionHandler } from './util/watch.ts';
@@ -20,6 +21,8 @@ export const run = async (options: MainOptions) => {
2021
debugLogObject('*', 'Unresolved configuration', options);
2122
debugLogObject('*', 'Included issue types', options.includedIssueTypes);
2223

24+
if (options.isCache) initGlobCache(options.cacheLocation);
25+
2326
const chief = new ConfigurationChief(options);
2427
const deputy = new DependencyDeputy(options);
2528
const streamer = new ConsoleStreamer(options);
@@ -100,6 +103,8 @@ export const run = async (options: MainOptions) => {
100103

101104
if (!options.isWatch) streamer.clear();
102105

106+
if (options.isCache) flushGlobCache();
107+
103108
return {
104109
results: {
105110
issues,
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { createHash } from 'node:crypto';
2+
import fs from 'node:fs';
3+
// oxlint-disable-next-line no-restricted-imports
4+
import path from 'node:path';
5+
import { deserialize, serialize } from 'node:v8';
6+
import { version } from '../version.ts';
7+
import { debugLog } from './debug.ts';
8+
import { isDirectory, isFile } from './fs.ts';
9+
import { dirname } from './path.ts';
10+
11+
interface GlobCacheEntry {
12+
paths: string[];
13+
/** Absolute dir path → mtimeMs of dir at the time the glob ran */
14+
dirMtimes: Record<string, number>;
15+
}
16+
17+
const CACHE_FILENAME = `glob-${version}.cache`;
18+
19+
let cacheFilePath: string | undefined;
20+
let cache: Map<string, GlobCacheEntry> | undefined;
21+
let isDirty = false;
22+
23+
export const initGlobCache = (cacheLocation: string) => {
24+
cacheFilePath = path.resolve(cacheLocation, CACHE_FILENAME);
25+
if (isFile(cacheFilePath)) {
26+
try {
27+
cache = deserialize(fs.readFileSync(cacheFilePath));
28+
} catch {
29+
debugLog('*', `Error reading glob cache from ${cacheFilePath}`);
30+
cache = new Map();
31+
}
32+
} else {
33+
cache = new Map();
34+
}
35+
};
36+
37+
export const isGlobCacheEnabled = () => cache !== undefined;
38+
39+
export const computeGlobCacheKey = (input: {
40+
patterns: string[];
41+
cwd: string;
42+
dir: string;
43+
gitignore: boolean;
44+
}): string => {
45+
const h = createHash('sha1');
46+
h.update(input.cwd);
47+
h.update('\0');
48+
h.update(input.dir);
49+
h.update('\0');
50+
h.update(input.gitignore ? '1' : '0');
51+
h.update('\0');
52+
for (const p of input.patterns) {
53+
h.update(p);
54+
h.update('\0');
55+
}
56+
return h.digest('base64url');
57+
};
58+
59+
const validateEntry = (entry: GlobCacheEntry): boolean => {
60+
for (const dir in entry.dirMtimes) {
61+
try {
62+
const stat = fs.statSync(dir);
63+
if (stat.mtimeMs !== entry.dirMtimes[dir]) return false;
64+
} catch {
65+
return false;
66+
}
67+
}
68+
return true;
69+
};
70+
71+
export const getCachedGlob = (key: string): string[] | undefined => {
72+
if (!cache) return undefined;
73+
const entry = cache.get(key);
74+
if (!entry) return undefined;
75+
if (!validateEntry(entry)) {
76+
cache.delete(key);
77+
isDirty = true;
78+
return undefined;
79+
}
80+
return entry.paths;
81+
};
82+
83+
const captureDirMtimes = (paths: string[], baseDir: string): Record<string, number> => {
84+
const dirs = new Set<string>();
85+
// Always track the base dir to catch new top-level files/dirs
86+
dirs.add(baseDir);
87+
for (const p of paths) {
88+
let d = dirname(p);
89+
while (d.length >= baseDir.length) {
90+
if (dirs.has(d)) break;
91+
dirs.add(d);
92+
const parent = dirname(d);
93+
if (parent === d) break;
94+
d = parent;
95+
}
96+
}
97+
const result: Record<string, number> = {};
98+
for (const d of dirs) {
99+
try {
100+
const stat = fs.statSync(d);
101+
if (stat.isDirectory()) result[d] = stat.mtimeMs;
102+
} catch {
103+
// dir disappeared between glob and stat — skip
104+
}
105+
}
106+
return result;
107+
};
108+
109+
export const setCachedGlob = (key: string, paths: string[], baseDir: string): void => {
110+
if (!cache) return;
111+
cache.set(key, {
112+
paths,
113+
dirMtimes: captureDirMtimes(paths, baseDir),
114+
});
115+
isDirty = true;
116+
};
117+
118+
export const clearGlobCache = (): void => {
119+
if (cache) {
120+
cache.clear();
121+
isDirty = true;
122+
}
123+
};
124+
125+
export const flushGlobCache = (): void => {
126+
if (!cache || !cacheFilePath || !isDirty) return;
127+
try {
128+
const dir = dirname(cacheFilePath);
129+
if (!isDirectory(dir)) fs.mkdirSync(dir, { recursive: true });
130+
fs.writeFileSync(cacheFilePath, serialize(cache));
131+
isDirty = false;
132+
} catch {
133+
debugLog('*', `Error writing glob cache to ${cacheFilePath}`);
134+
}
135+
};

packages/knip/src/util/glob.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fg from 'fast-glob';
22
import { compact } from './array.ts';
3+
import { computeGlobCacheKey, getCachedGlob, isGlobCacheEnabled, setCachedGlob } from './glob-cache.ts';
34
import { glob } from './glob-core.ts';
45
import { timerify } from './Performance.ts';
56
import { isAbsolute, join, relative } from './path.ts';
@@ -43,14 +44,25 @@ const defaultGlob = async ({ cwd, dir = cwd, patterns, gitignore = true, label }
4344
// Only negated patterns? Bail out.
4445
if (globPatterns[0].startsWith('!')) return [];
4546

46-
return glob(globPatterns, {
47+
const cacheEnabled = isGlobCacheEnabled();
48+
const cacheKey = cacheEnabled ? computeGlobCacheKey({ patterns: globPatterns, cwd, dir, gitignore }) : '';
49+
if (cacheEnabled) {
50+
const cached = getCachedGlob(cacheKey);
51+
if (cached) return cached;
52+
}
53+
54+
const paths = await glob(globPatterns, {
4755
cwd,
4856
dir,
4957
gitignore,
5058
absolute: true,
5159
dot: true,
5260
label,
5361
});
62+
63+
if (cacheEnabled && paths.length > 0) setCachedGlob(cacheKey, paths, dir);
64+
65+
return paths;
5466
};
5567

5668
const syncGlob = ({ cwd, patterns }: { cwd?: string; patterns: string | string[] }) =>

packages/knip/src/util/watch.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isFile } from './fs.ts';
1212
import { updateImportMap } from './module-graph.ts';
1313
import { toAbsolute, toPosix, toRelative } from './path.ts';
1414
import { clearModuleResolutionCaches } from '../typescript/resolve-module-names.ts';
15+
import { clearGlobCache } from './glob-cache.ts';
1516
import { clearResolverCache } from './resolve.ts';
1617

1718
export type OnFileChange = (options: { issues: Issues; duration?: number; mem?: number }) => void;
@@ -110,6 +111,7 @@ export const getSessionHandler = async (
110111

111112
clearResolverCache();
112113
clearModuleResolutionCaches();
114+
clearGlobCache();
113115
invalidateCache(graph);
114116

115117
unreferencedFiles.clear();

0 commit comments

Comments
 (0)