mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat(context): add configurable memoryBoundaryMarkers setting (#24020)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user