diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 9e18a41f66..fd16bf7500 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -15,6 +15,7 @@ import { getEnvironmentMemoryPaths, loadJitSubdirectoryMemory, refreshServerHierarchicalMemory, + readGeminiMdFiles, } from './memoryDiscovery.js'; import { setGeminiMdFilename, @@ -682,6 +683,97 @@ included directory memory expect(childOccurrences).toBe(1); }); + describe('EISDIR handling for GEMINI.md as a directory', () => { + it('readGeminiMdFiles returns null content (without throwing) when path is a directory', async () => { + const dirAsFilePath = await createEmptyDir( + path.join(cwd, DEFAULT_CONTEXT_FILENAME), + ); + + const results = await readGeminiMdFiles([dirAsFilePath]); + + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(dirAsFilePath); + expect(results[0].content).toBeNull(); + }); + + it('loadServerHierarchicalMemory ignores a GEMINI.md directory and returns empty memory', async () => { + // Create a directory named GEMINI.md where a regular file would be expected. + await createEmptyDir(path.join(cwd, DEFAULT_CONTEXT_FILENAME)); + + const result = flattenResult( + await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + new SimpleExtensionLoader([]), + DEFAULT_FOLDER_TRUST, + ), + ); + + // EISDIR is silently skipped, so memory is empty (no readable file + // contents) and no exception propagates. + expect(result.memoryContent).toBe(''); + }); + + it('falls back to a real GEMINI.md file at a higher level when a directory shadows the same name lower in the tree', async () => { + // Lower in the tree (cwd): a directory named GEMINI.md (invalid). + await createEmptyDir(path.join(cwd, DEFAULT_CONTEXT_FILENAME)); + // Higher in the tree (projectRoot): a real GEMINI.md file (valid). + const projectContextFile = await createTestFile( + path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), + 'Project root memory content', + ); + + const result = flattenResult( + await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + new SimpleExtensionLoader([]), + DEFAULT_FOLDER_TRUST, + ), + ); + + // The directory at cwd is silently skipped; the actual file at + // projectRoot is still discovered and loaded normally. + expect(result.memoryContent).toContain('Project root memory content'); + expect(result.filePaths).toContain(projectContextFile); + }); + + it('silently skips a GEMINI.md symlink that points to a directory', async () => { + // Create a real directory elsewhere and symlink GEMINI.md to it. + const realDir = await createEmptyDir(path.join(cwd, '.geminimd-target')); + const symlinkPath = path.join(cwd, DEFAULT_CONTEXT_FILENAME); + try { + await fsPromises.symlink(realDir, symlinkPath, 'dir'); + } catch (err) { + // Symlink creation may be unsupported on some Windows setups (no + // SeCreateSymbolicLinkPrivilege). Skip the test there rather than fail. + if ( + err instanceof Error && + (err as NodeJS.ErrnoException).code === 'EPERM' + ) { + return; + } + throw err; + } + + const result = flattenResult( + await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + new SimpleExtensionLoader([]), + DEFAULT_FOLDER_TRUST, + ), + ); + + // A symlink resolving to a directory triggers EISDIR on read in the + // same way a plain directory does and must be skipped silently. + expect(result.memoryContent).toBe(''); + }); + }); + describe('getGlobalMemoryPaths', () => { it('should find global memory file if it exists', async () => { const globalMemoryFile = await createTestFile( diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 95860d8368..adf65ebe75 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -406,19 +406,34 @@ export async function readGeminiMdFiles( return { filePath, content: processedResult.content }; } catch (error: unknown) { - const isTestEnv = - process.env['NODE_ENV'] === 'test' || process.env['VITEST']; - if (!isTestEnv) { - const message = - error instanceof Error ? error.message : String(error); - logger.warn( - `Warning: Could not read ${getAllGeminiMdFilenames()} file at ${filePath}. Error: ${message}`, + const isEISDIR = + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'EISDIR'; + + if (isEISDIR) { + // A directory exists where a GEMINI.md file is expected. + // This is valid in some project structures (e.g. a folder named + // GEMINI.md held for organisational purposes) — skip it silently + // instead of surfacing a confusing warning to the user. + debugLogger.debug( + '[DEBUG] [MemoryDiscovery] Skipping directory at GEMINI.md path:', + filePath, + ); + } else { + const isTestEnv = + process.env['NODE_ENV'] === 'test' || process.env['VITEST']; + if (!isTestEnv) { + const message = + error instanceof Error ? error.message : String(error); + logger.warn( + `Warning: Could not read ${getAllGeminiMdFilenames()} file at ${filePath}. Error: ${message}`, + ); + } + debugLogger.debug( + '[DEBUG] [MemoryDiscovery] Failed to read:', + filePath, ); } - debugLogger.debug( - '[DEBUG] [MemoryDiscovery] Failed to read:', - filePath, - ); return { filePath, content: null }; // Still include it with null content } },