Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 149 additions & 1 deletion sdk/src/__tests__/code-search.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'

import {
clearMockedModules,
mockModule,
Expand All @@ -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'

Expand All @@ -19,6 +27,7 @@ describe('codeSearch', () => {
let mockProcess: MockChildProcess

beforeEach(async () => {
clearHiddenDirsCache()
mockProcess = createMockChildProcess()
mockSpawn = mock(() => mockProcess)
await mockModule('child_process', () => ({
Expand All @@ -29,6 +38,7 @@ describe('codeSearch', () => {
afterEach(() => {
mock.restore()
clearMockedModules()
clearHiddenDirsCache()
})

describe('basic search', () => {
Expand Down Expand Up @@ -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'])
})
})
})
57 changes: 50 additions & 7 deletions sdk/src/tools/code-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { dirs: string[]; timestamp: number }>()

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,
Expand Down Expand Up @@ -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',
Expand Down
Loading