diff --git a/sdk/src/__tests__/code-search.test.ts b/sdk/src/__tests__/code-search.test.ts index 40b0f8a3de..5b2cd4383b 100644 --- a/sdk/src/__tests__/code-search.test.ts +++ b/sdk/src/__tests__/code-search.test.ts @@ -1,3 +1,7 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + import { clearMockedModules, mockModule, @@ -10,7 +14,11 @@ import { } from '@codebuff/common/testing/mocks' import { describe, expect, it, mock, beforeEach, afterEach } from 'bun:test' -import { codeSearch } from '../tools/code-search' +import { + codeSearch, + clearHiddenDirsCache, + getExistingHiddenDirs, +} from '../tools/code-search' import type { MockChildProcess } from '@codebuff/common/testing/mocks' @@ -19,6 +27,7 @@ describe('codeSearch', () => { let mockProcess: MockChildProcess beforeEach(async () => { + clearHiddenDirsCache() mockProcess = createMockChildProcess() mockSpawn = mock(() => mockProcess) await mockModule('child_process', () => ({ @@ -29,6 +38,7 @@ describe('codeSearch', () => { afterEach(() => { mock.restore() clearMockedModules() + clearHiddenDirsCache() }) describe('basic search', () => { @@ -899,4 +909,142 @@ describe('codeSearch', () => { expect(spawnOptions.cwd).toBe('/test/outside') }) }) + + describe('hidden directories caching', () => { + let tempDirs: string[] = [] + + const createTempDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-search-test-')) + tempDirs.push(dir) + return dir + } + + afterEach(() => { + clearHiddenDirsCache() + for (const dir of tempDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch {} + } + tempDirs = [] + }) + + it('caches detected hidden directories within TTL', () => { + const testDir = createTempDir() + const t0 = 10_000 + + // Initial check when no hidden directories exist + expect(getExistingHiddenDirs(testDir, t0)).toEqual([]) + + // Create a hidden directory on disk + fs.mkdirSync(path.join(testDir, '.github')) + + // Within 1-second TTL, cached result is returned without re-statting + expect(getExistingHiddenDirs(testDir, t0 + 500)).toEqual([]) + }) + + it('immediately picks up newly created directories after clearHiddenDirsCache()', () => { + const testDir = createTempDir() + + // Initial check + expect(getExistingHiddenDirs(testDir)).toEqual([]) + + // Create a hidden directory + fs.mkdirSync(path.join(testDir, '.github')) + + // Still cached + expect(getExistingHiddenDirs(testDir)).toEqual([]) + + // Clear cache and verify immediate discovery + clearHiddenDirsCache() + expect(getExistingHiddenDirs(testDir)).toEqual(['.github']) + }) + + it('immediately picks up newly removed directories after clearHiddenDirsCache()', () => { + const testDir = createTempDir() + const huskyDir = path.join(testDir, '.husky') + fs.mkdirSync(huskyDir) + + // Initial check detects .husky + expect(getExistingHiddenDirs(testDir)).toEqual(['.husky']) + + // Remove the directory + fs.rmdirSync(huskyDir) + + // Still cached + expect(getExistingHiddenDirs(testDir)).toEqual(['.husky']) + + // Clear cache and verify immediate discovery of removal + clearHiddenDirsCache() + expect(getExistingHiddenDirs(testDir)).toEqual([]) + }) + + it('automatically discovers newly created directories once TTL expires without manual cache clear', () => { + const testDir = createTempDir() + const t0 = 20_000 + + // Initial check + expect(getExistingHiddenDirs(testDir, t0)).toEqual([]) + + // Create a hidden directory + fs.mkdirSync(path.join(testDir, '.agents')) + + // Within TTL: cached + expect(getExistingHiddenDirs(testDir, t0 + 500)).toEqual([]) + + // Past 1000ms TTL: automatically refreshed on next call + expect(getExistingHiddenDirs(testDir, t0 + 1001)).toEqual(['.agents']) + }) + + it('supports targeted directory invalidation with clearHiddenDirsCache(dir)', () => { + const dirA = createTempDir() + const dirB = createTempDir() + + // Populate both in cache + expect(getExistingHiddenDirs(dirA)).toEqual([]) + expect(getExistingHiddenDirs(dirB)).toEqual([]) + + // Create directories in both + fs.mkdirSync(path.join(dirA, '.github')) + fs.mkdirSync(path.join(dirB, '.husky')) + + // Invalidate only dirA + clearHiddenDirsCache(dirA) + + // dirA is re-evaluated, dirB remains cached + expect(getExistingHiddenDirs(dirA)).toEqual(['.github']) + expect(getExistingHiddenDirs(dirB)).toEqual([]) + }) + + it('evicts least-recently used entry when cache capacity reaches MAX_CACHE_SIZE', () => { + const dirOld = createTempDir() + const dirRecent = createTempDir() + const t0 = 30_000 + + // Cache dirOld then dirRecent + expect(getExistingHiddenDirs(dirOld, t0)).toEqual([]) + expect(getExistingHiddenDirs(dirRecent, t0)).toEqual([]) + + // Access dirRecent again to refresh its recency in the LRU Map + expect(getExistingHiddenDirs(dirRecent, t0 + 100)).toEqual([]) + + // Fill remaining capacity up to 100 entries + for (let i = 2; i < 100; i++) { + getExistingHiddenDirs(`/mock/nonexistent/dir-${i}`, t0) + } + + // Add 101st entry to trigger eviction of the oldest (dirOld) + getExistingHiddenDirs('/mock/nonexistent/dir-overflow', t0) + + // Create .github in both test directories + fs.mkdirSync(path.join(dirOld, '.github')) + fs.mkdirSync(path.join(dirRecent, '.github')) + + // dirRecent was refreshed and preserved in LRU, so it remains cached within TTL + expect(getExistingHiddenDirs(dirRecent, t0 + 200)).toEqual([]) + + // dirOld was evicted, so it re-stats disk and discovers .github + expect(getExistingHiddenDirs(dirOld, t0 + 200)).toEqual(['.github']) + }) + }) }) diff --git a/sdk/src/tools/code-search.ts b/sdk/src/tools/code-search.ts index 23c70e6db7..f73e130ade 100644 --- a/sdk/src/tools/code-search.ts +++ b/sdk/src/tools/code-search.ts @@ -19,6 +19,55 @@ const INCLUDED_HIDDEN_DIRS = [ '.husky', // Git hooks ] +const HIDDEN_DIRS_CACHE_TTL_MS = 1_000 // 1 second TTL (burst de-duplication window) +const MAX_CACHE_SIZE = 100 +const hiddenDirsCache = new Map() + +export function getExistingHiddenDirs( + searchCwd: string, + now = Date.now(), +): string[] { + const normalizedCwd = path.resolve(searchCwd) + const cached = hiddenDirsCache.get(normalizedCwd) + if (cached && now - cached.timestamp < HIDDEN_DIRS_CACHE_TTL_MS) { + // Refresh LRU recency + hiddenDirsCache.delete(normalizedCwd) + hiddenDirsCache.set(normalizedCwd, cached) + return cached.dirs + } + + const existingHiddenDirs = INCLUDED_HIDDEN_DIRS.filter((dir) => { + try { + return fs.statSync(path.join(normalizedCwd, dir)).isDirectory() + } catch { + return false + } + }) + + if (hiddenDirsCache.has(normalizedCwd)) { + hiddenDirsCache.delete(normalizedCwd) + } else if (hiddenDirsCache.size >= MAX_CACHE_SIZE) { + const oldestKey = hiddenDirsCache.keys().next().value + if (oldestKey !== undefined) { + hiddenDirsCache.delete(oldestKey) + } + } + hiddenDirsCache.set(normalizedCwd, { + dirs: existingHiddenDirs, + timestamp: now, + }) + + return existingHiddenDirs +} + +export function clearHiddenDirsCache(dir?: string): void { + if (dir) { + hiddenDirsCache.delete(path.resolve(dir)) + } else { + hiddenDirsCache.clear() + } +} + export function codeSearch({ projectPath, pattern, @@ -68,13 +117,7 @@ export function codeSearch({ // "--"" prevents pattern from being misparsed as a flag (e.g., pattern starting with '-') // Search paths: '.' plus blessed hidden directories that actually exist // Filter out non-existent directories to avoid ripgrep stderr errors - const existingHiddenDirs = INCLUDED_HIDDEN_DIRS.filter((dir) => { - try { - return fs.statSync(path.join(searchCwd, dir)).isDirectory() - } catch { - return false - } - }) + const existingHiddenDirs = getExistingHiddenDirs(searchCwd) const searchPaths = ['.', ...existingHiddenDirs] const args = [ '--no-config',