source dump of claude code
1/**
2 * Provides ripgrep glob exclusion patterns for orphaned plugin versions.
3 *
4 * When plugin versions are updated, old versions are marked with a
5 * `.orphaned_at` file but kept on disk for 7 days (since concurrent
6 * sessions might still reference them). During this window, Grep/Glob
7 * could return files from orphaned versions, causing Claude to use
8 * outdated plugin code.
9 *
10 * We find `.orphaned_at` markers via a single ripgrep call and generate
11 * `--glob '!<dir>/**'` patterns for their parent directories. The cache
12 * is warmed in main.tsx AFTER cleanupOrphanedPluginVersionsInBackground
13 * settles disk state. Once populated, the exclusion list is frozen for
14 * the session unless /reload-plugins is called; subsequent disk mutations
15 * (autoupdate, concurrent sessions) don't affect it.
16 */
17
18import { dirname, isAbsolute, join, normalize, relative, sep } from 'path'
19import { ripGrep } from '../ripgrep.js'
20import { getPluginsDirectory } from './pluginDirectories.js'
21
22// Inlined from cacheUtils.ts to avoid a circular dep through commands.js.
23const ORPHANED_AT_FILENAME = '.orphaned_at'
24
25/** Session-scoped cache. Frozen once computed — only cleared by explicit /reload-plugins. */
26let cachedExclusions: string[] | null = null
27
28/**
29 * Get ripgrep glob exclusion patterns for orphaned plugin versions.
30 *
31 * @param searchPath - When provided, exclusions are only returned if the
32 * search overlaps the plugin cache directory (avoids unnecessary --glob
33 * args for searches outside the cache).
34 *
35 * Warmed eagerly in main.tsx after orphan GC; the lazy-compute path here
36 * is a fallback. Best-effort: returns empty array if anything goes wrong.
37 */
38export async function getGlobExclusionsForPluginCache(
39 searchPath?: string,
40): Promise<string[]> {
41 const cachePath = normalize(join(getPluginsDirectory(), 'cache'))
42
43 if (searchPath && !pathsOverlap(searchPath, cachePath)) {
44 return []
45 }
46
47 if (cachedExclusions !== null) {
48 return cachedExclusions
49 }
50
51 try {
52 // Find all .orphaned_at files within the plugin cache directory.
53 // --hidden: marker is a dotfile. --no-ignore: don't let a stray
54 // .gitignore hide it. --max-depth 4: marker is always at
55 // cache/<marketplace>/<plugin>/<version>/.orphaned_at — don't recurse
56 // into plugin contents (node_modules, etc.). Never-aborts signal: no
57 // caller signal to thread.
58 const markers = await ripGrep(
59 [
60 '--files',
61 '--hidden',
62 '--no-ignore',
63 '--max-depth',
64 '4',
65 '--glob',
66 ORPHANED_AT_FILENAME,
67 ],
68 cachePath,
69 new AbortController().signal,
70 )
71
72 cachedExclusions = markers.map(markerPath => {
73 // ripgrep may return absolute or relative — normalize to relative.
74 const versionDir = dirname(markerPath)
75 const rel = isAbsolute(versionDir)
76 ? relative(cachePath, versionDir)
77 : versionDir
78 // ripgrep glob patterns always use forward slashes, even on Windows
79 const posixRelative = rel.replace(/\\/g, '/')
80 return `!**/${posixRelative}/**`
81 })
82 return cachedExclusions
83 } catch {
84 // Best-effort — don't break core search tools if ripgrep fails here
85 cachedExclusions = []
86 return cachedExclusions
87 }
88}
89
90export function clearPluginCacheExclusions(): void {
91 cachedExclusions = null
92}
93
94/**
95 * One path is a prefix of the other. Special-cases root (normalize('/') + sep
96 * = '//'). Case-insensitive on win32 since normalize() doesn't lowercase
97 * drive letters and CLAUDE_CODE_PLUGIN_CACHE_DIR may disagree with resolved.
98 */
99function pathsOverlap(a: string, b: string): boolean {
100 const na = normalizeForCompare(a)
101 const nb = normalizeForCompare(b)
102 return (
103 na === nb ||
104 na === sep ||
105 nb === sep ||
106 na.startsWith(nb + sep) ||
107 nb.startsWith(na + sep)
108 )
109}
110
111function normalizeForCompare(p: string): string {
112 const n = normalize(p)
113 return process.platform === 'win32' ? n.toLowerCase() : n
114}