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.
408 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|