Fix performance issues.

This commit is contained in:
Colby McHenry
2026-02-11 16:11:50 -06:00
parent a7fc5853a2
commit ce504c642a
6 changed files with 388 additions and 75 deletions
+109
View File
@@ -10,6 +10,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { execFileSync } from 'child_process';
import CodeGraph from '../src/index'; import CodeGraph from '../src/index';
describe('Sync Module', () => { describe('Sync Module', () => {
@@ -150,4 +151,112 @@ describe('Sync Module', () => {
}); });
}); });
}); });
describe('Git-based sync', () => {
let testDir: string;
let cg: CodeGraph;
function git(...args: string[]) {
execFileSync('git', args, { cwd: testDir, stdio: 'pipe' });
}
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-git-sync-'));
// Initialize a git repo with an initial commit
git('init');
git('config', 'user.email', 'test@test.com');
git('config', 'user.name', 'Test');
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(
path.join(srcDir, 'index.ts'),
`export function hello() { return 'world'; }`
);
git('add', '-A');
git('commit', '-m', 'initial');
// Initialize CodeGraph and index
cg = CodeGraph.initSync(testDir, {
config: {
include: ['**/*.ts'],
exclude: [],
},
});
await cg.indexAll();
});
afterEach(() => {
if (cg) {
cg.destroy();
}
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
it('should detect modified files via git', async () => {
fs.writeFileSync(
path.join(testDir, 'src', 'index.ts'),
`export function hello() { return 'modified'; }`
);
const result = await cg.sync();
expect(result.filesModified).toBe(1);
expect(result.changedFilePaths).toContain('src/index.ts');
});
it('should detect new untracked files via git', async () => {
fs.writeFileSync(
path.join(testDir, 'src', 'new.ts'),
`export function newFunc() { return 42; }`
);
const result = await cg.sync();
expect(result.filesAdded).toBe(1);
expect(result.changedFilePaths).toContain('src/new.ts');
// Verify the function was indexed
const nodes = cg.searchNodes('newFunc');
expect(nodes.length).toBeGreaterThan(0);
});
it('should detect deleted files via git', async () => {
fs.unlinkSync(path.join(testDir, 'src', 'index.ts'));
const result = await cg.sync();
expect(result.filesRemoved).toBe(1);
// Verify function is gone
const nodes = cg.searchNodes('hello');
expect(nodes.length).toBe(0);
});
it('should skip files not matching config', async () => {
// Create a .js file which doesn't match **/*.ts
fs.writeFileSync(
path.join(testDir, 'src', 'ignored.js'),
`function ignored() {}`
);
const result = await cg.sync();
expect(result.filesAdded).toBe(0);
expect(result.filesModified).toBe(0);
});
it('should report no changes on clean working tree', async () => {
const result = await cg.sync();
expect(result.filesAdded).toBe(0);
expect(result.filesModified).toBe(0);
expect(result.filesRemoved).toBe(0);
expect(result.changedFilePaths).toBeUndefined();
});
});
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@colbymchenry/codegraph", "name": "@colbymchenry/codegraph",
"version": "0.5.1", "version": "0.5.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@colbymchenry/codegraph", "name": "@colbymchenry/codegraph",
"version": "0.5.1", "version": "0.5.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@colbymchenry/codegraph", "name": "@colbymchenry/codegraph",
"version": "0.5.1", "version": "0.5.2",
"description": "Supercharge Claude Code with semantic code intelligence. 30% fewer tokens, 25% fewer tool calls, 100% local.", "description": "Supercharge Claude Code with semantic code intelligence. 30% fewer tokens, 25% fewer tool calls, 100% local.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+24
View File
@@ -896,6 +896,30 @@ export class QueryBuilder {
})); }));
} }
/**
* Get unresolved references scoped to specific file paths.
* Uses the idx_unresolved_file_path index for efficient lookup.
*/
getUnresolvedReferencesByFiles(filePaths: string[]): UnresolvedReference[] {
if (filePaths.length === 0) return [];
const placeholders = filePaths.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT * FROM unresolved_refs WHERE file_path IN (${placeholders})`)
.all(...filePaths) as UnresolvedRefRow[];
return rows.map((row) => ({
fromNodeId: row.from_node_id,
referenceName: row.reference_name,
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
}));
}
/** /**
* Delete all unresolved references (after resolution) * Delete all unresolved references (after resolution)
*/ */
+220 -43
View File
@@ -63,6 +63,7 @@ export interface SyncResult {
filesRemoved: number; filesRemoved: number;
nodesUpdated: number; nodesUpdated: number;
durationMs: number; durationMs: number;
changedFilePaths?: string[];
} }
/** /**
@@ -105,27 +106,80 @@ export function shouldIncludeFile(
} }
/** /**
* Get directories ignored by .gitignore using git ls-files. * Get all files visible to git (tracked + untracked but not ignored).
* Returns a Set of normalized relative directory paths (forward slashes, no trailing slash). * Respects .gitignore at all levels (root, subdirectories).
* Gracefully returns empty Set on any failure. * Returns null on failure (non-git project) so callers can fall back.
*/ */
function getGitIgnoredDirectories(rootDir: string): Set<string> { function getGitVisibleFiles(rootDir: string): Set<string> | null {
try {
// -c = cached (tracked), -o = others (untracked), --exclude-standard = respect .gitignore
const output = execFileSync(
'git',
['ls-files', '-co', '--exclude-standard'],
{ cwd: rootDir, encoding: 'utf-8', timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
);
const files = new Set<string>();
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
files.add(normalizePath(trimmed));
}
}
return files;
} catch {
return null;
}
}
/**
* Result of git-based change detection.
* Returns null when git is unavailable (non-git project or command failure),
* signaling the caller to fall back to full filesystem scan.
*/
interface GitChanges {
modified: string[]; // M, MM, AM — files to re-hash + re-index
added: string[]; // ?? — new untracked files to index
deleted: string[]; // D — files to remove from DB
}
/**
* Use `git status` to detect changed files instead of scanning every file.
* Returns null on failure so callers fall back to full scan.
*/
function getGitChangedFiles(rootDir: string, config: CodeGraphConfig): GitChanges | null {
try { try {
const output = execFileSync( const output = execFileSync(
'git', 'git',
['ls-files', '-oi', '--exclude-standard', '--directory'], ['status', '--porcelain', '--no-renames'],
{ cwd: rootDir, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] } { cwd: rootDir, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }
); );
const dirs = new Set<string>();
const modified: string[] = [];
const added: string[] = [];
const deleted: string[] = [];
for (const line of output.split('\n')) { for (const line of output.split('\n')) {
const trimmed = line.trim(); if (line.length < 4) continue; // Minimum: "XY file"
if (trimmed.endsWith('/')) {
dirs.add(normalizePath(trimmed.slice(0, -1))); const statusCode = line.substring(0, 2);
const filePath = normalizePath(line.substring(3));
// Skip files that don't match include/exclude config
if (!shouldIncludeFile(filePath, config)) continue;
if (statusCode === '??') {
added.push(filePath);
} else if (statusCode.includes('D')) {
deleted.push(filePath);
} else {
// M, MM, AM, A (staged), etc. — treat as modified
modified.push(filePath);
} }
} }
return dirs;
return { modified, added, deleted };
} catch { } catch {
return new Set<string>(); return null;
} }
} }
@@ -135,21 +189,49 @@ function getGitIgnoredDirectories(rootDir: string): Set<string> {
const CODEGRAPH_IGNORE_MARKER = '.codegraphignore'; const CODEGRAPH_IGNORE_MARKER = '.codegraphignore';
/** /**
* Recursively scan directory for source files * Recursively scan directory for source files.
*
* In git repos, uses `git ls-files` to get the file list (inherently
* respects .gitignore at all levels), then filters by config include patterns.
* Falls back to filesystem walk for non-git projects.
*/ */
export function scanDirectory( export function scanDirectory(
rootDir: string, rootDir: string,
config: CodeGraphConfig, config: CodeGraphConfig,
onProgress?: (current: number, file: string) => void onProgress?: (current: number, file: string) => void
): string[] {
// Fast path: use git to get all visible files (respects .gitignore everywhere)
const gitFiles = getGitVisibleFiles(rootDir);
if (gitFiles) {
const files: string[] = [];
let count = 0;
for (const filePath of gitFiles) {
if (shouldIncludeFile(filePath, config)) {
files.push(filePath);
count++;
onProgress?.(count, filePath);
}
}
return files;
}
// Fallback: walk filesystem for non-git projects
return scanDirectoryWalk(rootDir, config, onProgress);
}
/**
* Filesystem walk fallback for non-git projects.
*/
function scanDirectoryWalk(
rootDir: string,
config: CodeGraphConfig,
onProgress?: (current: number, file: string) => void
): string[] { ): string[] {
const files: string[] = []; const files: string[] = [];
let count = 0; let count = 0;
// Track visited real paths to detect symlink cycles
const visitedDirs = new Set<string>(); const visitedDirs = new Set<string>();
const gitIgnoredDirs = getGitIgnoredDirectories(rootDir);
function walk(dir: string): void { function walk(dir: string): void {
// Resolve real path to detect symlink cycles
let realDir: string; let realDir: string;
try { try {
realDir = fs.realpathSync(dir); realDir = fs.realpathSync(dir);
@@ -164,7 +246,7 @@ export function scanDirectory(
} }
visitedDirs.add(realDir); visitedDirs.add(realDir);
// Check for .codegraphignore marker file - skip entire directory tree if present // Check for .codegraphignore marker file
const ignoreMarker = path.join(dir, CODEGRAPH_IGNORE_MARKER); const ignoreMarker = path.join(dir, CODEGRAPH_IGNORE_MARKER);
if (fs.existsSync(ignoreMarker)) { if (fs.existsSync(ignoreMarker)) {
logDebug('Skipping directory due to .codegraphignore marker', { dir }); logDebug('Skipping directory due to .codegraphignore marker', { dir });
@@ -184,17 +266,11 @@ export function scanDirectory(
const fullPath = path.join(dir, entry.name); const fullPath = path.join(dir, entry.name);
const relativePath = normalizePath(path.relative(rootDir, fullPath)); const relativePath = normalizePath(path.relative(rootDir, fullPath));
// Follow symlinked directories, but skip symlinked files to non-project targets
if (entry.isSymbolicLink()) { if (entry.isSymbolicLink()) {
try { try {
const realTarget = fs.realpathSync(fullPath); const realTarget = fs.realpathSync(fullPath);
const stat = fs.statSync(realTarget); const stat = fs.statSync(realTarget);
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check gitignore first (fast O(1) lookup)
if (gitIgnoredDirs.has(relativePath)) {
continue;
}
// Check exclusion, then recurse (cycle detection handles the rest)
const dirPattern = relativePath + '/'; const dirPattern = relativePath + '/';
let excluded = false; let excluded = false;
for (const pattern of config.exclude) { for (const pattern of config.exclude) {
@@ -210,9 +286,7 @@ export function scanDirectory(
if (shouldIncludeFile(relativePath, config)) { if (shouldIncludeFile(relativePath, config)) {
files.push(relativePath); files.push(relativePath);
count++; count++;
if (onProgress) { onProgress?.(count, relativePath);
onProgress(count, relativePath);
}
} }
} }
} catch { } catch {
@@ -222,11 +296,6 @@ export function scanDirectory(
} }
if (entry.isDirectory()) { if (entry.isDirectory()) {
// Check gitignore first (fast O(1) lookup)
if (gitIgnoredDirs.has(relativePath)) {
continue;
}
// Check if directory should be excluded
const dirPattern = relativePath + '/'; const dirPattern = relativePath + '/';
let excluded = false; let excluded = false;
for (const pattern of config.exclude) { for (const pattern of config.exclude) {
@@ -242,9 +311,7 @@ export function scanDirectory(
if (shouldIncludeFile(relativePath, config)) { if (shouldIncludeFile(relativePath, config)) {
files.push(relativePath); files.push(relativePath);
count++; count++;
if (onProgress) { onProgress?.(count, relativePath);
onProgress(count, relativePath);
}
} }
} }
} }
@@ -611,7 +678,8 @@ export class ExtractionOrchestrator {
} }
/** /**
* Sync with current file state * Sync with current file state.
* Uses git status as a fast path when available, falling back to full scan.
*/ */
async sync(onProgress?: (progress: IndexProgress) => void): Promise<SyncResult> { async sync(onProgress?: (progress: IndexProgress) => void): Promise<SyncResult> {
const startTime = Date.now(); const startTime = Date.now();
@@ -620,19 +688,74 @@ export class ExtractionOrchestrator {
let filesModified = 0; let filesModified = 0;
let filesRemoved = 0; let filesRemoved = 0;
let nodesUpdated = 0; let nodesUpdated = 0;
const changedFilePaths: string[] = [];
// Get current files on disk
onProgress?.({ onProgress?.({
phase: 'scanning', phase: 'scanning',
current: 0, current: 0,
total: 0, total: 0,
}); });
const filesToIndex: string[] = [];
const gitChanges = getGitChangedFiles(this.rootDir, this.config);
if (gitChanges) {
// === Git fast path ===
// Only inspect the files git reports as changed instead of scanning everything.
filesChecked = gitChanges.modified.length + gitChanges.added.length + gitChanges.deleted.length;
// Handle deleted files
for (const filePath of gitChanges.deleted) {
const tracked = this.queries.getFileByPath(filePath);
if (tracked) {
this.queries.deleteFile(filePath);
filesRemoved++;
}
}
// Handle modified files — read + hash only these files
for (const filePath of gitChanges.modified) {
const fullPath = path.join(this.rootDir, filePath);
let content: string;
try {
content = fs.readFileSync(fullPath, 'utf-8');
} catch (error) {
captureException(error, { operation: 'sync-read-file', filePath });
logDebug('Skipping unreadable file during sync', { filePath, error: String(error) });
continue;
}
const contentHash = hashContent(content);
const tracked = this.queries.getFileByPath(filePath);
if (!tracked) {
filesToIndex.push(filePath);
changedFilePaths.push(filePath);
filesAdded++;
} else if (tracked.contentHash !== contentHash) {
filesToIndex.push(filePath);
changedFilePaths.push(filePath);
filesModified++;
}
}
// Handle added (untracked) files
for (const filePath of gitChanges.added) {
filesToIndex.push(filePath);
changedFilePaths.push(filePath);
filesAdded++;
}
} else {
// === Fallback: full scan (non-git project or git failure) ===
const currentFiles = new Set(scanDirectory(this.rootDir, this.config)); const currentFiles = new Set(scanDirectory(this.rootDir, this.config));
filesChecked = currentFiles.size; filesChecked = currentFiles.size;
// Get tracked files from database // Build Map for O(1) lookups instead of .find() per file
const trackedFiles = this.queries.getAllFiles(); const trackedFiles = this.queries.getAllFiles();
const trackedMap = new Map<string, FileRecord>();
for (const f of trackedFiles) {
trackedMap.set(f.path, f);
}
// Find files to remove (in DB but not on disk) // Find files to remove (in DB but not on disk)
for (const tracked of trackedFiles) { for (const tracked of trackedFiles) {
@@ -643,8 +766,6 @@ export class ExtractionOrchestrator {
} }
// Find files to add or update // Find files to add or update
const filesToIndex: string[] = [];
for (const filePath of currentFiles) { for (const filePath of currentFiles) {
const fullPath = path.join(this.rootDir, filePath); const fullPath = path.join(this.rootDir, filePath);
let content: string; let content: string;
@@ -657,18 +778,19 @@ export class ExtractionOrchestrator {
} }
const contentHash = hashContent(content); const contentHash = hashContent(content);
const tracked = trackedFiles.find((f) => f.path === filePath); const tracked = trackedMap.get(filePath);
if (!tracked) { if (!tracked) {
// New file
filesToIndex.push(filePath); filesToIndex.push(filePath);
changedFilePaths.push(filePath);
filesAdded++; filesAdded++;
} else if (tracked.contentHash !== contentHash) { } else if (tracked.contentHash !== contentHash) {
// Modified file
filesToIndex.push(filePath); filesToIndex.push(filePath);
changedFilePaths.push(filePath);
filesModified++; filesModified++;
} }
} }
}
// Index changed files // Index changed files
const total = filesToIndex.length; const total = filesToIndex.length;
@@ -692,16 +814,71 @@ export class ExtractionOrchestrator {
filesRemoved, filesRemoved,
nodesUpdated, nodesUpdated,
durationMs: Date.now() - startTime, durationMs: Date.now() - startTime,
changedFilePaths: changedFilePaths.length > 0 ? changedFilePaths : undefined,
}; };
} }
/** /**
* Get files that have changed since last index * Get files that have changed since last index.
* Uses git status as a fast path when available, falling back to full scan.
*/ */
getChangedFiles(): { added: string[]; modified: string[]; removed: string[] } { getChangedFiles(): { added: string[]; modified: string[]; removed: string[] } {
const gitChanges = getGitChangedFiles(this.rootDir, this.config);
if (gitChanges) {
// === Git fast path ===
const added: string[] = [];
const modified: string[] = [];
const removed: string[] = [];
// Deleted files — only report if tracked in DB
for (const filePath of gitChanges.deleted) {
const tracked = this.queries.getFileByPath(filePath);
if (tracked) {
removed.push(filePath);
}
}
// Modified files — read + hash only these, compare with DB
for (const filePath of gitChanges.modified) {
const fullPath = path.join(this.rootDir, filePath);
let content: string;
try {
content = fs.readFileSync(fullPath, 'utf-8');
} catch (error) {
captureException(error, { operation: 'detect-changes-read-file', filePath });
logDebug('Skipping unreadable file while detecting changes', { filePath, error: String(error) });
continue;
}
const contentHash = hashContent(content);
const tracked = this.queries.getFileByPath(filePath);
if (!tracked) {
added.push(filePath);
} else if (tracked.contentHash !== contentHash) {
modified.push(filePath);
}
}
// Added (untracked) files
for (const filePath of gitChanges.added) {
added.push(filePath);
}
return { added, modified, removed };
}
// === Fallback: full scan (non-git project or git failure) ===
const currentFiles = new Set(scanDirectory(this.rootDir, this.config)); const currentFiles = new Set(scanDirectory(this.rootDir, this.config));
const trackedFiles = this.queries.getAllFiles(); const trackedFiles = this.queries.getAllFiles();
// Build Map for O(1) lookups
const trackedMap = new Map<string, FileRecord>();
for (const f of trackedFiles) {
trackedMap.set(f.path, f);
}
const added: string[] = []; const added: string[] = [];
const modified: string[] = []; const modified: string[] = [];
const removed: string[] = []; const removed: string[] = [];
@@ -726,7 +903,7 @@ export class ExtractionOrchestrator {
} }
const contentHash = hashContent(content); const contentHash = hashContent(content);
const tracked = trackedFiles.find((f) => f.path === filePath); const tracked = trackedMap.get(filePath);
if (!tracked) { if (!tracked) {
added.push(filePath); added.push(filePath);
+6 -3
View File
@@ -449,15 +449,18 @@ export class CodeGraph {
// Resolve references if files were updated // Resolve references if files were updated
if (result.filesAdded > 0 || result.filesModified > 0) { if (result.filesAdded > 0 || result.filesModified > 0) {
const unresolvedCount = this.queries.getUnresolvedReferences().length; // Scope resolution to changed files when available (git fast path)
const unresolvedRefs = result.changedFilePaths
? this.queries.getUnresolvedReferencesByFiles(result.changedFilePaths)
: this.queries.getUnresolvedReferences();
options.onProgress?.({ options.onProgress?.({
phase: 'resolving', phase: 'resolving',
current: 0, current: 0,
total: unresolvedCount, total: unresolvedRefs.length,
}); });
this.resolveReferences((current, total) => { this.resolver.resolveAndPersist(unresolvedRefs, (current, total) => {
options.onProgress?.({ options.onProgress?.({
phase: 'resolving', phase: 'resolving',
current, current,