mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 22:14:52 -07:00
fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive filesystems (#19904) (#19915)
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user