From a56d9e6941ce7233a0fb9c7f5e1c225a50c97b36 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 8 Jun 2026 19:31:50 -0400 Subject: [PATCH] feat(directory): CODEGRAPH_DIR env var to override the index dir name (#636) (#741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two environments that share one working tree — most concretely Windows and WSL — can't safely share a single `.codegraph/`: the daemon lockfile records a platform-specific pid + socket (named pipe vs Unix socket), and SQLite locking across the WSL2/Windows filesystem boundary is unreliable, so two daemons over one index risks corruption. Add a `CODEGRAPH_DIR` env var (default `.codegraph`) that overrides the per-project data directory name, so each environment keeps its own index in the same tree (e.g. `CODEGRAPH_DIR=.codegraph-win` on Windows). The name is resolved live and validated (rejects separators / `..` / absolute, falling back to the default with a one-time stderr warning). Indexing and file-watching now skip ANY `.codegraph-*` sibling so neither side trips over the other's data. Routes the previously-hardcoded `.codegraph` literals (db path, lockfile, error log, watcher ignore, file-scan skip, installer) through the resolver. No extraction-version bump — index content is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + README.md | 2 + __tests__/foundation.test.ts | 92 +++++++++++++++++++++++++++++++++++- src/bin/codegraph.ts | 4 +- src/db/index.ts | 3 +- src/directory.ts | 72 ++++++++++++++++++++++++++-- src/extraction/index.ts | 6 ++- src/index.ts | 3 +- src/installer/index.ts | 5 +- src/sync/watcher.ts | 7 ++- 10 files changed, 182 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54bfc89..faf3175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A Razor/Blazor type reference now resolves through the component's `@using` namespaces — including the folder's cascading `_Imports.razor` — so a simple name that exists in several namespaces lands on the right one. A `@model` / `` / `@code` reference to `CatalogBrand` resolves to the `@using`'d DTO (`BlazorShared.Models.CatalogBrand`) rather than a same-named domain entity. (ASP.NET, Blazor) - `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329) - TypeScript service/RPC contracts defined as a tuple of generic types — `type MyServiceList = [Service<'query_apply_record', …>, Service<'apply_confirm', …>]` — now index each entry's string-literal name as a searchable symbol. Previously these names existed only as type arguments, so `codegraph query query_apply_record` found nothing even though the names are the app's primary API surface. The pattern is common in typed RPC / BFF clients and mock servers where the types are the source of truth for a runtime proxy object. Utility types (`Pick`, `Omit`, `Record`) and route paths are deliberately left out to avoid noise. Thanks @jiezhiyong. (#634) (TypeScript) +- New `CODEGRAPH_DIR` environment variable sets the per-project index directory name (default `.codegraph`). This lets one working tree hold two independent indexes — most usefully when you open the same checkout from both **Windows** and **WSL**, which can't safely share a single `.codegraph/`: the background-server lock and the SQLite database are tied to the OS that wrote them, and SQLite locking across the WSL2/Windows filesystem boundary is unreliable. Set `CODEGRAPH_DIR=.codegraph-win` on the Windows side, leave WSL on the default, and each keeps its own index in the same folder without clobbering the other. CodeGraph also skips any sibling `.codegraph-*` directory when indexing and watching, so neither environment trips over the other's data. Thanks @rrtt2323. (#636) ### Fixes diff --git a/README.md b/README.md index 7c7b84a..ab147b5 100644 --- a/README.md +++ b/README.md @@ -684,6 +684,8 @@ Framework routing is validated the same way, on a canonical app per framework: E **Missing symbols** — The MCP server auto-syncs on save (wait a couple seconds). Run `codegraph sync` manually if needed. Check that the file's language is supported and isn't inside a `.gitignore`d or default-excluded directory (e.g. `node_modules`, `dist`). +**Sharing one checkout between Windows and WSL** — Don't point both at the same `.codegraph/`: the background-server lock and the SQLite index are tied to the OS that wrote them, and SQLite locking across the WSL2/Windows filesystem boundary is unreliable. Give each side its own index in the same tree by setting `CODEGRAPH_DIR` to a distinct name on one of them — e.g. `CODEGRAPH_DIR=.codegraph-win` on Windows, leaving WSL on the default `.codegraph`. CodeGraph skips any sibling `.codegraph-*` directory when indexing and watching, so the two never trip over each other. + ## Star History diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 9933cf8..12b11c2 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; import { Node, Edge } from '../src/types'; -import { isInitialized, getCodeGraphDir, validateDirectory } from '../src/directory'; +import { isInitialized, getCodeGraphDir, validateDirectory, codeGraphDirName, isCodeGraphDataDir } from '../src/directory'; import { DatabaseConnection, getDatabasePath } from '../src/db'; // Create a temporary directory for each test @@ -306,3 +306,93 @@ describe('Query Builder', () => { expect(files).toEqual([]); }); }); + +// Two environments that share one working tree (Windows-native + WSL) must not +// share one `.codegraph/`. CODEGRAPH_DIR overrides the data directory name so +// each side keeps its own index in the same tree (issue #636). +describe('CODEGRAPH_DIR override (#636)', () => { + const saved = process.env.CODEGRAPH_DIR; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-dirname-')); + }); + afterEach(() => { + if (saved === undefined) delete process.env.CODEGRAPH_DIR; + else process.env.CODEGRAPH_DIR = saved; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('codeGraphDirName()', () => { + it('defaults to .codegraph when unset', () => { + delete process.env.CODEGRAPH_DIR; + expect(codeGraphDirName()).toBe('.codegraph'); + }); + + it('honors a valid override', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + expect(codeGraphDirName()).toBe('.codegraph-win'); + }); + + // Anything that isn't a plain segment could escape the project root or + // clobber it, so it's ignored in favor of the default. + it.each(['foo/bar', 'a\\b', '..', '../x', '.', '/abs/path', ' ', ''])( + 'falls back to .codegraph for invalid value %j', + (bad) => { + process.env.CODEGRAPH_DIR = bad; + expect(codeGraphDirName()).toBe('.codegraph'); + } + ); + }); + + describe('isCodeGraphDataDir()', () => { + it('matches the default, the active override, and .codegraph-* siblings', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + expect(isCodeGraphDataDir('.codegraph')).toBe(true); // the other env's dir + expect(isCodeGraphDataDir('.codegraph-win')).toBe(true); // active override + expect(isCodeGraphDataDir('.codegraph-wsl')).toBe(true); // any sibling + }); + + it('does not match unrelated directories', () => { + delete process.env.CODEGRAPH_DIR; + for (const name of ['src', 'node_modules', '.git', 'codegraph', '.codegraphextra']) { + expect(isCodeGraphDataDir(name)).toBe(false); + } + }); + }); + + it('init writes the index under the overridden directory, not .codegraph', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + const cg = CodeGraph.initSync(tempDir); + try { + expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.codegraph'))).toBe(false); + expect(getCodeGraphDir(tempDir)).toBe(path.join(tempDir, '.codegraph-win')); + expect(CodeGraph.isInitialized(tempDir)).toBe(true); + } finally { + cg.close(); + } + }); + + it('two index dirs coexist in one tree and the override side skips the sibling', async () => { + // WSL side: default `.codegraph`, with a source file. + delete process.env.CODEGRAPH_DIR; + fs.writeFileSync(path.join(tempDir, 'app.ts'), 'export function onlyReal() {}\n'); + const wsl = await CodeGraph.init(tempDir, { index: true }); + wsl.close(); + + // Windows side: override dir, same tree. Plant a decoy source file INSIDE + // the WSL data dir — the override-side index must not pick it up. + process.env.CODEGRAPH_DIR = '.codegraph-win'; + fs.writeFileSync(path.join(tempDir, '.codegraph', 'decoy.ts'), 'export function decoyLeak() {}\n'); + const win = await CodeGraph.init(tempDir, { index: true }); + try { + expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(win.searchNodes('onlyReal').length).toBeGreaterThan(0); + expect(win.searchNodes('decoyLeak')).toEqual([]); // sibling data dir not indexed + } finally { + win.close(); + } + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index bd66773..879dbb0 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -356,7 +356,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR clack.log.info(`The index is fully usable ${getGlyphs().dash} only the failed files are missing.`); } } else if (projectPath) { - const logPath = path.join(projectPath, '.codegraph', 'errors.log'); + const logPath = path.join(getCodeGraphDir(projectPath), 'errors.log'); if (fs.existsSync(logPath)) { fs.unlinkSync(logPath); } @@ -367,7 +367,7 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR * Write detailed error log to .codegraph/errors.log */ function writeErrorLog(projectPath: string, errors: Array<{ message: string; filePath?: string; severity: string; code?: string }>): void { - const cgDir = path.join(projectPath, '.codegraph'); + const cgDir = getCodeGraphDir(projectPath); if (!fs.existsSync(cgDir)) return; const logPath = path.join(cgDir, 'errors.log'); diff --git a/src/db/index.ts b/src/db/index.ts index cbc08b8..e6d52d4 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { SchemaVersion } from '../types'; import { runMigrations, getCurrentVersion, CURRENT_SCHEMA_VERSION } from './migrations'; +import { getCodeGraphDir } from '../directory'; export { SqliteDatabase, SqliteBackend } from './sqlite-adapter'; @@ -240,5 +241,5 @@ export const DATABASE_FILENAME = 'codegraph.db'; * Get the default database path for a project */ export function getDatabasePath(projectRoot: string): string { - return path.join(projectRoot, '.codegraph', DATABASE_FILENAME); + return path.join(getCodeGraphDir(projectRoot), DATABASE_FILENAME); } diff --git a/src/directory.ts b/src/directory.ts index 3a5c91d..8f5abb0 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -7,16 +7,82 @@ import * as fs from 'fs'; import * as path from 'path'; +/** The default per-project data directory name. */ +const DEFAULT_CODEGRAPH_DIR = '.codegraph'; + +let warnedBadDirName = false; + /** - * CodeGraph directory name + * Resolve the per-project data directory name, honoring the `CODEGRAPH_DIR` + * environment override (default `.codegraph`). The override is a single path + * segment that lives in the project root. + * + * Why this exists: two environments that share one working tree must NOT share + * one `.codegraph/` — most concretely Windows-native and WSL (issue #636). The + * daemon lockfile (`.codegraph/daemon.pid`) records a platform-specific pid and + * socket path (a Windows named pipe vs a WSL Unix socket), and SQLite file + * locking across the WSL2 ↔ Windows filesystem boundary is unreliable, so two + * daemons sharing one index risks corruption. Setting `CODEGRAPH_DIR=.codegraph-win` + * on one side gives each environment its own index in the same tree. + * + * Read live (not captured at load) so it is both process-accurate and testable. + * An override that isn't a plain directory name — empty, containing a path + * separator, `.`, `..`/traversal, or absolute — is ignored (we keep the + * default) rather than risk writing the index outside the project or into the + * project root itself; we warn once to stderr so the misconfiguration is seen. */ -export const CODEGRAPH_DIR = '.codegraph'; +export function codeGraphDirName(): string { + const raw = process.env.CODEGRAPH_DIR?.trim(); + if (!raw) return DEFAULT_CODEGRAPH_DIR; + const invalid = + raw === '.' || + raw.includes('..') || + raw.includes('/') || + raw.includes('\\') || + path.isAbsolute(raw); + if (invalid) { + if (!warnedBadDirName) { + warnedBadDirName = true; + // stderr only — stdout is the MCP protocol channel. + console.warn( + `[codegraph] Ignoring invalid CODEGRAPH_DIR="${raw}" — it must be a plain ` + + `directory name (no path separators, no "..", not absolute). Using "${DEFAULT_CODEGRAPH_DIR}".` + ); + } + return DEFAULT_CODEGRAPH_DIR; + } + return raw; +} + +/** + * CodeGraph directory name — a load-time snapshot of {@link codeGraphDirName}. + * A running process's environment is fixed, so this equals the live value; + * it's kept as a stable string export for backward compatibility. Internal code + * resolves the name through {@link codeGraphDirName} / {@link getCodeGraphDir} + * so the `CODEGRAPH_DIR` override always applies. + */ +export const CODEGRAPH_DIR = codeGraphDirName(); + +/** + * Is `name` (a single path segment) a CodeGraph data directory? Matches the + * default `.codegraph`, the active `CODEGRAPH_DIR` override, and any + * `.codegraph-*` sibling. File-watching and the indexer skip ALL of these, so + * when two environments share one working tree (Windows + WSL, issue #636) + * neither indexes or watches the other's index directory. + */ +export function isCodeGraphDataDir(name: string): boolean { + return ( + name === DEFAULT_CODEGRAPH_DIR || + name === codeGraphDirName() || + name.startsWith(DEFAULT_CODEGRAPH_DIR + '-') + ); +} /** * Get the .codegraph directory path for a project */ export function getCodeGraphDir(projectRoot: string): string { - return path.join(projectRoot, CODEGRAPH_DIR); + return path.join(projectRoot, codeGraphDirName()); } /** diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 3656925..9f1831f 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -18,6 +18,7 @@ import { import { QueryBuilder } from '../db/queries'; import { extractFromSource } from './tree-sitter'; import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages } from './grammars'; +import { isCodeGraphDataDir } from '../directory'; import { logDebug, logWarn } from '../errors'; import { validatePathWithinRoot, normalizePath } from '../utils'; import ignore, { Ignore } from 'ignore'; @@ -454,8 +455,9 @@ function scanDirectoryWalk( } for (const entry of entries) { - // Never descend into git internals or our own data directory. - if (entry.name === '.git' || entry.name === '.codegraph') continue; + // Never descend into git internals or any CodeGraph data directory + // (the active one or a sibling another environment created — #636). + if (entry.name === '.git' || isCodeGraphDataDir(entry.name)) continue; const fullPath = path.join(dir, entry.name); const relativePath = normalizePath(path.relative(rootDir, fullPath)); diff --git a/src/index.ts b/src/index.ts index 55ef12e..1b2642d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ import { ContextBuilder, createContextBuilder } from './context'; import { Mutex, FileLock } from './utils'; import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync'; import { EXTRACTION_VERSION } from './extraction/extraction-version'; +import { getCodeGraphDir } from './directory'; import { CodeGraphPackageVersion } from './mcp/version'; // Re-export types for consumers @@ -154,7 +155,7 @@ export class CodeGraph { this.queries = queries; this.projectRoot = projectRoot; this.fileLock = new FileLock( - path.join(projectRoot, '.codegraph', 'codegraph.lock') + path.join(getCodeGraphDir(projectRoot), 'codegraph.lock') ); this.orchestrator = new ExtractionOrchestrator(projectRoot, queries); this.resolver = createResolver(projectRoot, queries); diff --git a/src/installer/index.ts b/src/installer/index.ts index edd48ec..a9be118 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -28,6 +28,7 @@ import { getGlyphs } from '../ui/glyphs'; // installer must stay importable even when native modules can't load). import { watchDisabledReason } from '../sync/watch-policy'; import { isGitRepo, isSyncHookInstalled, installGitSyncHook } from '../sync/git-hooks'; +import { getCodeGraphDir, codeGraphDirName } from '../directory'; // Backwards-compat: keep these named exports — downstream code may // import them. The shim in `config-writer.ts` continues to re-export @@ -362,8 +363,8 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise // Step 4: for local uninstall, the index dir is separate — point at // `uninit` so the user knows it's still there (and how to remove it). - if (location === 'local' && fs.existsSync(path.join(process.cwd(), '.codegraph'))) { - clack.log.info('The .codegraph/ index for this project is still here. Run `codegraph uninit` to delete it.'); + if (location === 'local' && fs.existsSync(getCodeGraphDir(process.cwd()))) { + clack.log.info(`The ${codeGraphDirName()}/ index for this project is still here. Run \`codegraph uninit\` to delete it.`); } // Step 5: summary. diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index 8635a26..9bc6547 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -37,6 +37,7 @@ import type { Ignore } from 'ignore'; import { isSourceFile, buildDefaultIgnore } from '../extraction'; import { logDebug, logWarn } from '../errors'; import { normalizePath } from '../utils'; +import { isCodeGraphDataDir } from '../directory'; import { watchDisabledReason } from './watch-policy'; /** @@ -425,8 +426,12 @@ export class FileWatcher { /** Our own dirs are always ignored, regardless of .gitignore. */ private isAlwaysIgnored(rel: string): boolean { + // First path segment. Ignore any CodeGraph data dir — the active one AND a + // sibling like `.codegraph-win` a second environment (Windows/WSL) created + // in the same tree, so neither side watches the other's index (#636). + const top = rel.split('/')[0] ?? rel; return ( - rel === '.codegraph' || rel.startsWith('.codegraph/') || + isCodeGraphDataDir(top) || rel === '.git' || rel.startsWith('.git/') ); }