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
92 changes: 92 additions & 0 deletions packages/core/src/utils/memoryDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getEnvironmentMemoryPaths,
loadJitSubdirectoryMemory,
refreshServerHierarchicalMemory,
readGeminiMdFiles,
} from './memoryDiscovery.js';
import {
setGeminiMdFilename,
Expand Down Expand Up @@ -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(
Expand Down
37 changes: 26 additions & 11 deletions packages/core/src/utils/memoryDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,19 +403,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
}
},
Expand Down