fix(desktop): evict the whole subtree on a scoped clearProjectDirCache (#122083 review)

The scoped clear deleted only the root's own key from each cache, but this
PR's listing pass now keys gitRootCache on every listed directory and
nestedRepoCache on every entry the ignore rules probe — all strict
descendants of the root, never the root key itself. The refresh paths in
use-project-tree therefore served stale nested-repo and git-root answers:
git init inside a parent-ignored directory stayed hidden until a full
no-arg clear (connection change or relaunch).

Evict every key at or under the root, for all three caches, matched on a
path boundary via isUnderPath so /repo does not evict /repo2, and only
within the current connection's cache keys. Regression test covers the
refresh-makes-it-visible case and the sibling-root boundary.
This commit is contained in:
Hermes Agent
2026-09-26 16:54:21 -05:00
committed by brooklyn!
parent f0288f2272
commit 26780d55ae
2 changed files with 102 additions and 5 deletions

View File

@@ -276,4 +276,77 @@ describe('readProjectDir', () => {
expect(result.entries.map(entry => entry.name)).toEqual(['.gitignore', 'company-memory', 'README.md'])
})
it('a scoped clearProjectDirCache evicts that root’s subtree and leaves sibling roots cached', async () => {
// `nested` is the set of directories that are their own repository root;
// everything else resolves to its parent repo. Growing the set between
// reads simulates the user running `git init` underneath the project.
const gitRootOf = (nested: string[]) =>
async (path: string): Promise<string | null> => (nested.includes(path) ? path : path.startsWith('/repo2') ? '/repo2' : '/repo')
const countCalls = (mock: typeof gitRoot | typeof readFileDataUrl, arg: string) =>
mock.mock.calls.filter(([callArg]) => callArg === arg).length
readDir.mockImplementation(async path => {
if (path === '/repo') {
return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
}
if (path === '/repo/dev') {
return ok([
{ name: 'ownward-studio', path: '/repo/dev/ownward-studio', isDirectory: true },
{ name: 'scratch', path: '/repo/dev/scratch', isDirectory: true },
{ name: 'README.md', path: '/repo/dev/README.md', isDirectory: false }
])
}
if (path === '/repo2') {
return ok([{ name: '.gitignore', path: '/repo2/.gitignore', isDirectory: false }])
}
if (path === '/repo2/src') {
return ok([
{ name: 'debug.log', path: '/repo2/src/debug.log', isDirectory: false },
{ name: 'keep.ts', path: '/repo2/src/keep.ts', isDirectory: false }
])
}
return ok([])
})
readFileDataUrl.mockImplementation(async path =>
path === '/repo/.gitignore' ? dataUrl('dev/*\n!dev/README.md\n') : dataUrl('src/*.log\n')
)
// First pass: scratch is an ordinary ignored directory, so it is hidden.
gitRoot.mockImplementation(gitRootOf(['/repo/dev/ownward-studio']))
await expect(readProjectDir('/repo/dev', '/repo')).resolves.toMatchObject({
entries: [{ name: 'ownward-studio' }, { name: 'README.md' }]
})
await expect(readProjectDir('/repo2/src', '/repo2')).resolves.toMatchObject({
entries: [{ name: 'keep.ts' }]
})
// The user runs `git init` in /repo/dev/scratch and hits refresh: the
// scoped clear must evict everything cached at or under /repo — the git
// roots, the .gitignore chain, the nested-repo answers — so the re-read
// sees the new repository instead of the stale `false`.
gitRoot.mockImplementation(gitRootOf(['/repo/dev/ownward-studio', '/repo/dev/scratch']))
clearProjectDirCache('/repo')
await expect(readProjectDir('/repo/dev', '/repo')).resolves.toMatchObject({
entries: [{ name: 'ownward-studio' }, { name: 'scratch' }, { name: 'README.md' }]
})
// The cleared root was genuinely re-probed, not served from cache.
expect(countCalls(gitRoot, '/repo/dev')).toBe(2)
expect(countCalls(readFileDataUrl, '/repo/.gitignore')).toBe(2)
// The sibling root /repo2 is NOT under /repo — a bare string prefix would
// have evicted it — so its answers stay cached and nothing re-probes.
await expect(readProjectDir('/repo2/src', '/repo2')).resolves.toMatchObject({
entries: [{ name: 'keep.ts' }]
})
expect(countCalls(gitRoot, '/repo2/src')).toBe(1)
expect(countCalls(readFileDataUrl, '/repo2/.gitignore')).toBe(1)
})
})

View File

@@ -3,7 +3,7 @@ import ignore from 'ignore'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import { ALWAYS_EXCLUDED } from '@/lib/excluded-paths'
import { cleanPath, comparisonPath } from '@/lib/path-compare'
import { cleanPath, comparisonPath, isUnderPath } from '@/lib/path-compare'
import { showsIgnoredFiles } from './prefs'
@@ -218,8 +218,32 @@ export function clearProjectDirCache(rootPath?: string) {
return
}
const key = `${desktopFsCacheKey()}:${cleanPath(rootPath)}`
gitRootCache.delete(key)
gitignoreCache.delete(key)
nestedRepoCache.delete(key)
// The caches are keyed `<connection>:<path>` on every path a listing pass
// touched — the listed directory (gitRootFor(dirPath)), its .gitignore chain
// (gitignoreFor over ancestorDirs), and the entries the ignore rules probe
// (isNestedRepoRoot) — all strict descendants of the root, never the root's
// own key alone. A refresh (use-project-tree) clears so the re-read probes
// underneath what is on screen, so evict every key at or under the root.
// Match on a path boundary via isUnderPath — `/repo` must not evict `/repo2`
// — and only within the current connection's cache keys.
const cachePrefix = `${desktopFsCacheKey()}:`
const inScope = (key: string) => key.startsWith(cachePrefix) && isUnderPath(rootPath, key.slice(cachePrefix.length))
for (const key of gitRootCache.keys()) {
if (inScope(key)) {
gitRootCache.delete(key)
}
}
for (const key of gitignoreCache.keys()) {
if (inScope(key)) {
gitignoreCache.delete(key)
}
}
for (const key of nestedRepoCache.keys()) {
if (inScope(key)) {
nestedRepoCache.delete(key)
}
}
}