feat(context): add configurable memoryBoundaryMarkers setting (#24020)

This commit is contained in:
Sandy Tao
2026-03-27 14:51:32 -07:00
committed by GitHub
parent 765fb67011
commit 4034c030e7
13 changed files with 265 additions and 55 deletions
+12
View File
@@ -1287,6 +1287,18 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Maximum number of directories to search for memory. - **Description:** Maximum number of directories to search for memory.
- **Default:** `200` - **Default:** `200`
- **`context.memoryBoundaryMarkers`** (array):
- **Description:** File or directory names that mark the boundary for
GEMINI.md discovery. The upward traversal stops at the first directory
containing any of these markers. An empty array disables parent traversal.
- **Default:**
```json
[".git"]
```
- **Requires restart:** Yes
- **`context.includeDirectories`** (array): - **`context.includeDirectories`** (array):
- **Description:** Additional directories to include in the workspace context. - **Description:** Additional directories to include in the workspace context.
Missing directories will be skipped with a warning. Missing directories will be skipped with a warning.
+3
View File
@@ -989,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true, respectGeminiIgnore: true,
}), }),
200, // maxDirs 200, // maxDirs
['.git'], // boundaryMarkers
); );
}); });
@@ -1018,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true, respectGeminiIgnore: true,
}), }),
200, 200,
['.git'], // boundaryMarkers
); );
}); });
@@ -1046,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true, respectGeminiIgnore: true,
}), }),
200, 200,
['.git'], // boundaryMarkers
); );
}); });
}); });
+2
View File
@@ -642,6 +642,7 @@ export async function loadCliConfig(
memoryImportFormat, memoryImportFormat,
memoryFileFiltering, memoryFileFiltering,
settings.context?.discoveryMaxDirs, settings.context?.discoveryMaxDirs,
settings.context?.memoryBoundaryMarkers,
); );
memoryContent = result.memoryContent; memoryContent = result.memoryContent;
fileCount = result.fileCount; fileCount = result.fileCount;
@@ -896,6 +897,7 @@ export async function loadCliConfig(
loadMemoryFromIncludeDirectories: loadMemoryFromIncludeDirectories:
settings.context?.loadMemoryFromIncludeDirectories || false, settings.context?.loadMemoryFromIncludeDirectories || false,
discoveryMaxDirs: settings.context?.discoveryMaxDirs, discoveryMaxDirs: settings.context?.discoveryMaxDirs,
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
importFormat: settings.context?.importFormat, importFormat: settings.context?.importFormat,
debugMode, debugMode,
question, question,
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
respectGeminiIgnore: true, respectGeminiIgnore: true,
}), }),
getDiscoveryMaxDirs: () => 200, getDiscoveryMaxDirs: () => 200,
getMemoryBoundaryMarkers: () => ['.git'],
getMcpClientManager: () => ({ getMcpClientManager: () => ({
getMcpInstructions: () => '', getMcpInstructions: () => '',
startExtension: vi.fn().mockResolvedValue(undefined), startExtension: vi.fn().mockResolvedValue(undefined),
+13
View File
@@ -1291,6 +1291,19 @@ const SETTINGS_SCHEMA = {
description: 'Maximum number of directories to search for memory.', description: 'Maximum number of directories to search for memory.',
showInDialog: true, showInDialog: true,
}, },
memoryBoundaryMarkers: {
type: 'array',
label: 'Memory Boundary Markers',
category: 'Context',
requiresRestart: true,
default: ['.git'] as string[],
description:
'File or directory names that mark the boundary for GEMINI.md discovery. ' +
'The upward traversal stops at the first directory containing any of these markers. ' +
'An empty array disables parent traversal.',
showInDialog: false,
items: { type: 'string' },
},
includeDirectories: { includeDirectories: {
type: 'array', type: 'array',
label: 'Include Directories', label: 'Include Directories',
@@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAdminSkillsEnabled: vi.fn().mockReturnValue(false), getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]), getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false), getExperimentalJitContext: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined), getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'), getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false), getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
+7
View File
@@ -685,6 +685,7 @@ export interface ConfigParameters {
experimentalAgentHistoryTruncationThreshold?: number; experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number; experimentalAgentHistoryRetainedMessages?: number;
experimentalAgentHistorySummarization?: boolean; experimentalAgentHistorySummarization?: boolean;
memoryBoundaryMarkers?: string[];
topicUpdateNarration?: boolean; topicUpdateNarration?: boolean;
toolOutputMasking?: Partial<ToolOutputMaskingConfig>; toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean; disableLLMCorrection?: boolean;
@@ -917,6 +918,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly experimentalAgentHistoryTruncationThreshold: number; private readonly experimentalAgentHistoryTruncationThreshold: number;
private readonly experimentalAgentHistoryRetainedMessages: number; private readonly experimentalAgentHistoryRetainedMessages: number;
private readonly experimentalAgentHistorySummarization: boolean; private readonly experimentalAgentHistorySummarization: boolean;
private readonly memoryBoundaryMarkers: readonly string[];
private readonly topicUpdateNarration: boolean; private readonly topicUpdateNarration: boolean;
private readonly disableLLMCorrection: boolean; private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean; private readonly planEnabled: boolean;
@@ -1134,6 +1136,7 @@ export class Config implements McpContext, AgentLoopContext {
params.experimentalAgentHistoryRetainedMessages ?? 15; params.experimentalAgentHistoryRetainedMessages ?? 15;
this.experimentalAgentHistorySummarization = this.experimentalAgentHistorySummarization =
params.experimentalAgentHistorySummarization ?? false; params.experimentalAgentHistorySummarization ?? false;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
this.topicUpdateNarration = params.topicUpdateNarration ?? false; this.topicUpdateNarration = params.topicUpdateNarration ?? false;
this.modelSteering = params.modelSteering ?? false; this.modelSteering = params.modelSteering ?? false;
this.injectionService = new InjectionService(() => this.injectionService = new InjectionService(() =>
@@ -2310,6 +2313,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalJitContext; return this.experimentalJitContext;
} }
getMemoryBoundaryMarkers(): readonly string[] {
return this.memoryBoundaryMarkers;
}
isMemoryManagerEnabled(): boolean { isMemoryManagerEnabled(): boolean {
return this.experimentalMemoryManager; return this.experimentalMemoryManager;
} }
@@ -46,6 +46,7 @@ describe('ContextManager', () => {
getMcpInstructions: vi.fn().mockReturnValue('MCP Instructions'), getMcpInstructions: vi.fn().mockReturnValue('MCP Instructions'),
}), }),
isTrustedFolder: vi.fn().mockReturnValue(true), isTrustedFolder: vi.fn().mockReturnValue(true),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
} as unknown as Config; } as unknown as Config;
contextManager = new ContextManager(mockConfig); contextManager = new ContextManager(mockConfig);
@@ -81,12 +82,14 @@ describe('ContextManager', () => {
await contextManager.refresh(); await contextManager.refresh();
expect(memoryDiscovery.getGlobalMemoryPaths).toHaveBeenCalled(); expect(memoryDiscovery.getGlobalMemoryPaths).toHaveBeenCalled();
expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith([ expect(memoryDiscovery.getEnvironmentMemoryPaths).toHaveBeenCalledWith(
'/app', ['/app'],
]); ['.git'],
);
expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith( expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith(
expect.arrayContaining([...globalPaths, ...envPaths]), expect.arrayContaining([...globalPaths, ...envPaths]),
'tree', 'tree',
['.git'],
); );
expect(contextManager.getGlobalMemory()).toContain('Global Content'); expect(contextManager.getGlobalMemory()).toContain('Global Content');
@@ -172,6 +175,7 @@ describe('ContextManager', () => {
expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith( expect(memoryDiscovery.readGeminiMdFiles).toHaveBeenCalledWith(
['/home/user/.gemini/GEMINI.md', '/app/gemini.md'], ['/home/user/.gemini/GEMINI.md', '/app/gemini.md'],
'tree', 'tree',
['.git'],
); );
expect(contextManager.getEnvironmentMemory()).toContain( expect(contextManager.getEnvironmentMemory()).toContain(
'Project Content', 'Project Content',
@@ -197,6 +201,7 @@ describe('ContextManager', () => {
['/app'], ['/app'],
expect.any(Set), expect.any(Set),
expect.any(Set), expect.any(Set),
['.git'],
); );
expect(result).toMatch(/--- Context from: \/app\/src\/GEMINI\.md ---/); expect(result).toMatch(/--- Context from: \/app\/src\/GEMINI\.md ---/);
expect(result).toContain('Src Content'); expect(result).toContain('Src Content');
@@ -226,5 +231,25 @@ describe('ContextManager', () => {
expect(memoryDiscovery.loadJitSubdirectoryMemory).not.toHaveBeenCalled(); expect(memoryDiscovery.loadJitSubdirectoryMemory).not.toHaveBeenCalled();
expect(result).toBe(''); expect(result).toBe('');
}); });
it('should pass custom boundary markers from config', async () => {
const customMarkers = ['.monorepo-root', 'package.json'];
vi.mocked(mockConfig.getMemoryBoundaryMarkers).mockReturnValue(
customMarkers,
);
vi.mocked(memoryDiscovery.loadJitSubdirectoryMemory).mockResolvedValue({
files: [],
});
await contextManager.discoverContext('/app/src/file.ts', ['/app']);
expect(memoryDiscovery.loadJitSubdirectoryMemory).toHaveBeenCalledWith(
'/app/src/file.ts',
['/app'],
expect.any(Set),
expect.any(Set),
customMarkers,
);
});
}); });
}); });
+6 -3
View File
@@ -51,9 +51,10 @@ export class ContextManager {
getExtensionMemoryPaths(this.config.getExtensionLoader()), getExtensionMemoryPaths(this.config.getExtensionLoader()),
), ),
this.config.isTrustedFolder() this.config.isTrustedFolder()
? getEnvironmentMemoryPaths([ ? getEnvironmentMemoryPaths(
...this.config.getWorkspaceContext().getDirectories(), [...this.config.getWorkspaceContext().getDirectories()],
]) this.config.getMemoryBoundaryMarkers(),
)
: Promise.resolve([]), : Promise.resolve([]),
]); ]);
@@ -76,6 +77,7 @@ export class ContextManager {
const allContents = await readGeminiMdFiles( const allContents = await readGeminiMdFiles(
allPaths, allPaths,
this.config.getImportFormat(), this.config.getImportFormat(),
this.config.getMemoryBoundaryMarkers(),
); );
const loadedFilePaths = allContents const loadedFilePaths = allContents
@@ -133,6 +135,7 @@ export class ContextManager {
trustedRoots, trustedRoots,
this.loadedPaths, this.loadedPaths,
this.loadedFileIdentities, this.loadedFileIdentities,
this.config.getMemoryBoundaryMarkers(),
); );
if (result.files.length === 0) { if (result.files.length === 0) {
@@ -1269,6 +1269,96 @@ included directory memory
expect(result.files[0].path).toBe(subDirMemory); expect(result.files[0].path).toBe(subDirMemory);
expect(result.files[0].content).toBe('Content without git'); expect(result.files[0].content).toBe('Content without git');
}); });
it('should stop at a custom boundary marker instead of .git', async () => {
const rootDir = await createEmptyDir(
path.join(testRootDir, 'custom_marker'),
);
// Use a custom marker file instead of .git
await createTestFile(path.join(rootDir, '.monorepo-root'), '');
const subDir = await createEmptyDir(path.join(rootDir, 'packages/app'));
const targetFile = path.join(subDir, 'file.ts');
const rootMemory = await createTestFile(
path.join(rootDir, DEFAULT_CONTEXT_FILENAME),
'Root rules',
);
const subDirMemory = await createTestFile(
path.join(subDir, DEFAULT_CONTEXT_FILENAME),
'App rules',
);
const result = await loadJitSubdirectoryMemory(
targetFile,
[rootDir],
new Set(),
undefined,
['.monorepo-root'],
);
expect(result.files).toHaveLength(2);
expect(result.files.find((f) => f.path === rootMemory)).toBeDefined();
expect(result.files.find((f) => f.path === subDirMemory)).toBeDefined();
});
it('should support multiple boundary markers', async () => {
const rootDir = await createEmptyDir(
path.join(testRootDir, 'multi_marker'),
);
// Use a non-.git marker
await createTestFile(path.join(rootDir, 'package.json'), '{}');
const subDir = await createEmptyDir(path.join(rootDir, 'src'));
const targetFile = path.join(subDir, 'index.ts');
const rootMemory = await createTestFile(
path.join(rootDir, DEFAULT_CONTEXT_FILENAME),
'Root content',
);
const result = await loadJitSubdirectoryMemory(
targetFile,
[rootDir],
new Set(),
undefined,
['.git', 'package.json'],
);
// Should find the root because package.json is a marker
expect(result.files).toHaveLength(1);
expect(result.files[0].path).toBe(rootMemory);
});
it('should disable parent traversal when boundary markers array is empty', async () => {
const rootDir = await createEmptyDir(
path.join(testRootDir, 'empty_markers'),
);
await createEmptyDir(path.join(rootDir, '.git'));
const subDir = await createEmptyDir(path.join(rootDir, 'subdir'));
const targetFile = path.join(subDir, 'target.txt');
await createTestFile(
path.join(rootDir, DEFAULT_CONTEXT_FILENAME),
'Root content',
);
const subDirMemory = await createTestFile(
path.join(subDir, DEFAULT_CONTEXT_FILENAME),
'Subdir content',
);
const result = await loadJitSubdirectoryMemory(
targetFile,
[rootDir],
new Set(),
undefined,
[],
);
// With empty markers, no project root is found so the trusted root
// is used as the ceiling. Traversal still finds files between the
// target path and the trusted root.
expect(result.files).toHaveLength(2);
expect(result.files.find((f) => f.path === subDirMemory)).toBeDefined();
});
}); });
it('refreshServerHierarchicalMemory should refresh memory and update config', async () => { it('refreshServerHierarchicalMemory should refresh memory and update config', async () => {
@@ -1341,6 +1431,7 @@ included directory memory
getImportFormat: vi.fn().mockReturnValue('tree'), getImportFormat: vi.fn().mockReturnValue('tree'),
getFileFilteringOptions: vi.fn().mockReturnValue(undefined), getFileFilteringOptions: vi.fn().mockReturnValue(undefined),
getDiscoveryMaxDirs: vi.fn().mockReturnValue(200), getDiscoveryMaxDirs: vi.fn().mockReturnValue(200),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
setUserMemory: vi.fn(), setUserMemory: vi.fn(),
setGeminiMdFileCount: vi.fn(), setGeminiMdFileCount: vi.fn(),
setGeminiMdFilePaths: vi.fn(), setGeminiMdFilePaths: vi.fn(),
+64 -37
View File
@@ -146,41 +146,54 @@ export async function deduplicatePathsByFileIdentity(
}; };
} }
async function findProjectRoot(startDir: string): Promise<string | null> { async function findProjectRoot(
startDir: string,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<string | null> {
if (boundaryMarkers.length === 0) {
return null;
}
let currentDir = normalizePath(startDir); let currentDir = normalizePath(startDir);
while (true) { while (true) {
const gitPath = path.join(currentDir, '.git'); for (const marker of boundaryMarkers) {
try { // Sanitize: skip markers with path traversal or absolute paths
// Check for existence only — .git can be a directory (normal repos) if (path.isAbsolute(marker) || marker.includes('..')) {
// or a file (submodules / worktrees). continue;
await fs.access(gitPath); }
return currentDir; const markerPath = path.join(currentDir, marker);
} catch (error: unknown) { try {
// Don't log ENOENT errors as they're expected when .git doesn't exist // Check for existence only — marker can be a directory (normal repos)
// Also don't log errors in test environments, which often have mocked fs // or a file (submodules / worktrees).
const isENOENT = await fs.access(markerPath);
typeof error === 'object' && return currentDir;
error !== null && } catch (error: unknown) {
'code' in error && // Don't log ENOENT errors as they're expected when marker doesn't exist
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // Also don't log errors in test environments, which often have mocked fs
(error as { code: string }).code === 'ENOENT'; const isENOENT =
typeof error === 'object' &&
// Only log unexpected errors in non-test environments error !== null &&
// process.env['NODE_ENV'] === 'test' or VITEST are common test indicators 'code' in error &&
const isTestEnv =
process.env['NODE_ENV'] === 'test' || process.env['VITEST'];
if (!isENOENT && !isTestEnv) {
if (typeof error === 'object' && error !== null && 'code' in error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const fsError = error as { code: string; message: string }; (error as { code: string }).code === 'ENOENT';
logger.warn(
`Error checking for .git at ${gitPath}: ${fsError.message}`, // Only log unexpected errors in non-test environments
); // process.env['NODE_ENV'] === 'test' or VITEST are common test indicators
} else { const isTestEnv =
logger.warn( process.env['NODE_ENV'] === 'test' || process.env['VITEST'];
`Non-standard error checking for .git at ${gitPath}: ${String(error)}`,
); if (!isENOENT && !isTestEnv) {
if (typeof error === 'object' && error !== null && 'code' in error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const fsError = error as { code: string; message: string };
logger.warn(
`Error checking for ${marker} at ${markerPath}: ${fsError.message}`,
);
} else {
logger.warn(
`Non-standard error checking for ${marker} at ${markerPath}: ${String(error)}`,
);
}
} }
} }
} }
@@ -200,6 +213,7 @@ async function getGeminiMdFilePathsInternal(
folderTrust: boolean, folderTrust: boolean,
fileFilteringOptions: FileFilteringOptions, fileFilteringOptions: FileFilteringOptions,
maxDirs: number, maxDirs: number,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<{ global: string[]; project: string[] }> { ): Promise<{ global: string[]; project: string[] }> {
const dirs = new Set<string>([ const dirs = new Set<string>([
...includeDirectoriesToReadGemini, ...includeDirectoriesToReadGemini,
@@ -222,6 +236,7 @@ async function getGeminiMdFilePathsInternal(
folderTrust, folderTrust,
fileFilteringOptions, fileFilteringOptions,
maxDirs, maxDirs,
boundaryMarkers,
), ),
); );
@@ -253,6 +268,7 @@ async function getGeminiMdFilePathsInternalForEachDir(
folderTrust: boolean, folderTrust: boolean,
fileFilteringOptions: FileFilteringOptions, fileFilteringOptions: FileFilteringOptions,
maxDirs: number, maxDirs: number,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<{ global: string[]; project: string[] }> { ): Promise<{ global: string[]; project: string[] }> {
const globalPaths = new Set<string>(); const globalPaths = new Set<string>();
const projectPaths = new Set<string>(); const projectPaths = new Set<string>();
@@ -289,7 +305,7 @@ async function getGeminiMdFilePathsInternalForEachDir(
resolvedCwd, resolvedCwd,
); );
const projectRoot = await findProjectRoot(resolvedCwd); const projectRoot = await findProjectRoot(resolvedCwd, boundaryMarkers);
debugLogger.debug( debugLogger.debug(
'[DEBUG] [MemoryDiscovery] Determined project root:', '[DEBUG] [MemoryDiscovery] Determined project root:',
projectRoot ?? 'None', projectRoot ?? 'None',
@@ -356,6 +372,7 @@ async function getGeminiMdFilePathsInternalForEachDir(
export async function readGeminiMdFiles( export async function readGeminiMdFiles(
filePaths: string[], filePaths: string[],
importFormat: 'flat' | 'tree' = 'tree', importFormat: 'flat' | 'tree' = 'tree',
boundaryMarkers: readonly string[] = ['.git'],
): Promise<GeminiFileContent[]> { ): Promise<GeminiFileContent[]> {
// Process files in parallel with concurrency limit to prevent EMFILE errors // Process files in parallel with concurrency limit to prevent EMFILE errors
const CONCURRENT_LIMIT = 20; // Higher limit for file reads as they're typically faster const CONCURRENT_LIMIT = 20; // Higher limit for file reads as they're typically faster
@@ -376,6 +393,7 @@ export async function readGeminiMdFiles(
undefined, undefined,
undefined, undefined,
importFormat, importFormat,
boundaryMarkers,
); );
debugLogger.debug( debugLogger.debug(
'[DEBUG] [MemoryDiscovery] Successfully read and processed imports:', '[DEBUG] [MemoryDiscovery] Successfully read and processed imports:',
@@ -481,13 +499,14 @@ export function getExtensionMemoryPaths(
export async function getEnvironmentMemoryPaths( export async function getEnvironmentMemoryPaths(
trustedRoots: string[], trustedRoots: string[],
boundaryMarkers: readonly string[] = ['.git'],
): Promise<string[]> { ): Promise<string[]> {
const allPaths = new Set<string>(); const allPaths = new Set<string>();
// Trusted Roots Upward Traversal (Parallelized) // Trusted Roots Upward Traversal (Parallelized)
const traversalPromises = trustedRoots.map(async (root) => { const traversalPromises = trustedRoots.map(async (root) => {
const resolvedRoot = normalizePath(root); const resolvedRoot = normalizePath(root);
const gitRoot = await findProjectRoot(resolvedRoot); const gitRoot = await findProjectRoot(resolvedRoot, boundaryMarkers);
const ceiling = gitRoot ? normalizePath(gitRoot) : resolvedRoot; const ceiling = gitRoot ? normalizePath(gitRoot) : resolvedRoot;
debugLogger.debug( debugLogger.debug(
'[DEBUG] [MemoryDiscovery] Loading environment memory for trusted root:', '[DEBUG] [MemoryDiscovery] Loading environment memory for trusted root:',
@@ -597,6 +616,7 @@ export async function loadServerHierarchicalMemory(
importFormat: 'flat' | 'tree' = 'tree', importFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions, fileFilteringOptions?: FileFilteringOptions,
maxDirs: number = 200, maxDirs: number = 200,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<LoadServerHierarchicalMemoryResponse> { ): Promise<LoadServerHierarchicalMemoryResponse> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks. // FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = normalizePath( const realCwd = normalizePath(
@@ -629,6 +649,7 @@ export async function loadServerHierarchicalMemory(
folderTrust, folderTrust,
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
maxDirs, maxDirs,
boundaryMarkers,
), ),
Promise.resolve(getExtensionMemoryPaths(extensionLoader)), Promise.resolve(getExtensionMemoryPaths(extensionLoader)),
]); ]);
@@ -669,7 +690,11 @@ export async function loadServerHierarchicalMemory(
} }
// 2. GATHER: Read all files in parallel // 2. GATHER: Read all files in parallel
const allContents = await readGeminiMdFiles(allFilePaths, importFormat); const allContents = await readGeminiMdFiles(
allFilePaths,
importFormat,
boundaryMarkers,
);
const contentsMap = new Map(allContents.map((c) => [c.filePath, c])); const contentsMap = new Map(allContents.map((c) => [c.filePath, c]));
// 3. CATEGORIZE: Back into Global, Project, Extension // 3. CATEGORIZE: Back into Global, Project, Extension
@@ -707,6 +732,7 @@ export async function refreshServerHierarchicalMemory(config: Config) {
config.getImportFormat(), config.getImportFormat(),
config.getFileFilteringOptions(), config.getFileFilteringOptions(),
config.getDiscoveryMaxDirs(), config.getDiscoveryMaxDirs(),
config.getMemoryBoundaryMarkers(),
); );
const mcpInstructions = const mcpInstructions =
config.getMcpClientManager()?.getMcpInstructions() || ''; config.getMcpClientManager()?.getMcpInstructions() || '';
@@ -728,6 +754,7 @@ export async function loadJitSubdirectoryMemory(
trustedRoots: string[], trustedRoots: string[],
alreadyLoadedPaths: Set<string>, alreadyLoadedPaths: Set<string>,
alreadyLoadedIdentities?: Set<string>, alreadyLoadedIdentities?: Set<string>,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<MemoryLoadResult> { ): Promise<MemoryLoadResult> {
const resolvedTarget = normalizePath(targetPath); const resolvedTarget = normalizePath(targetPath);
let bestRoot: string | null = null; let bestRoot: string | null = null;
@@ -760,7 +787,7 @@ export async function loadJitSubdirectoryMemory(
// Find the git root to use as the traversal ceiling. // Find the git root to use as the traversal ceiling.
// If no git root exists, fall back to the trusted root as the ceiling. // If no git root exists, fall back to the trusted root as the ceiling.
const gitRoot = await findProjectRoot(bestRoot); const gitRoot = await findProjectRoot(bestRoot, boundaryMarkers);
const resolvedCeiling = gitRoot ? normalizePath(gitRoot) : bestRoot; const resolvedCeiling = gitRoot ? normalizePath(gitRoot) : bestRoot;
debugLogger.debug( debugLogger.debug(
@@ -850,7 +877,7 @@ export async function loadJitSubdirectoryMemory(
JSON.stringify(newPaths), JSON.stringify(newPaths),
); );
const contents = await readGeminiMdFiles(newPaths, 'tree'); const contents = await readGeminiMdFiles(newPaths, 'tree', boundaryMarkers);
return { return {
files: contents files: contents
@@ -48,18 +48,31 @@ export interface ProcessImportsResult {
importTree: MemoryFile; importTree: MemoryFile;
} }
// Helper to find the project root (looks for .git directory or file for worktrees) // Helper to find the project root (looks for boundary marker directories/files)
async function findProjectRoot(startDir: string): Promise<string> { async function findProjectRoot(
startDir: string,
boundaryMarkers: readonly string[] = ['.git'],
): Promise<string> {
if (boundaryMarkers.length === 0) {
return path.resolve(startDir);
}
let currentDir = path.resolve(startDir); let currentDir = path.resolve(startDir);
while (true) { while (true) {
const gitPath = path.join(currentDir, '.git'); for (const marker of boundaryMarkers) {
try { // Sanitize: skip markers with path traversal or absolute paths
// Check for existence only — .git can be a directory (normal repos) if (path.isAbsolute(marker) || marker.includes('..')) {
// or a file (submodules / worktrees). continue;
await fs.access(gitPath); }
return currentDir; const markerPath = path.join(currentDir, marker);
} catch { try {
// .git not found, continue to parent // Check for existence only — marker can be a directory (normal repos)
// or a file (submodules / worktrees).
await fs.access(markerPath);
return currentDir;
} catch {
// marker not found, continue
}
} }
const parentDir = path.dirname(currentDir); const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) { if (parentDir === currentDir) {
@@ -68,7 +81,7 @@ async function findProjectRoot(startDir: string): Promise<string> {
} }
currentDir = parentDir; currentDir = parentDir;
} }
// Fallback to startDir if .git not found // Fallback to startDir if no marker found
return path.resolve(startDir); return path.resolve(startDir);
} }
@@ -185,9 +198,10 @@ export async function processImports(
}, },
projectRoot?: string, projectRoot?: string,
importFormat: 'flat' | 'tree' = 'tree', importFormat: 'flat' | 'tree' = 'tree',
boundaryMarkers: readonly string[] = ['.git'],
): Promise<ProcessImportsResult> { ): Promise<ProcessImportsResult> {
if (!projectRoot) { if (!projectRoot) {
projectRoot = await findProjectRoot(basePath); projectRoot = await findProjectRoot(basePath, boundaryMarkers);
} }
if (importState.currentDepth >= importState.maxDepth) { if (importState.currentDepth >= importState.maxDepth) {
@@ -346,6 +360,7 @@ export async function processImports(
newImportState, newImportState,
projectRoot, projectRoot,
importFormat, importFormat,
boundaryMarkers,
); );
result += `<!-- Imported from: ${importPath} -->\n${imported.content}\n<!-- End of import from: ${importPath} -->`; result += `<!-- Imported from: ${importPath} -->\n${imported.content}\n<!-- End of import from: ${importPath} -->`;
imports.push(imported.importTree); imports.push(imported.importTree);
+10
View File
@@ -2272,6 +2272,16 @@
"default": 200, "default": 200,
"type": "number" "type": "number"
}, },
"memoryBoundaryMarkers": {
"title": "Memory Boundary Markers",
"description": "File or directory names that mark the boundary for GEMINI.md discovery. The upward traversal stops at the first directory containing any of these markers. An empty array disables parent traversal.",
"markdownDescription": "File or directory names that mark the boundary for GEMINI.md discovery. The upward traversal stops at the first directory containing any of these markers. An empty array disables parent traversal.\n\n- Category: `Context`\n- Requires restart: `yes`\n- Default: `[\n \".git\"\n]`",
"default": [".git"],
"type": "array",
"items": {
"type": "string"
}
},
"includeDirectories": { "includeDirectories": {
"title": "Include Directories", "title": "Include Directories",
"description": "Additional directories to include in the workspace context. Missing directories will be skipped with a warning.", "description": "Additional directories to include in the workspace context. Missing directories will be skipped with a warning.",