Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### MCP / indexing

- On Windows, the shared MCP daemon now waits longer for another program — an antivirus scan, an indexer, or another session reading its lock file — to let go of that file, so it starts instead of leaving the session to fall back to a slower in-process server. (#1773)
- An empty or table-less `codegraph.db` left behind in a parent directory (by an interrupted `codegraph init`, or a never-populated `~/.codegraph/`) no longer counts as an initialized project, so it can no longer hide the real index of every project beneath it. Only a database that actually carries the codegraph schema is treated as initialized, and `codegraph init` in the directory with the broken file now repairs it instead of refusing with "Already initialized". (#1895)
- File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964)
- Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.
- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.
Expand Down
9 changes: 6 additions & 3 deletions __tests__/frontload-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ import { planFrontload, isTaskNotification, findIndexedSubprojectRoots, unsafeIn
// fixture without changing the process environment or the user's home files.
vi.mock('os', async (importOriginal) => ({ ...await importOriginal<typeof import('os')>() }));

/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
/**
* Make `dir` indexed. isInitialized needs `.codegraph/codegraph.db` WITH the
* codegraph schema — an empty file no longer counts (#1895).
*/
function mkIndexed(dir: string): string {
fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), '');
fs.mkdirSync(dir, { recursive: true });
CodeGraph.initSync(dir).close();
return dir;
}
/** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */
Expand Down
210 changes: 210 additions & 0 deletions __tests__/schemaless-db-not-initialized.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* A schema-less `codegraph.db` does not make a project initialized (#1895).
*
* `isInitialized()` used to accept any existing `.codegraph/codegraph.db`, so
* an empty or table-less file in an ANCESTOR directory (an interrupted `init`,
* a stray `touch`, a never-populated `$HOME/.codegraph/`) captured the upward
* resolution of every project beneath it: the CLI and the MCP server operated
* on the broken file instead of the project's own index, or instead of the
* "not initialized" guidance. An initialized project is now one whose db
* carries the codegraph schema.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require('node:sqlite');
import { CodeGraph } from '../src';
import { isInitialized, findNearestCodeGraphRoot, resolveServerRoot, planFrontload } from '../src/directory';
import { ToolHandler } from '../src/mcp/tools';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');

function run(cwd: string, args: string[]) {
const r = spawnSync(process.execPath, [BIN, ...args], {
cwd,
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
});
return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
}

/** A `.codegraph/codegraph.db` that exists but is not a codegraph index. */
function plantBrokenDb(dir: string, kind: 'empty' | 'no-tables' | 'garbage'): string {
const cgDir = path.join(dir, '.codegraph');
fs.mkdirSync(cgDir, { recursive: true });
const dbPath = path.join(cgDir, 'codegraph.db');
if (kind === 'empty') {
fs.writeFileSync(dbPath, '');
} else if (kind === 'garbage') {
fs.writeFileSync(dbPath, 'x'.repeat(4096));
} else {
// A valid SQLite file with zero tables — what an interrupted init leaves.
const db = new DatabaseSync(dbPath);
db.exec('CREATE TABLE t(x); DROP TABLE t;');
db.close();
}
return dbPath;
}

async function indexProject(dir: string): Promise<void> {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src', 'a.py'), 'def alpha(x):\n return beta(x) + 1\n\ndef beta(x):\n return x * 2\n');
const cg = CodeGraph.initSync(dir);
await cg.indexAll();
cg.close();
}

describe('a schema-less codegraph.db is not an initialized project (#1895)', () => {
let tmp: string;
let parent: string;
let child: string;

beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1895-')));
parent = path.join(tmp, 'parent');
child = path.join(parent, 'child');
fs.mkdirSync(child, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});

it.each(['empty', 'no-tables', 'garbage'] as const)('isInitialized() rejects a %s codegraph.db', (kind) => {
plantBrokenDb(parent, kind);
expect(isInitialized(parent)).toBe(false);
expect(findNearestCodeGraphRoot(child)).toBeNull();
expect(resolveServerRoot(child).root).toBeNull();
expect(planFrontload(child, 'how does alpha call beta').exploreRoot).toBeNull();
});

// Root ignores mode bits and Windows has no chmod-based directory permissions.
const canDenyWrite = process.platform !== 'win32' && process.getuid?.() !== 0;
it.runIf(canDenyWrite)('fails OPEN: a WAL db in a directory we cannot open read-only is still initialized', async () => {
// SQLite needs to create `-shm` to open a WAL database; with the directory
// non-writable the read-only open throws. That is a database we cannot
// inspect, not a schema-less one — it must keep resolving (#1913 review).
await indexProject(parent);
const src = path.join(parent, '.codegraph', 'codegraph.db');
const other = path.join(tmp, 'other');
fs.mkdirSync(path.join(other, '.codegraph'), { recursive: true });
fs.copyFileSync(src, path.join(other, '.codegraph', 'codegraph.db'));
const dir = path.join(other, '.codegraph');
fs.chmodSync(dir, 0o555);
try {
expect(isInitialized(other)).toBe(true);
expect(findNearestCodeGraphRoot(path.join(other, 'sub'))).toBe(other);
} finally {
fs.chmodSync(dir, 0o755);
}
});

it('a real index still resolves upward from a subdirectory', async () => {
await indexProject(parent);
expect(isInitialized(parent)).toBe(true);
expect(findNearestCodeGraphRoot(child)).toBe(parent);
const r = run(child, ['status']);
expect(r.status).toBe(0);
expect(r.out).toContain(parent);
});

it('the cache follows the file: a db that gains its schema flips to initialized', async () => {
plantBrokenDb(parent, 'no-tables');
expect(isInitialized(parent)).toBe(false);
await indexProject(parent); // repairs the file in place
expect(isInitialized(parent)).toBe(true);
});

it('(a) an empty db in the parent does not hijack the initialized child', async () => {
plantBrokenDb(parent, 'empty');
await indexProject(child);
expect(findNearestCodeGraphRoot(child)).toBe(child);
expect(resolveServerRoot(child).root).toBe(child);

const status = run(child, ['status']);
expect(status.status).toBe(0);
expect(status.out).toContain(child);
expect(status.out).not.toMatch(/SQLITE_|not a database|no such table/i);

const explore = run(child, ['explore', 'alpha']);
expect(explore.status).toBe(0);
expect(explore.out).toContain('alpha');
});

it('(a) a workspace container adopts its indexed sub-project even under a schema-less ancestor', async () => {
// The issue's exact reproduction: ws/ has .git and no index, ws/sub is
// indexed, and an ancestor of ws carries a zero-table db.
plantBrokenDb(tmp, 'no-tables');
const ws = path.join(tmp, 'ws');
const sub = path.join(ws, 'sub');
fs.mkdirSync(path.join(ws, '.git'), { recursive: true });
await indexProject(sub);
fs.writeFileSync(path.join(sub, 'pyproject.toml'), '[project]\nname = "sub"\nversion = "0.1"\n');
const res = resolveServerRoot(ws);
expect(res.root).toBe(sub);
expect(res.viaSubScan).toBe(true);
});

it('(b) CLI: parent with a schema-less db, child not initialized → not-initialized guidance, never a SQLite error', () => {
plantBrokenDb(parent, 'no-tables');
const r = run(child, ['status']);
expect(r.out).toMatch(/not initialized/i);
expect(r.out).toContain('codegraph init');
expect(r.out).not.toMatch(/SQLITE_|not a database|no such table/i);
});

it('(b) MCP: the handler returns success-shaped not-indexed guidance for the child', async () => {
plantBrokenDb(parent, 'no-tables');
const handler = new ToolHandler(null);
const res = await handler.execute('codegraph_explore', { query: 'alpha', projectPath: child });
expect(res.isError).toBeUndefined();
const text = res.content.map((c: any) => c.text ?? '').join('\n');
expect(text).toMatch(/isn't indexed|codegraph init/i);
expect(text).not.toMatch(/SQLITE_|not a database|no such table/i);
});

it('(d) codegraph init in the directory with the broken file repairs it', () => {
const dbPath = plantBrokenDb(parent, 'no-tables');
fs.writeFileSync(path.join(parent, 'a.py'), 'def alpha():\n return 1\n');
const r = run(parent, ['init', '--yes']);
expect(r.status).toBe(0);
expect(r.out).toContain('without the codegraph schema');
expect(r.out).not.toContain('Already initialized');
expect(isInitialized(parent)).toBe(true);
const db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare("SELECT count(*) AS c FROM sqlite_master WHERE type = 'table' AND name = 'nodes'").get() as { c: number };
db.close();
expect(row.c).toBe(1);
});

it('(d) an empty file is repaired too; a file that is not SQLite is refused with guidance and left alone', () => {
plantBrokenDb(parent, 'empty');
fs.writeFileSync(path.join(parent, 'a.py'), 'def alpha():\n return 1\n');
const empty = run(parent, ['init', '--yes']);
expect(empty.status).toBe(0);
expect(empty.out).toContain('without the codegraph schema');
expect(isInitialized(parent)).toBe(true);

for (const bytes of ['x'.repeat(4096), 'hello']) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1895-foreign-'));
try {
const cgDir = path.join(dir, '.codegraph');
fs.mkdirSync(cgDir);
const dbPath = path.join(cgDir, 'codegraph.db');
fs.writeFileSync(dbPath, bytes);
fs.writeFileSync(path.join(dir, 'a.py'), 'def alpha():\n return 1\n');
const r = run(dir, ['init', '--yes']);
expect(r.status).toBe(1);
expect(r.out).toContain('is not a SQLite database');
expect(r.out).not.toContain('rebuilding');
expect(r.out).not.toContain('file is not a database');
// Nothing is deleted or rewritten behind the user's back.
expect(fs.readFileSync(dbPath, 'utf-8')).toBe(bytes);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
});
13 changes: 12 additions & 1 deletion src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ try {
import { Command } from 'commander';
import * as path from 'path';
import * as fs from 'fs';
import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory';
import { getCodeGraphDir, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory';
import { extractProseCandidates } from '../search/identifier-segments';
import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
import { createShimmerProgress } from '../ui/shimmer-progress';
Expand Down Expand Up @@ -697,6 +697,17 @@ async function runInit(
return;
}

if (hasForeignDbFile(projectPath)) {
const dbFile = path.join(getCodeGraphDir(projectPath), 'codegraph.db');
clack.log.error(`${dbFile} is not a SQLite database, so it cannot be rebuilt in place.`);
clack.log.info('Move or delete that file, then run "codegraph init" again.');
clack.outro('');
process.exitCode = 1;
return;
}
if (hasSchemalessDb(projectPath)) {
clack.log.warn(`Found a codegraph.db without the codegraph schema in ${getCodeGraphDir(projectPath)} (left by an interrupted init?) — rebuilding it.`);
}
const { default: CodeGraph, getDatabasePath } = await loadCodeGraph();
const cg = await CodeGraph.init(projectPath, { index: false });
clack.log.success(`Initialized in ${projectPath}`);
Expand Down
112 changes: 105 additions & 7 deletions src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,21 @@ export function getCodeGraphDir(projectRoot: string): string {
}

/**
* Check if a project has been initialized with CodeGraph
* Requires both .codegraph/ directory AND codegraph.db to exist
* Check if a project has been initialized with CodeGraph.
*
* Requires `.codegraph/codegraph.db` to exist AND to carry the codegraph
* schema. A file that merely exists — empty, or a SQLite database with no
* tables, as an interrupted `init` or a stray `touch` leaves behind — used to
* count as initialized, so one such file in an ANCESTOR directory (worst
* case: `$HOME`) captured the upward resolution of every project beneath it
* and made their real indexes unreachable (#1895).
*
* The probe is cheap and gated so hot callers (the prompt hook, MCP root
* resolution on every call) pay one small read: file size, then the SQLite
* header magic, then a read-only open with a single `sqlite_master` lookup —
* memoized per path + mtime + size so an unchanged db is never reopened. A
* database that passes the header check but cannot be opened counts as
* initialized (see hasNodesTable) — only a proven-absent schema says no.
*/
export function isInitialized(projectRoot: string): boolean {
const codegraphDir = getCodeGraphDir(projectRoot);
Expand All @@ -132,7 +145,92 @@ export function isInitialized(projectRoot: string): boolean {
}
// Must have codegraph.db, not just .codegraph folder
const dbPath = path.join(codegraphDir, 'codegraph.db');
return fs.existsSync(dbPath);
let st: fs.Stats;
try {
st = fs.statSync(dbPath);
} catch {
return false;
}
return hasCodeGraphSchema(dbPath, st);
}

/**
* `codegraph.db` exists at `projectRoot` but does not carry the schema, and
* `init` can add it in place: an empty file, or a SQLite database without the
* codegraph tables (#1895). A file that is not SQLite at all is NOT this case —
* see {@link hasForeignDbFile}.
*/
export function hasSchemalessDb(projectRoot: string): boolean {
const dbPath = path.join(getCodeGraphDir(projectRoot), 'codegraph.db');
let st: fs.Stats;
try { st = fs.statSync(dbPath); } catch { return false; }
if (!st.isFile() || isInitialized(projectRoot)) return false;
return st.size === 0 || readsAsSqlite(dbPath);
}

/**
* `codegraph.db` exists at `projectRoot` and is not a SQLite database (no
* header magic): SQLite refuses to open it, so `init` cannot rebuild it in
* place. The caller must say so rather than promise a repair; nothing here
* deletes the file.
*/
export function hasForeignDbFile(projectRoot: string): boolean {
const dbPath = path.join(getCodeGraphDir(projectRoot), 'codegraph.db');
let st: fs.Stats;
try { st = fs.statSync(dbPath); } catch { return false; }
return st.isFile() && st.size > 0 && !readsAsSqlite(dbPath);
}

const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1');
/** A SQLite file header is 100 bytes; anything shorter cannot be a database. */
const SQLITE_HEADER_SIZE = 100;
const schemaProbeCache = new Map<string, { mtimeMs: number; size: number; ok: boolean }>();

function hasCodeGraphSchema(dbPath: string, st: fs.Stats): boolean {
if (!st.isFile() || st.size < SQLITE_HEADER_SIZE) return false;
const cached = schemaProbeCache.get(dbPath);
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.ok;
const ok = readsAsSqlite(dbPath) && hasNodesTable(dbPath);
schemaProbeCache.set(dbPath, { mtimeMs: st.mtimeMs, size: st.size, ok });
return ok;
}

function readsAsSqlite(dbPath: string): boolean {
let fd: number | null = null;
try {
fd = fs.openSync(dbPath, 'r');
const head = Buffer.alloc(SQLITE_MAGIC.length);
const n = fs.readSync(fd, head, 0, head.length, 0);
return n === head.length && head.equals(SQLITE_MAGIC);
} catch {
return false;
} finally {
if (fd !== null) fs.closeSync(fd);
}
}

/**
* Fails OPEN. Once the size gate and the header magic have passed, the file IS
* a SQLite database; only a SUCCESSFUL `sqlite_master` query that proves the
* `nodes` table is absent may say "not initialized". Any open/prepare error —
* locked, busy, a WAL db in a directory we cannot create `-shm` in (read-only
* checkout, mount, another user's tree), disk I/O — returns true: the
* pre-existing behaviour for a database we cannot inspect.
*/
function hasNodesTable(dbPath: string): boolean {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require('node:sqlite');
let db: any = null;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'nodes'").get();
return row !== undefined;
} catch {
return true;
} finally {
// Never hold the handle: Windows file locking would block the owner.
try { db?.close(); } catch { /* already closed */ }
}
}

/**
Expand Down Expand Up @@ -806,11 +904,11 @@ function ensureGitignore(gitignorePath: string): boolean {
*/
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)) {
// Only throw if CodeGraph is actually initialized (db with a schema).
// .codegraph/ folder alone — or a schema-less codegraph.db left by an
// interrupted init (#1895) — is fine: initialize() adds the schema to it.
if (isInitialized(projectRoot)) {
throw new Error(`CodeGraph already initialized in ${projectRoot}`);
}

Expand Down