Răsfoiți Sursa

feat(directory): CODEGRAPH_DIR env var to override the index dir name (#636) (#741)

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) <noreply@anthropic.com>
Colby Mchenry 2 săptămâni în urmă
părinte
comite
a56d9e6941

+ 1 - 0
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` / `<MyComponent>` / `@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
 

+ 2 - 0
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
 
 <a href="https://www.star-history.com/?repos=colbymchenry%2Fcodegraph&type=date&legend=top-left">

+ 91 - 1
__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();
+    }
+  });
+});

+ 2 - 2
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');

+ 2 - 1
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);
 }

+ 69 - 3
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;
+
+/**
+ * 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 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
+ * 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 = '.codegraph';
+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());
 }
 
 /**

+ 4 - 2
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));

+ 2 - 1
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);

+ 3 - 2
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<void>
 
   // 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.

+ 6 - 1
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/')
     );
   }