fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive filesystems (#19904) (#19915)

This commit is contained in:
nityam
2026-03-06 23:22:08 +05:30
committed by GitHub
parent 337e4bc8c6
commit 82316ef6e4
7 changed files with 569 additions and 144 deletions
@@ -21,6 +21,7 @@ vi.mock('../utils/memoryDiscovery.js', async (importOriginal) => {
getEnvironmentMemoryPaths: vi.fn(),
readGeminiMdFiles: vi.fn(),
loadJitSubdirectoryMemory: vi.fn(),
deduplicatePathsByFileIdentity: vi.fn(),
concatenateInstructions: vi
.fn()
.mockImplementation(actual.concatenateInstructions),
@@ -33,7 +34,6 @@ describe('ContextManager', () => {
beforeEach(() => {
mockConfig = {
getDebugMode: vi.fn().mockReturnValue(false),
getWorkingDir: vi.fn().mockReturnValue('/app'),
getImportFormat: vi.fn().mockReturnValue('tree'),
getWorkspaceContext: vi.fn().mockReturnValue({
@@ -52,6 +52,13 @@ describe('ContextManager', () => {
vi.clearAllMocks();
vi.spyOn(coreEvents, 'emit');
vi.mocked(memoryDiscovery.getExtensionMemoryPaths).mockReturnValue([]);
// default mock: deduplication returns paths as-is (no deduplication)
vi.mocked(
memoryDiscovery.deduplicatePathsByFileIdentity,
).mockImplementation(async (paths: string[]) => ({
paths,
identityMap: new Map<string, string>(),
}));
});
describe('refresh', () => {
@@ -74,13 +81,11 @@ describe('ContextManager', () => {
await contextManager.refresh();
expect(memoryDiscovery.getGlobalMemoryPaths).toHaveBeenCalled();
expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith(
['/app'],
false,
);
expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith([
'/app',
]);
expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith(
expect.arrayContaining([...globalPaths, ...envPaths]),
false,
'tree',
);
@@ -128,6 +133,50 @@ describe('ContextManager', () => {
expect(contextManager.getEnvironmentMemory()).toBe('');
expect(contextManager.getGlobalMemory()).toContain('Global Content');
});
it('should deduplicate files by file identity in case-insensitive filesystems', async () => {
const globalPaths = ['/home/user/.gemini/GEMINI.md'];
const envPaths = ['/app/gemini.md', '/app/GEMINI.md'];
vi.mocked(memoryDiscovery.getGlobalMemoryPaths).mockResolvedValue(
globalPaths,
);
vi.mocked(memoryDiscovery.getEnvironmentMemoryPaths).mockResolvedValue(
envPaths,
);
// mock deduplication to return deduplicated paths (simulating same file)
vi.mocked(
memoryDiscovery.deduplicatePathsByFileIdentity,
).mockResolvedValue({
paths: ['/home/user/.gemini/GEMINI.md', '/app/gemini.md'],
identityMap: new Map<string, string>(),
});
vi.mocked(memoryDiscovery.readGeminiMdFiles).mockResolvedValue([
{ filePath: '/home/user/.gemini/GEMINI.md', content: 'Global Content' },
{ filePath: '/app/gemini.md', content: 'Project Content' },
]);
await contextManager.refresh();
expect(
memoryDiscovery.deduplicatePathsByFileIdentity,
).toHaveBeenCalledWith(
expect.arrayContaining([
'/home/user/.gemini/GEMINI.md',
'/app/gemini.md',
'/app/GEMINI.md',
]),
);
expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith(
['/home/user/.gemini/GEMINI.md', '/app/gemini.md'],
'tree',
);
expect(contextManager.getEnvironmentMemory()).toContain(
'Project Content',
);
});
});
describe('discoverContext', () => {
@@ -147,7 +196,7 @@ describe('ContextManager', () => {
'/app/src/file.ts',
['/app'],
expect.any(Set),
false,
expect.any(Set),
);
expect(result).toMatch(/--- Context from: src[\\/]GEMINI\.md ---/);
expect(result).toContain('Src Content');
+42 -20
View File
@@ -13,12 +13,14 @@ import {
readGeminiMdFiles,
categorizeAndConcatenate,
type GeminiFileContent,
deduplicatePathsByFileIdentity,
} from '../utils/memoryDiscovery.js';
import type { Config } from '../config/config.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
export class ContextManager {
private readonly loadedPaths: Set<string> = new Set();
private readonly loadedFileIdentities: Set<string> = new Set();
private readonly config: Config;
private globalMemory: string = '';
private extensionMemory: string = '';
@@ -33,49 +35,61 @@ export class ContextManager {
*/
async refresh(): Promise<void> {
this.loadedPaths.clear();
const debugMode = this.config.getDebugMode();
this.loadedFileIdentities.clear();
const paths = await this.discoverMemoryPaths(debugMode);
const contentsMap = await this.loadMemoryContents(paths, debugMode);
const paths = await this.discoverMemoryPaths();
const contentsMap = await this.loadMemoryContents(paths);
this.categorizeMemoryContents(paths, contentsMap);
this.emitMemoryChanged();
}
private async discoverMemoryPaths(debugMode: boolean) {
private async discoverMemoryPaths() {
const [global, extension, project] = await Promise.all([
getGlobalMemoryPaths(debugMode),
getGlobalMemoryPaths(),
Promise.resolve(
getExtensionMemoryPaths(this.config.getExtensionLoader()),
),
this.config.isTrustedFolder()
? getEnvironmentMemoryPaths(
[...this.config.getWorkspaceContext().getDirectories()],
debugMode,
)
? getEnvironmentMemoryPaths([
...this.config.getWorkspaceContext().getDirectories(),
])
: Promise.resolve([]),
]);
return { global, extension, project };
}
private async loadMemoryContents(
paths: { global: string[]; extension: string[]; project: string[] },
debugMode: boolean,
) {
const allPaths = Array.from(
private async loadMemoryContents(paths: {
global: string[];
extension: string[];
project: string[];
}) {
const allPathsStringDeduped = Array.from(
new Set([...paths.global, ...paths.extension, ...paths.project]),
);
// deduplicate by file identity to handle case-insensitive filesystems
const { paths: allPaths, identityMap: pathIdentityMap } =
await deduplicatePathsByFileIdentity(allPathsStringDeduped);
const allContents = await readGeminiMdFiles(
allPaths,
debugMode,
this.config.getImportFormat(),
);
this.markAsLoaded(
allContents.filter((c) => c.content !== null).map((c) => c.filePath),
);
const loadedFilePaths = allContents
.filter((c) => c.content !== null)
.map((c) => c.filePath);
this.markAsLoaded(loadedFilePaths);
// Cache file identities for performance optimization
for (const filePath of loadedFilePaths) {
const identity = pathIdentityMap.get(filePath);
if (identity) {
this.loadedFileIdentities.add(identity);
}
}
return new Map(allContents.map((c) => [c.filePath, c]));
}
@@ -123,14 +137,22 @@ export class ContextManager {
accessedPath,
trustedRoots,
this.loadedPaths,
this.config.getDebugMode(),
this.loadedFileIdentities,
);
if (result.files.length === 0) {
return '';
}
this.markAsLoaded(result.files.map((f) => f.path));
const newFilePaths = result.files.map((f) => f.path);
this.markAsLoaded(newFilePaths);
// Cache identities for newly loaded files
if (result.fileIdentities) {
for (const identity of result.fileIdentities) {
this.loadedFileIdentities.add(identity);
}
}
return concatenateInstructions(
result.files.map((f) => ({ filePath: f.path, content: f.content })),
this.config.getWorkingDir(),