Skip to content

Commit 7ffdc2f

Browse files
committed
Cache parsed .gitignore patterns across --cache runs
1 parent eee3b89 commit 7ffdc2f

4 files changed

Lines changed: 171 additions & 2 deletions

File tree

packages/docs/src/content/docs/reference/cli.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ Consecutive runs are 10-40% faster as the results of file analysis (AST
8282
traversal) are cached. Conservative. Cache strategy based on file meta data
8383
(modification time + file size).
8484

85+
Newly-added `.gitignore` files are not detected automatically — delete the cache
86+
to pick them up.
87+
8588
### `--cache-location`
8689

8790
Provide alternative cache location.

packages/knip/src/run.ts

Lines changed: 9 additions & 2 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 { flushGitignoreCache, initGitignoreCache } from './util/gitignore-cache.ts';
1314
import { flushGlobCache, initGlobCache } from './util/glob-cache.ts';
1415
import { getGitIgnoredHandler } from './util/glob-core.ts';
1516
import { getModuleSourcePathHandler, getWorkspaceManifestHandler } from './util/to-source-path.ts';
@@ -21,7 +22,10 @@ export const run = async (options: MainOptions) => {
2122
debugLogObject('*', 'Unresolved configuration', options);
2223
debugLogObject('*', 'Included issue types', options.includedIssueTypes);
2324

24-
if (options.isCache) initGlobCache(options.cacheLocation);
25+
if (options.isCache) {
26+
initGlobCache(options.cacheLocation);
27+
initGitignoreCache(options.cacheLocation);
28+
}
2529

2630
const chief = new ConfigurationChief(options);
2731
const deputy = new DependencyDeputy(options);
@@ -104,7 +108,10 @@ export const run = async (options: MainOptions) => {
104108

105109
if (!options.isWatch) streamer.clear();
106110

107-
if (options.isCache) flushGlobCache();
111+
if (options.isCache) {
112+
flushGlobCache();
113+
flushGitignoreCache();
114+
}
108115

109116
return {
110117
results: {
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import fs from 'node:fs';
2+
// oxlint-disable-next-line no-restricted-imports
3+
import path from 'node:path';
4+
import { deserialize, serialize } from 'node:v8';
5+
import { version } from '../version.ts';
6+
import { debugLog } from './debug.ts';
7+
import { isDirectory, isFile } from './fs.ts';
8+
import { dirname } from './path.ts';
9+
10+
interface PerDirIgnores {
11+
ignores: string[];
12+
unignores: string[];
13+
}
14+
15+
interface GitignoreCacheEntry {
16+
/** Relative paths (to cwd) — matches the contract of findAndParseGitignores's return */
17+
gitignoreFiles: string[];
18+
/** Parallel array: mtimeMs of each gitignoreFiles[i] at cache write */
19+
mtimes: number[];
20+
ignores: string[];
21+
unignores: string[];
22+
/** Absolute dir path → cached ignores/unignores for that dir */
23+
perDirIgnores: Record<string, PerDirIgnores>;
24+
/** Sorted workspace dirs, joined with \0 — invalidates when workspace set changes */
25+
workspaceDirsKey: string;
26+
}
27+
28+
export interface CachedGitignoreResult {
29+
ignores: Set<string>;
30+
unignores: Set<string>;
31+
gitignoreFiles: string[];
32+
perDirIgnores: Map<string, { ignores: Set<string>; unignores: Set<string> }>;
33+
}
34+
35+
const CACHE_FILENAME = `gitignore-${version}.cache`;
36+
37+
let cacheFilePath: string | undefined;
38+
let cache: Map<string, GitignoreCacheEntry> | undefined;
39+
let isDirty = false;
40+
41+
export const initGitignoreCache = (cacheLocation: string) => {
42+
cacheFilePath = path.resolve(cacheLocation, CACHE_FILENAME);
43+
if (isFile(cacheFilePath)) {
44+
try {
45+
cache = deserialize(fs.readFileSync(cacheFilePath));
46+
} catch {
47+
debugLog('*', `Error reading gitignore cache from ${cacheFilePath}`);
48+
cache = new Map();
49+
}
50+
} else {
51+
cache = new Map();
52+
}
53+
};
54+
55+
export const isGitignoreCacheEnabled = () => cache !== undefined;
56+
57+
const workspaceDirsKey = (workspaceDirs?: Set<string>): string => {
58+
if (!workspaceDirs || workspaceDirs.size === 0) return '';
59+
return [...workspaceDirs].sort().join('\0');
60+
};
61+
62+
const validateEntry = (entry: GitignoreCacheEntry, wsKey: string, cwd: string): boolean => {
63+
if (entry.workspaceDirsKey !== wsKey) return false;
64+
const files = entry.gitignoreFiles;
65+
const mtimes = entry.mtimes;
66+
for (let i = 0; i < files.length; i++) {
67+
const abs = path.isAbsolute(files[i]) ? files[i] : path.resolve(cwd, files[i]);
68+
try {
69+
if (fs.statSync(abs).mtimeMs !== mtimes[i]) return false;
70+
} catch {
71+
return false;
72+
}
73+
}
74+
return true;
75+
};
76+
77+
export const getCachedGitignore = (cwd: string, workspaceDirs?: Set<string>): CachedGitignoreResult | undefined => {
78+
if (!cache) return undefined;
79+
const entry = cache.get(cwd);
80+
if (!entry) return undefined;
81+
const wsKey = workspaceDirsKey(workspaceDirs);
82+
if (!validateEntry(entry, wsKey, cwd)) {
83+
cache.delete(cwd);
84+
isDirty = true;
85+
return undefined;
86+
}
87+
const perDirIgnores = new Map<string, { ignores: Set<string>; unignores: Set<string> }>();
88+
for (const dir in entry.perDirIgnores) {
89+
const data = entry.perDirIgnores[dir];
90+
perDirIgnores.set(dir, { ignores: new Set(data.ignores), unignores: new Set(data.unignores) });
91+
}
92+
return {
93+
ignores: new Set(entry.ignores),
94+
unignores: new Set(entry.unignores),
95+
gitignoreFiles: entry.gitignoreFiles,
96+
perDirIgnores,
97+
};
98+
};
99+
100+
export const setCachedGitignore = (
101+
cwd: string,
102+
workspaceDirs: Set<string> | undefined,
103+
gitignoreFiles: string[],
104+
ignores: Set<string>,
105+
unignores: Set<string>,
106+
perDirIgnores: Map<string, { ignores: Set<string>; unignores: Set<string> }>
107+
): void => {
108+
if (!cache) return;
109+
const mtimes: number[] = [];
110+
const validFiles: string[] = [];
111+
for (const file of gitignoreFiles) {
112+
const abs = path.isAbsolute(file) ? file : path.resolve(cwd, file);
113+
try {
114+
mtimes.push(fs.statSync(abs).mtimeMs);
115+
validFiles.push(file);
116+
} catch {
117+
// File was removed between read and stat; skip rather than poison cache
118+
}
119+
}
120+
const perDir: Record<string, PerDirIgnores> = {};
121+
for (const [dir, data] of perDirIgnores) {
122+
perDir[dir] = { ignores: [...data.ignores], unignores: [...data.unignores] };
123+
}
124+
cache.set(cwd, {
125+
gitignoreFiles: validFiles,
126+
mtimes,
127+
ignores: [...ignores],
128+
unignores: [...unignores],
129+
perDirIgnores: perDir,
130+
workspaceDirsKey: workspaceDirsKey(workspaceDirs),
131+
});
132+
isDirty = true;
133+
};
134+
135+
export const flushGitignoreCache = (): void => {
136+
if (!cache || !cacheFilePath || !isDirty) return;
137+
try {
138+
const dir = dirname(cacheFilePath);
139+
if (!isDirectory(dir)) fs.mkdirSync(dir, { recursive: true });
140+
fs.writeFileSync(cacheFilePath, serialize(cache));
141+
isDirty = false;
142+
} catch {
143+
debugLog('*', `Error writing gitignore cache to ${cacheFilePath}`);
144+
}
145+
};

packages/knip/src/util/glob-core.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { GLOBAL_IGNORE_PATTERNS } from '../constants.ts';
88
import { compact, partition } from './array.ts';
99
import { debugLogObject } from './debug.ts';
1010
import { isDirectory, isFile } from './fs.ts';
11+
import { getCachedGitignore, isGitignoreCacheEnabled, setCachedGitignore } from './gitignore-cache.ts';
1112
import { timerify } from './Performance.ts';
1213
import { expandIgnorePatterns, parseAndConvertGitignorePatterns } from './parse-and-convert-gitignores.ts';
1314
import { dirname, join, relative, toPosix } from './path.ts';
@@ -61,6 +62,15 @@ const findAncestorGitignoreFiles = (cwd: string): string[] => {
6162

6263
/** @internal */
6364
export const findAndParseGitignores = async (cwd: string, workspaceDirs?: Set<string>) => {
65+
if (isGitignoreCacheEnabled()) {
66+
const cached = getCachedGitignore(cwd, workspaceDirs);
67+
if (cached) {
68+
for (const [dir, data] of cached.perDirIgnores) cachedGitIgnores.set(dir, data);
69+
debugLogObject('*', 'Parsed gitignore files (cached)', { gitignoreFiles: cached.gitignoreFiles });
70+
return { gitignoreFiles: cached.gitignoreFiles, ignores: cached.ignores, unignores: cached.unignores };
71+
}
72+
}
73+
6474
const ignores: Set<string> = new Set(GLOBAL_IGNORE_PATTERNS);
6575
const unignores: Set<string> = new Set();
6676
const gitignoreFiles: string[] = [];
@@ -221,6 +231,10 @@ export const findAndParseGitignores = async (cwd: string, workspaceDirs?: Set<st
221231

222232
debugLogObject('*', 'Parsed gitignore files', { gitignoreFiles });
223233

234+
if (isGitignoreCacheEnabled()) {
235+
setCachedGitignore(cwd, workspaceDirs, gitignoreFiles, ignores, unignores, cachedGitIgnores);
236+
}
237+
224238
return { gitignoreFiles, ignores, unignores };
225239
};
226240

0 commit comments

Comments
 (0)