diff --git a/docs/users/features/memory.md b/docs/users/features/memory.md index cdd7951c7d4..19aeebd0c15 100644 --- a/docs/users/features/memory.md +++ b/docs/users/features/memory.md @@ -24,15 +24,32 @@ Don't include things Qwen can figure out by reading your code. QWEN.md works bes ### Where to create QWEN.md -| File | Who it applies to | -| ----------------------------- | --------------------------------------------- | -| `~/.qwen/QWEN.md` | You, across all your projects | -| `QWEN.md` in the project root | Your whole team (commit it to source control) | +| File | Who it applies to | +| ----------------------------- | ------------------------------------------------ | +| `~/.qwen/QWEN.md` | You, across all your projects | +| `QWEN.md` in the project root | Your whole team (commit it to source control) | +| `.qwen/QWEN.local.md` | Only you, only in this project (keep out of git) | -You can have both. Qwen loads all QWEN.md files it finds when you start a session — your personal one plus any in the project. +You can have any combination of these. Qwen loads all of them when you start a session. If your repository already has an `AGENTS.md` file for other AI tools, Qwen reads that too. No need to duplicate instructions. +#### When to use `.qwen/QWEN.local.md` + +Use it for **project-specific but personal** instructions — things that belong to this project but shouldn't be shared with the team: + +- Your own cluster ID, container registry namespace, or cloud account +- A personal debug command that hardcodes your local environment +- Notes you want Qwen to know about your work-in-progress, but not commit + +It loads **after** the shared project `QWEN.md`, so your local instructions can supplement or override the team's. + +**You must gitignore it yourself.** Although `.qwen/` is often treated as a local directory, qwen-code does not generate a `.gitignore` for you, and some projects commit `.qwen/settings.json`. Add this line to your `.gitignore` (or to your global git ignore): + +``` +.qwen/QWEN.local.md +``` + ### Generate one automatically with `/init` Run `/init` and Qwen will analyze your codebase to create a starter QWEN.md with build commands, test instructions, and conventions it finds. If one already exists, it suggests additions instead of overwriting. diff --git a/packages/core/src/memory/const.ts b/packages/core/src/memory/const.ts index 7b23ebaf74d..37ac5490881 100644 --- a/packages/core/src/memory/const.ts +++ b/packages/core/src/memory/const.ts @@ -6,6 +6,26 @@ export const DEFAULT_CONTEXT_FILENAME = 'QWEN.md'; export const AGENT_CONTEXT_FILENAME = 'AGENTS.md'; +/** + * Per-developer, project-scoped context file. Anchored at + * `/.qwen/QWEN.local.md`. Intended to be gitignored so each + * developer can keep personal instructions (local cluster IDs, account + * names, paths) without polluting the shared project `QWEN.md` or the + * global `~/.qwen/QWEN.md`. + * + * Unlike `DEFAULT_CONTEXT_FILENAME` / `AGENT_CONTEXT_FILENAME`, this name is + * NOT part of the hierarchical upward-search list — it is loaded from a + * single fixed slot, after all other project-level context files, so it can + * supplement or override shared instructions. + * + * Project root is the nearest ancestor containing a `.git` directory OR a + * `.git` file (the latter marks git worktrees and submodules). If no + * project root can be found, the slot is skipped — the loader does NOT + * fall back to cwd, because that would turn a "single fixed slot" into a + * per-cwd file and (when cwd is the home directory) would collide with + * the global Qwen dir at `~/.qwen/`. + */ +export const LOCAL_CONTEXT_FILENAME = 'QWEN.local.md'; export const MEMORY_SECTION_HEADER = '## Qwen Added Memories'; // This variable will hold the currently configured filename for context files. diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index a61138ce246..c69015c6349 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -536,4 +536,328 @@ describe('loadServerHierarchicalMemory', () => { expect(parentOccurrences).toBe(1); expect(childOccurrences).toBe(1); }); + + describe('QWEN.local.md (project-local context file)', () => { + // The local-context-file slot is anchored at `/.qwen/`, where + // projectRoot is the nearest ancestor containing a `.git` directory OR a + // `.git` file (the latter is how git worktrees and submodules are marked). + // Most tests in this block use the directory form; a few below cover the + // file form and the no-project-root case explicitly. + beforeEach(async () => { + await createEmptyDir(path.join(projectRoot, '.git')); + }); + + it('loads .qwen/QWEN.local.md from project root when present', async () => { + const localFile = await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'local context content', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(1); + expect(result.memoryContent).toContain( + `--- Context from: ${path.relative(cwd, localFile)} ---\nlocal context content`, + ); + }); + + it('orders QWEN.local.md after the project-root QWEN.md', async () => { + const projectFile = await createTestFile( + path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), + 'shared project context', + ); + const localFile = await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'local override', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(2); + const projectIdx = result.memoryContent.indexOf( + path.relative(cwd, projectFile), + ); + const localIdx = result.memoryContent.indexOf( + path.relative(cwd, localFile), + ); + expect(projectIdx).toBeGreaterThanOrEqual(0); + expect(localIdx).toBeGreaterThan(projectIdx); + }); + + it('orders QWEN.local.md after upward-traversed CWD QWEN.md', async () => { + const projectFile = await createTestFile( + path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), + 'project root memory', + ); + const cwdFile = await createTestFile( + path.join(cwd, DEFAULT_CONTEXT_FILENAME), + 'cwd memory', + ); + const localFile = await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'local memory', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(3); + const projectIdx = result.memoryContent.indexOf( + path.relative(cwd, projectFile), + ); + const cwdIdx = result.memoryContent.indexOf(path.relative(cwd, cwdFile)); + const localIdx = result.memoryContent.indexOf( + path.relative(cwd, localFile), + ); + expect(projectIdx).toBeGreaterThanOrEqual(0); + expect(cwdIdx).toBeGreaterThan(projectIdx); + expect(localIdx).toBeGreaterThan(cwdIdx); + }); + + it('silently ignores absent .qwen/QWEN.local.md', async () => { + await createTestFile( + path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), + 'project content', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(1); + expect(result.memoryContent).toContain('project content'); + expect(result.memoryContent).not.toContain('QWEN.local.md'); + }); + + it('does not load QWEN.local.md from untrusted workspaces', async () => { + await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'local content', + ); + + const { fileCount, memoryContent } = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + false, // untrusted + ); + + expect(fileCount).toBe(0); + expect(memoryContent).not.toContain('local content'); + }); + + it('does not load QWEN.local.md in explicit-only mode', async () => { + await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'local content', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + 'tree', + [], + { explicitOnly: true }, + ); + + expect(result.fileCount).toBe(0); + expect(result.memoryContent).not.toContain('local content'); + }); + + it('does not search .qwen/QWEN.local.md in CWD subdirectories', async () => { + // A `.qwen/QWEN.local.md` placed inside a nested directory (not the + // project root) must NOT be picked up — the slot is single, fixed, + // and lives at /.qwen/QWEN.local.md. + await createTestFile( + path.join(cwd, QWEN_DIR, 'QWEN.local.md'), + 'misplaced local content', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(0); + expect(result.memoryContent).not.toContain('misplaced local content'); + }); + + it('loads QWEN.local.md even when no project QWEN.md exists', async () => { + const localFile = await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'standalone local', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(1); + expect(result.memoryContent).toContain( + `--- Context from: ${path.relative(cwd, localFile)} ---\nstandalone local`, + ); + }); + + it('loads QWEN.local.md when project root is marked by a .git FILE (worktree / submodule layout)', async () => { + // Git worktrees and submodules mark the repo root with a `.git` file + // (containing `gitdir: `), not a `.git` directory. The loader + // must treat that as a valid project root, otherwise `` is used + // as a silent fallback and the documented project-root slot never + // loads. Replace the directory created by beforeEach with a file. + await fsPromises.rm(path.join(projectRoot, '.git'), { + recursive: true, + force: true, + }); + await fsPromises.writeFile( + path.join(projectRoot, '.git'), + 'gitdir: /elsewhere/worktrees/feature/.git\n', + ); + + const localFile = await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'worktree local', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(1); + expect(result.memoryContent).toContain( + `--- Context from: ${path.relative(cwd, localFile)} ---\nworktree local`, + ); + }); + + it('skips QWEN.local.md when no project root can be found (no .git ancestor)', async () => { + // Without a project root, falling back to cwd would silently turn the + // single fixed slot into a per-cwd file — opposite of the design. + // Pin the "skip" behavior so a future regression doesn't reintroduce + // the fallback. + await fsPromises.rm(path.join(projectRoot, '.git'), { + recursive: true, + force: true, + }); + + await createTestFile( + path.join(cwd, QWEN_DIR, 'QWEN.local.md'), + 'cwd-anchored local that must not load', + ); + await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'projectRoot-anchored local that must not load either', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(0); + expect(result.memoryContent).not.toContain( + 'cwd-anchored local that must not load', + ); + expect(result.memoryContent).not.toContain( + 'projectRoot-anchored local that must not load either', + ); + }); + + it('skips QWEN.local.md when cwd === homedir without .git (avoids global-dir collision)', async () => { + // When cwd is the home directory and there is no `.git` there, the + // would-be slot path resolves to `/.qwen/QWEN.local.md` — + // i.e. inside the GLOBAL Qwen dir. Loading that as a project-local + // override is wrong: there is no project. Pin the "skip" behavior. + await fsPromises.rm(path.join(projectRoot, '.git'), { + recursive: true, + force: true, + }); + await createTestFile( + path.join(homedir, QWEN_DIR, 'QWEN.local.md'), + 'do not promote this to project-local', + ); + + const result = await loadServerHierarchicalMemory( + homedir, // cwd === homedir + [], + new FileDiscoveryService(homedir), + [], + DEFAULT_FOLDER_TRUST, + ); + + // Allowed: global QWEN.md / AGENTS.md in ~/.qwen/ may still load via + // the existing global-discovery path. The assertion here is narrow — + // the LOCAL slot specifically must not have been loaded. + expect(result.memoryContent).not.toContain( + 'do not promote this to project-local', + ); + }); + + it('dedupes when an extension registers the local slot path explicitly', async () => { + // The hierarchical scan iterates `getAllGeminiMdFilenames()` + // (QWEN.md / AGENTS.md) and never produces a `QWEN.local.md` path, + // so the dedup guard in the slot loader looks unreachable in + // production paths. It IS reachable, though, via + // `extensionContextFilePaths`: an extension may register the slot + // path explicitly, in which case the hierarchical scan picks it up + // via the extension-paths append. The dedup guard prevents the + // slot loader from then appending the same file a second time + // (double content + inflated fileCount). Pin that behavior. + const localFile = await createTestFile( + path.join(projectRoot, QWEN_DIR, 'QWEN.local.md'), + 'slot content only once', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [localFile], // extension explicitly registers the slot path + DEFAULT_FOLDER_TRUST, + ); + + expect(result.fileCount).toBe(1); + const occurrences = ( + result.memoryContent.match(/slot content only once/g) ?? [] + ).length; + expect(occurrences).toBe(1); + }); + }); }); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index ef7f43c6d49..726dfa62f7d 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -8,12 +8,16 @@ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as path from 'node:path'; import { homedir } from 'node:os'; -import { getAllGeminiMdFilenames } from '../memory/const.js'; +import { + getAllGeminiMdFilenames, + LOCAL_CONTEXT_FILENAME, +} from '../memory/const.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { processImports } from './memoryImportProcessor.js'; import { QWEN_DIR } from './paths.js'; import { Storage } from '../config/storage.js'; import { createDebugLogger } from './debugLogger.js'; +import { findProjectRoot } from './projectRoot.js'; import { loadRules, type RuleFile } from './rulesDiscovery.js'; const logger = createDebugLogger('MEMORY_DISCOVERY'); @@ -23,50 +27,6 @@ interface GeminiFileContent { content: string | null; } -async function findProjectRoot(startDir: string): Promise { - let currentDir = path.resolve(startDir); - while (true) { - const gitPath = path.join(currentDir, '.git'); - try { - const stats = await fs.lstat(gitPath); - if (stats.isDirectory()) { - return currentDir; - } - } catch (error: unknown) { - // Don't log ENOENT errors as they're expected when .git doesn't exist - // Also don't log errors in test environments, which often have mocked fs - const isENOENT = - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code: string }).code === 'ENOENT'; - - // Only log unexpected errors in non-test environments - // process.env['NODE_ENV'] === 'test' or VITEST are common test indicators - const isTestEnv = - process.env['NODE_ENV'] === 'test' || process.env['VITEST']; - - if (!isENOENT && !isTestEnv) { - if (typeof error === 'object' && error !== null && 'code' in error) { - const fsError = error as { code: string; message: string }; - logger.warn( - `Error checking for .git directory at ${gitPath}: ${fsError.message}`, - ); - } else { - logger.warn( - `Non-standard error checking for .git directory at ${gitPath}: ${String(error)}`, - ); - } - } - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - return null; - } - currentDir = parentDir; - } -} - async function getGeminiMdFilePathsInternal( currentWorkingDirectory: string, includeDirectoriesToReadGemini: readonly string[], @@ -373,6 +333,43 @@ export async function loadServerHierarchicalMemory( implicitDiscoveryEnabled, ); + // Resolve project root once — needed both for the QWEN.local.md slot + // (below) and for rules discovery (further down). + const resolvedCwd = path.resolve(currentWorkingDirectory); + const foundRoot = await findProjectRoot(resolvedCwd); + const effectiveRoot = foundRoot ?? resolvedCwd; + + // Append the per-developer local context file slot: + // `/.qwen/QWEN.local.md`. Loaded after all hierarchical + // QWEN.md / AGENTS.md files so local instructions can supplement or + // override shared ones. Same trust + explicit-only gating as the rest + // of the project-level discovery. + // + // Requires a real project root (`foundRoot`, not the `resolvedCwd` + // fallback). Without that gate, two failure modes appear: + // * Deep cwd in a non-git workspace turns the slot into a per-cwd + // file, breaking the "single fixed slot" invariant. + // * `cwd === homedir` resolves the slot path to `~/.qwen/QWEN.local.md`, + // colliding with the global Qwen directory. + if (implicitDiscoveryEnabled && folderTrust && foundRoot) { + const localContextPath = path.join( + foundRoot, + QWEN_DIR, + LOCAL_CONTEXT_FILENAME, + ); + try { + await fs.access(localContextPath, fsSync.constants.R_OK); + if (!filePaths.includes(localContextPath)) { + filePaths.push(localContextPath); + logger.debug( + `Found readable local ${LOCAL_CONTEXT_FILENAME}: ${localContextPath}`, + ); + } + } catch { + // Not found, which is the common case — silently skip. + } + } + let combinedInstructions = ''; let fileCount = 0; @@ -386,17 +383,16 @@ export async function loadServerHierarchicalMemory( // Only count files that match configured memory filenames (e.g., QWEN.md), // excluding system context files like output-language.md - const memoryFilenames = new Set(getAllGeminiMdFilenames()); + const memoryFilenames = new Set([ + ...getAllGeminiMdFilenames(), + LOCAL_CONTEXT_FILENAME, + ]); fileCount = contentsWithPaths.filter((item) => memoryFilenames.has(path.basename(item.filePath)), ).length; } - // Load path-based context rules from .qwen/rules/ directories - const resolvedCwd = path.resolve(currentWorkingDirectory); - const foundRoot = await findProjectRoot(resolvedCwd); - const effectiveRoot = foundRoot ?? resolvedCwd; - + // Load path-based context rules from .qwen/rules/ directories. const { content: rulesContent, ruleCount, diff --git a/packages/core/src/utils/memoryImportProcessor.ts b/packages/core/src/utils/memoryImportProcessor.ts index 0b48a3a1bf5..fe4c22d38c2 100644 --- a/packages/core/src/utils/memoryImportProcessor.ts +++ b/packages/core/src/utils/memoryImportProcessor.ts @@ -9,6 +9,7 @@ import * as path from 'node:path'; import { isSubpath } from './paths.js'; import { marked, type Token } from 'marked'; import { createDebugLogger } from './debugLogger.js'; +import { findProjectRoot } from './projectRoot.js'; const logger = createDebugLogger('IMPORT_PROCESSOR'); @@ -38,29 +39,11 @@ export interface ProcessImportsResult { importTree: MemoryFile; } -// Helper to find the project root (looks for .git directory) -async function findProjectRoot(startDir: string): Promise { - let currentDir = path.resolve(startDir); - while (true) { - const gitPath = path.join(currentDir, '.git'); - try { - const stats = await fs.lstat(gitPath); - if (stats.isDirectory()) { - return currentDir; - } - } catch { - // .git not found, continue to parent - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - // Reached filesystem root - break; - } - currentDir = parentDir; - } - // Fallback to startDir if .git not found - return path.resolve(startDir); -} +// `findProjectRoot` now lives in `./projectRoot.ts` and is shared with +// memoryDiscovery. It returns `string | null`; `processImports` below +// preserves the previous "fall back to startDir" contract at the call +// site, so behavior for code paths that don't care about the difference +// (a non-git scratch dir for example) is unchanged. // Add a type guard for error objects function hasMessage(err: unknown): err is { message: string } { @@ -209,7 +192,10 @@ export async function processImports( importFormat: 'flat' | 'tree' = 'tree', ): Promise { if (!projectRoot) { - projectRoot = await findProjectRoot(basePath); + // Preserve the previous local helper's contract: if no `.git` + // ancestor exists, fall back to the absolute basePath so + // `@`-imports can still resolve relatively. + projectRoot = (await findProjectRoot(basePath)) ?? path.resolve(basePath); } if (importState.currentDepth >= importState.maxDepth) { diff --git a/packages/core/src/utils/projectRoot.test.ts b/packages/core/src/utils/projectRoot.test.ts new file mode 100644 index 00000000000..d29295a7181 --- /dev/null +++ b/packages/core/src/utils/projectRoot.test.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fsPromises from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { findProjectRoot } from './projectRoot.js'; + +describe('findProjectRoot', () => { + let testRootDir: string; + let projectRoot: string; + let subDir: string; + + beforeEach(async () => { + testRootDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'find-project-root-'), + ); + projectRoot = path.join(testRootDir, 'project'); + subDir = path.join(projectRoot, 'src', 'nested'); + await fsPromises.mkdir(subDir, { recursive: true }); + }); + + afterEach(async () => { + await fsPromises.rm(testRootDir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 10, + }); + }); + + it('returns the project root when .git is a directory (normal clone)', async () => { + await fsPromises.mkdir(path.join(projectRoot, '.git')); + + expect(await findProjectRoot(subDir)).toBe(projectRoot); + expect(await findProjectRoot(projectRoot)).toBe(projectRoot); + }); + + it('returns the project root when .git is a FILE (git worktree / submodule layout)', async () => { + // Git worktrees and submodules mark the repo root with a `.git` file + // containing `gitdir: `. The old implementation only checked + // `stats.isDirectory()` and silently returned null here — the bug + // that prompted the extraction. + await fsPromises.writeFile( + path.join(projectRoot, '.git'), + 'gitdir: /elsewhere/worktrees/feature/.git\n', + ); + + expect(await findProjectRoot(subDir)).toBe(projectRoot); + expect(await findProjectRoot(projectRoot)).toBe(projectRoot); + }); + + it('returns null when no .git ancestor exists', async () => { + // No .git anywhere — neither directory nor file. + expect(await findProjectRoot(subDir)).toBeNull(); + }); + + it('walks up past intermediate directories without .git', async () => { + // Only the outermost has .git; intermediates do not. + await fsPromises.mkdir(path.join(projectRoot, '.git')); + const deep = path.join(projectRoot, 'a', 'b', 'c', 'd'); + await fsPromises.mkdir(deep, { recursive: true }); + + expect(await findProjectRoot(deep)).toBe(projectRoot); + }); + + it('treats a .git symlink to a directory as a project root', async () => { + // Edge: some setups symlink .git. lstat would NOT follow the link, + // so this pins the behavior we get with the directory-or-file shape: + // a symlink to a directory should still be recognized via the file + // branch (lstat reports it as a symlink, which is neither — so this + // documents the current behavior, not a guarantee). + const target = path.join(testRootDir, 'real-git'); + await fsPromises.mkdir(target); + await fsPromises.symlink(target, path.join(projectRoot, '.git')); + + // Symlinks aren't directories or regular files under lstat. Document + // that we do NOT chase them — caller would see null and fall back. + // If this assertion ever needs to flip, do it deliberately. + expect(await findProjectRoot(projectRoot)).toBeNull(); + }); +}); diff --git a/packages/core/src/utils/projectRoot.ts b/packages/core/src/utils/projectRoot.ts new file mode 100644 index 00000000000..4d84a9c2a83 --- /dev/null +++ b/packages/core/src/utils/projectRoot.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { createDebugLogger } from './debugLogger.js'; + +const logger = createDebugLogger('PROJECT_ROOT'); + +/** + * Walk up from `startDir` looking for the nearest ancestor that contains a + * `.git` entry, and return that ancestor's path. Returns `null` if no + * ancestor up to the filesystem root has `.git`. + * + * `.git` is a directory in a normal clone but a regular file (containing + * `gitdir: `) in git worktrees and submodules. Both shapes mark a + * repo root — this helper accepts either, so callers don't silently break + * for worktree / submodule users. + * + * Symlinks are intentionally not chased: `lstat` reports them as + * `isSymbolicLink()`, which is neither a directory nor a regular file, so + * the walk continues past them. That preserves the behavior the previous + * private copies in `memoryDiscovery.ts` and `memoryImportProcessor.ts` + * had. + */ +export async function findProjectRoot( + startDir: string, +): Promise { + let currentDir = path.resolve(startDir); + while (true) { + const gitPath = path.join(currentDir, '.git'); + try { + const stats = await fs.lstat(gitPath); + if (stats.isDirectory() || stats.isFile()) { + return currentDir; + } + } catch (error: unknown) { + // ENOENT is the expected case while walking up — don't log it. + // Tests often mock fs in ways that throw non-ENOENT errors; stay + // quiet there too. + const isENOENT = + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: string }).code === 'ENOENT'; + + const isTestEnv = + process.env['NODE_ENV'] === 'test' || process.env['VITEST']; + + if (!isENOENT && !isTestEnv) { + if (typeof error === 'object' && error !== null && 'code' in error) { + const fsError = error as { code: string; message: string }; + logger.warn( + `Error checking for .git at ${gitPath}: ${fsError.message}`, + ); + } else { + logger.warn( + `Non-standard error checking for .git at ${gitPath}: ${String( + error, + )}`, + ); + } + } + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +}