Files
codegraph/src/directory.ts
T
Colby MchenryandGitHub 2472508549 fix(installer,cli): refuse to index $HOME / filesystem root (#860)
Running the installer or `codegraph init`/`index` from $HOME auto-indexed the
entire home tree (installer indexes process.cwd() with no guard), producing a
multi-GB ~/.codegraph/codegraph.db; the install dir sharing the ~/.codegraph
name then made every home subdir resolve its root to $HOME. On pre-1.0 macOS the
per-file watcher over that tree exhausted kern.maxfiles and crashed the machine
(#845; the fd blowup was fixed in 1.0.0, this fixes the root cause).

Add unsafeIndexRootReason() and refuse the home dir, a parent of home, and
filesystem roots at the installer auto-index, `init`, and `index`. Overridable
with --force. Closes #845.
2026-06-13 12:35:18 -05:00

408 lines
13 KiB
TypeScript

/**
* Directory Management
*
* Manages the .codegraph/ directory structure for CodeGraph data.
*/
import * as fs from 'fs';
import * as os from 'os';
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 — 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, codeGraphDirName());
}
/**
* Check if a project has been initialized with CodeGraph
* Requires both .codegraph/ directory AND codegraph.db to exist
*/
export function isInitialized(projectRoot: string): boolean {
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir) || !fs.statSync(codegraphDir).isDirectory()) {
return false;
}
// Must have codegraph.db, not just .codegraph folder
const dbPath = path.join(codegraphDir, 'codegraph.db');
return fs.existsSync(dbPath);
}
/**
* Find the nearest parent directory containing .codegraph/
*
* Walks up from the given path to find a CodeGraph-initialized project,
* similar to how git finds .git/ directories.
*
* @param startPath - Directory to start searching from
* @returns The project root containing .codegraph/, or null if not found
*/
/**
* Reason a directory is unsafe to use as an index ROOT, or null when it's fine.
*
* Indexing your home directory or a filesystem root drags in caches, `Library`,
* every other project, etc. — a multi-GB index, constant file-watcher churn, and
* (pre-1.0 on macOS) a file-descriptor blowup that exhausted `kern.maxfiles` and
* took unrelated apps / the whole machine down (#845). The classic trigger:
* running the installer or `codegraph init` from `$HOME`, which auto-indexes the
* current directory. These are never intended project roots, so the installer
* and `init`/`index` refuse them (overridable with `--force`).
*
* Pure-ish (reads only `os.homedir()` + realpath) so it's easy to unit-test.
* The returned string is a human phrase that slots into "… looks like {reason}".
*/
export function unsafeIndexRootReason(projectRoot: string): string | null {
const resolve = (p: string): string => {
try {
return fs.realpathSync(path.resolve(p));
} catch {
return path.resolve(p);
}
};
const resolved = resolve(projectRoot);
// Filesystem root: `/` on POSIX, a drive root like `C:\` on Windows.
if (path.parse(resolved).root === resolved) {
return 'the filesystem root';
}
const home = resolve(os.homedir());
// Case-insensitive on macOS/Windows (case-preserving but case-insensitive FS).
const norm = (p: string): string =>
process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p;
const r = norm(resolved);
const h = norm(home);
if (r === h) {
return 'your home directory';
}
// An ancestor of home (e.g. `/Users`, `/home`) — even broader than home.
if (h.startsWith(r + path.sep)) {
return 'a parent of your home directory';
}
return null;
}
export function findNearestCodeGraphRoot(startPath: string): string | null {
let current = path.resolve(startPath);
const root = path.parse(current).root;
while (current !== root) {
if (isInitialized(current)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) break; // Reached filesystem root
current = parent;
}
// Check root as well
if (isInitialized(current)) {
return current;
}
return null;
}
/**
* Contents of `.codegraph/.gitignore`. A single wildcard ignore keeps every
* transient file in the index dir — the database, `daemon.pid`, the socket,
* logs, cache, and anything future versions add — out of git, without having
* to enumerate each name (issues #788, #492, #484). Older versions wrote an
* explicit allowlist that never listed `daemon.pid` or the socket, so those
* runtime files were silently committed.
*/
const GITIGNORE_CONTENT = `# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
`;
/** Header line that prefixes every .gitignore CodeGraph has auto-generated. */
const GITIGNORE_MARKER = '# CodeGraph data files';
/**
* Is `content` a stale CodeGraph-generated `.gitignore` that should be
* regenerated in place? True when it carries our header but predates the
* wildcard ignore (it has no bare `*` line) — i.e. one of the old explicit
* allowlists (`*.db`, `cache/`, `.dirty`, …) that never ignored `daemon.pid`
* or the socket (issue #788). A file WITHOUT our header is user-authored and
* is left untouched; one that already has the wildcard is current. Matching
* on the header (not a byte-exact list of past defaults) heals every old
* variant — v0.7.x through 0.9.9 — and is idempotent once upgraded.
*/
function isStaleDefaultGitignore(content: string): boolean {
if (!content.trimStart().startsWith(GITIGNORE_MARKER)) return false;
return !content.split('\n').some((line) => line.trim() === '*');
}
/**
* Write `.codegraph/.gitignore` if it's absent, or upgrade a stale
* CodeGraph-generated default in place; a user-customized file is left alone.
* Best-effort — returns `false` only if a needed write failed.
*/
function ensureGitignore(gitignorePath: string): boolean {
let existing: string | null;
try {
existing = fs.readFileSync(gitignorePath, 'utf-8');
} catch {
existing = null; // absent (ENOENT) or unreadable — (re)create below
}
// Current default or a user-authored file: nothing to do.
if (existing !== null && !isStaleDefaultGitignore(existing)) return true;
try {
fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
return true;
} catch {
return false;
}
}
/**
* Create the .codegraph directory structure
* Note: Only throws if codegraph.db already exists, not just if .codegraph/ exists.
*/
export function createDirectory(projectRoot: string): void {
const codegraphDir = getCodeGraphDir(projectRoot);
const dbPath = path.join(codegraphDir, 'codegraph.db');
// Only throw if CodeGraph is actually initialized (db exists)
// .codegraph/ folder alone is fine
if (fs.existsSync(dbPath)) {
throw new Error(`CodeGraph already initialized in ${projectRoot}`);
}
// Create main directory (if it doesn't exist)
fs.mkdirSync(codegraphDir, { recursive: true });
// Write .gitignore inside .codegraph (create if absent, upgrade a stale
// pre-wildcard default left by an older version — issue #788).
ensureGitignore(path.join(codegraphDir, '.gitignore'));
}
/**
* Remove the .codegraph directory
*/
export function removeDirectory(projectRoot: string): void {
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir)) {
return;
}
// Verify .codegraph is a real directory, not a symlink pointing elsewhere
const lstat = fs.lstatSync(codegraphDir);
if (lstat.isSymbolicLink()) {
// Only remove the symlink itself, never follow it for recursive delete
fs.unlinkSync(codegraphDir);
return;
}
if (!lstat.isDirectory()) {
// Not a directory - remove the single file
fs.unlinkSync(codegraphDir);
return;
}
// Recursively remove directory
fs.rmSync(codegraphDir, { recursive: true, force: true });
}
/**
* Get all files in the .codegraph directory
*/
export function listDirectoryContents(projectRoot: string): string[] {
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir)) {
return [];
}
const files: string[] = [];
function walkDir(dir: string, prefix: string = ''): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
// Skip symlinks to prevent following links outside .codegraph
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
walkDir(path.join(dir, entry.name), relativePath);
} else {
files.push(relativePath);
}
}
}
walkDir(codegraphDir);
return files;
}
/**
* Get the total size of the .codegraph directory in bytes
*/
export function getDirectorySize(projectRoot: string): number {
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir)) {
return 0;
}
let totalSize = 0;
function walkDir(dir: string): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
// Skip symlinks to prevent following links outside .codegraph
if (entry.isSymbolicLink()) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else {
const stats = fs.statSync(fullPath);
totalSize += stats.size;
}
}
}
walkDir(codegraphDir);
return totalSize;
}
/**
* Ensure a subdirectory exists within .codegraph
*/
export function ensureSubdirectory(projectRoot: string, subdirName: string): string {
if (subdirName.includes('..') || subdirName.includes(path.sep) || subdirName.includes('/')) {
throw new Error(`Invalid subdirectory name: ${subdirName}`);
}
const subdirPath = path.join(getCodeGraphDir(projectRoot), subdirName);
if (!fs.existsSync(subdirPath)) {
fs.mkdirSync(subdirPath, { recursive: true });
}
return subdirPath;
}
/**
* Check if the .codegraph directory has valid structure
*/
export function validateDirectory(projectRoot: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
const codegraphDir = getCodeGraphDir(projectRoot);
if (!fs.existsSync(codegraphDir)) {
errors.push('CodeGraph directory does not exist');
return { valid: false, errors };
}
if (!fs.statSync(codegraphDir).isDirectory()) {
errors.push('.codegraph exists but is not a directory');
return { valid: false, errors };
}
// Auto-repair / upgrade .gitignore (non-critical file). A missing one is
// recreated; a stale pre-wildcard default that never ignored daemon.pid is
// regenerated in place (issue #788); a user-authored file is left alone.
const gitignorePath = path.join(codegraphDir, '.gitignore');
const existedBefore = fs.existsSync(gitignorePath);
if (!ensureGitignore(gitignorePath) && !existedBefore) {
// Only a missing-and-uncreatable file is surfaced; a failed in-place
// upgrade of an existing file is non-fatal — the index still works.
errors.push('.gitignore missing in .codegraph directory and could not be created');
}
return {
valid: errors.length === 0,
errors,
};
}