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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

#### MCP / indexing

- While auto-sync is off, `codegraph_search`, `codegraph_callers`, `codegraph_callees` and `codegraph_impact` likewise refuse an answer that names a file changed since its last sync, and name that file instead. (#1959)
- While auto-sync is off, `codegraph_explore` no longer answers from files that changed since their last sync: it names them so they can be read directly, and keeps answering from files that did not change. (#1959)
- `codegraph_status` over MCP now reports when files were last indexed and how many were added, changed or removed since, computed without blocking other requests, so a frozen index shows up as numbers rather than only a banner. (#1959)
- After a long stretch of contention for the index lock, the next MCP call restarts file watching and runs a full catch-up instead of leaving auto-sync off for the rest of the session; answers say the index may be stale until the catch-up finishes. (#1959)
- 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.
- Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829)
Expand Down
23 changes: 23 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8248,6 +8248,29 @@ describe('Nested non-submodule git repos', () => {
expect(ig.ignores('scratch/tmp.ts')).toBe(true);
});

it('filesystem fallback retains git info/exclude and core.excludesFile when ls-files fails (#1959)', async () => {
const { execFileSync } = await import('child_process');
const root = path.join(tempDir, 'fallback-excludes-root');
fs.mkdirSync(root, { recursive: true });
execFileSync('git', ['init', '-q'], { cwd: root, stdio: 'pipe' });
const globalExcludes = path.join(tempDir, 'fallback-global-excludes');
fs.writeFileSync(globalExcludes, 'scratch/\n');
execFileSync('git', ['config', 'core.excludesFile', globalExcludes], { cwd: root, stdio: 'pipe' });
fs.writeFileSync(path.join(root, '.git', 'info', 'exclude'), 'worktrees/\n');
fs.mkdirSync(path.join(root, 'scratch'));
fs.mkdirSync(path.join(root, 'worktrees'));
fs.writeFileSync(path.join(root, 'app.ts'), 'export const app = 1;\n');
fs.writeFileSync(path.join(root, 'scratch', 'hidden.ts'), 'export const hidden = 1;\n');
fs.writeFileSync(path.join(root, 'worktrees', 'hidden.ts'), 'export const hidden = 2;\n');

// rev-parse/config still work, but both ls-files and status fail as they
// would under a Git timeout. This exercises the real filesystem walk.
fs.writeFileSync(path.join(root, '.git', 'index'), 'not a git index');
expect(() => execFileSync('git', ['ls-files'], { cwd: root, stdio: 'pipe' })).toThrow();
expect(scanDirectory(root)).toEqual(['app.ts']);
expect(await scanDirectoryAsync(root)).toEqual(['app.ts']);
});

it('buildScopeIgnore prunes dirs ignored only by a nested .gitignore (#1728)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
Expand Down
98 changes: 98 additions & 0 deletions __tests__/mcp-stale-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { ExploreSessionState } from '../src/mcp/explore-session-state';
import { ToolHandler } from '../src/mcp/tools';
import { __setFsWatchForTests } from '../src/sync/watcher';

describe('a degraded index refuses answers from changed files (#1959)', () => {
let root: string;
let cg: CodeGraph;
let handler: ToolHandler;

beforeEach(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-refusal-'));
fs.writeFileSync(path.join(root, 'alpha.ts'), 'export function alphaOnly() { return 1; }\n');
fs.writeFileSync(path.join(root, 'beta.ts'), 'export function betaOnly() { return 2; }\n');
fs.writeFileSync(
path.join(root, 'gamma.ts'),
"import { alphaOnly } from './alpha';\nexport function gammaUses() { return alphaOnly(); }\n"
);
cg = CodeGraph.initSync(root);
await cg.indexAll();
handler = new ToolHandler(cg);

__setFsWatchForTests(() => {
const err = new Error('too many open files') as NodeJS.ErrnoException;
err.code = 'EMFILE';
throw err;
});
expect(cg.watch()).toBe(false);
expect(cg.isWatcherDegraded()).toBe(true);
__setFsWatchForTests(null);
});

afterEach(() => {
__setFsWatchForTests(null);
try { cg.unwatch(); } catch { /* ignore */ }
try { cg.close(); } catch { /* ignore */ }
fs.rmSync(root, { recursive: true, force: true });
});

it('names a changed file without serving its result, but keeps unaffected source available', async () => {
const alphaPath = path.join(root, 'alpha.ts');
const primed = await handler.execute('codegraph_explore', { query: 'alphaOnly' });
expect(primed.content[0].text).toContain('export function alphaOnly');
const before = fs.statSync(alphaPath);
fs.writeFileSync(alphaPath, 'export function alphaOnly() { return 9; }\n');
// Identical size and indexed mtime: the last-mile guard must hash bytes,
// not trust metadata or a prior two-second drift-cache verdict.
fs.utimesSync(alphaPath, before.atime, before.mtime);

const session = new ExploreSessionState();
const refused = await handler.execute('codegraph_explore', { query: 'alphaOnly' }, session);
expect(refused.isError).toBeFalsy();
expect(refused.content[0].text).toContain('alpha.ts');
expect(refused.content[0].text).toContain('cannot answer from this index');
expect(refused.content[0].text).not.toContain('export function alphaOnly');
expect(session.view().projects).toEqual([]);

const unaffected = await handler.execute('codegraph_explore', { query: 'betaOnly' }, session);
expect(unaffected.content[0].text).toContain('export function betaOnly');
expect(unaffected.content[0].text).toContain('auto-sync is DISABLED');

// Let the ordinary sync see a definite metadata change, then ensure the
// same session receives the source it was not shown before.
fs.utimesSync(alphaPath, before.atime, new Date(before.mtimeMs + 2000));
await cg.sync();
const refreshed = await handler.execute('codegraph_explore', { query: 'alphaOnly' }, session);
expect(refreshed.content[0].text).toContain('export function alphaOnly');
expect(refreshed.content[0].text).toContain('return 9');
expect(refreshed.content[0].text).not.toContain('cannot answer from this index');
});

it('refuses a graph answer that names a changed file, and serves one that does not', async () => {
const callers = await handler.execute('codegraph_callers', { symbol: 'alphaOnly' });
expect(callers.content[0].text).toContain('gammaUses');

fs.writeFileSync(
path.join(root, 'gamma.ts'),
"import { alphaOnly } from './alpha';\nexport function gammaUses() { return 0; }\n"
);

const refused = await handler.execute('codegraph_callers', { symbol: 'alphaOnly' });
expect(refused.isError).toBeFalsy();
expect(refused.content[0].text).toContain('cannot answer from this index');
expect(refused.content[0].text).toContain('- gamma.ts');
expect(refused.content[0].text).not.toContain('gammaUses');

const search = await handler.execute('codegraph_search', { query: 'gammaUses' });
expect(search.content[0].text).toContain('cannot answer from this index');

const unaffected = await handler.execute('codegraph_search', { query: 'betaOnly' });
expect(unaffected.content[0].text).toContain('beta.ts');
expect(unaffected.content[0].text).not.toContain('cannot answer from this index');
});
});
23 changes: 22 additions & 1 deletion __tests__/mcp-staleness-banner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* left untouched.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
Expand Down Expand Up @@ -72,6 +72,7 @@ describe('MCP staleness banner', () => {

afterEach(() => {
__setFsWatchForTests(null); // reset the injected fs.watch seam
vi.restoreAllMocks();
try { cg.unwatch(); } catch { /* ignore */ }
try { cg.close(); } catch { /* ignore */ }
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
Expand Down Expand Up @@ -209,4 +210,24 @@ describe('MCP staleness banner', () => {
// status renders the notice inline, so the auto-banner is not also prepended.
expect(text.startsWith('⚠️')).toBe(false);
});

it('distinguishes a re-armed but not-yet-caught-up watcher from a disabled one (#1959)', async () => {
vi.spyOn(cg, 'isWatcherDegraded').mockReturnValue(true);
vi.spyOn(cg, 'isWatcherRecovering').mockReturnValue(true);

const search = await handler.execute('codegraph_search', { query: 'alphaOnly' });
expect(search.content[0].text).toMatch(/auto-sync is RECOVERING/);
expect(search.content[0].text).not.toMatch(/auto-sync is DISABLED/);

const status = await handler.execute('codegraph_status', {});
expect(status.content[0].text).toContain('**Auto-sync recovering:**');
expect(status.content[0].text).not.toContain('**Auto-sync disabled:**');
});

it('asks the owned watcher to re-arm on the next MCP tool call (#1959)', async () => {
const rearm = vi.spyOn(cg, 'rearmWatcherAfterLockContention').mockReturnValue(false);

await handler.execute('codegraph_status', {});
expect(rearm).toHaveBeenCalledTimes(1);
});
});
60 changes: 60 additions & 0 deletions __tests__/mcp-status-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execFileSync } from 'child_process';
import CodeGraph from '../src/index';
import { measurePendingChanges } from '../src/mcp/index-freshness';
import { ToolHandler } from '../src/mcp/tools';

describe('MCP status freshness (#1959)', () => {
let root: string;
let cg: CodeGraph;
let handler: ToolHandler;

beforeEach(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-status-freshness-'));
fs.writeFileSync(path.join(root, 'modify.ts'), 'export const modify = 1;\n');
fs.writeFileSync(path.join(root, 'remove.ts'), 'export const remove = 1;\n');
const git = (...args: string[]) => execFileSync('git', args, { cwd: root, stdio: 'pipe' });
git('init', '-q');
git('config', 'user.name', 'CodeGraph Test');
git('config', 'user.email', 'codegraph-test@example.invalid');
git('add', 'modify.ts', 'remove.ts');
git('commit', '-qm', 'baseline');
cg = CodeGraph.initSync(root, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});

afterEach(() => {
try { cg.close(); } catch { /* ignore */ }
fs.rmSync(root, { recursive: true, force: true });
});

it('reports the latest indexed file and exact change counts', async () => {
const initial = (await handler.execute('codegraph_status', {})).content[0].text;
expect(initial).toMatch(/\*\*Latest file indexed:\*\* \d{4}-\d\d-\d\dT/);
expect(initial).toContain('**Changes since index:** 0 added, 0 modified, 0 removed');

fs.writeFileSync(path.join(root, 'modify.ts'), 'export const modify = 42;\n');
fs.unlinkSync(path.join(root, 'remove.ts'));
fs.writeFileSync(path.join(root, 'add.ts'), 'export const added = 1;\n');

const changed = (await handler.execute('codegraph_status', {})).content[0].text;
expect(changed).toContain('**Changes since index:** 1 added, 1 modified, 1 removed');
});

it('returns unknown rather than a false zero when the measurement cannot open an index', async () => {
expect(await measurePendingChanges(path.join(root, 'missing'))).toBeNull();
});

it('counts edits committed after the index even when the working tree is clean', async () => {
fs.writeFileSync(path.join(root, 'modify.ts'), 'export const modify = 99;\n');
execFileSync('git', ['add', 'modify.ts'], { cwd: root, stdio: 'pipe' });
execFileSync('git', ['commit', '-qm', 'changed'], { cwd: root, stdio: 'pipe' });

const status = (await handler.execute('codegraph_status', {})).content[0].text;
expect(status).toContain('**Changes since index:** 0 added, 1 modified, 0 removed');
});
});
120 changes: 120 additions & 0 deletions __tests__/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,53 @@ describe('FileWatcher', () => {

watcher.stop();
});

it('re-arms after lock contention and keeps the stale banner until a full catch-up (#1959)', async () => {
let finishCatchUp!: () => void;
const catchUp = new Promise<void>(resolve => { finishCatchUp = resolve; });
const syncFn = vi.fn().mockRejectedValue(new LockUnavailableError());
const watcher = newWatcher(syncFn, { debounceMs: 25 });
watcher.start();
try {
await watcher.waitUntilReady();
__emitWatchEventForTests(testDir, 'src/locked.ts');
await waitFor(() => watcher.isDegraded() && !watcher.isActive(), 8000);

syncFn.mockImplementation(async () => {
await catchUp;
return { filesChanged: 1, durationMs: 5 };
});
expect(watcher.rearmAfterLockContention()).toBe(true);
expect(watcher.isActive()).toBe(true);
expect(watcher.isDegraded()).toBe(true);
expect(watcher.rearmAfterLockContention()).toBe(false);
await waitFor(() => syncFn.mock.calls.length >= 7, 4000);
expect(syncFn.mock.calls.at(-1)?.[0]).toBeUndefined();
expect(watcher.isDegraded()).toBe(true);
finishCatchUp();
await waitFor(() => !watcher.isDegraded(), 4000);
expect(watcher.isActive()).toBe(true);
} finally {
finishCatchUp?.();
watcher.stop();
}
});

it('throttles a failed re-arm across tool calls (#1959)', async () => {
const syncFn = vi.fn().mockRejectedValue(new LockUnavailableError());
const watcher = newWatcher(syncFn, { debounceMs: 25 });
watcher.start();
try {
await watcher.waitUntilReady();
__emitWatchEventForTests(testDir, 'src/locked.ts');
await waitFor(() => watcher.isDegraded() && !watcher.isActive(), 8000);
expect(watcher.rearmAfterLockContention()).toBe(true);
await waitFor(() => watcher.isDegraded() && !watcher.isActive(), 8000);
expect(watcher.rearmAfterLockContention()).toBe(false);
} finally {
watcher.stop();
}
});
});

describe('persistent sync-failure degradation (#1127)', () => {
Expand Down Expand Up @@ -349,6 +396,7 @@ describe('FileWatcher', () => {

expect(syncFn.mock.calls.length).toBeGreaterThanOrEqual(6); // MAX_SYNC_FAILURE_RETRIES + 1
expect(watcher.isDegraded()).toBe(true);
expect(watcher.rearmAfterLockContention()).toBe(false);
expect(onDegraded).toHaveBeenCalledTimes(1);
expect(onDegraded).toHaveBeenCalledWith(expect.stringContaining('auto-sync disabled'));
// The degrade reason carries the underlying error so the user can act.
Expand Down Expand Up @@ -968,6 +1016,78 @@ describe('FileWatcher', () => {
expect(calls[0]).toBeUndefined();
});

it.each([
['lock contention', () => new LockUnavailableError()],
['sync failure', () => new Error('injected sync failure')],
])('retries a full-only directory removal after %s (#1964)', async (_case, failure) => {
const syncFn = vi.fn()
.mockRejectedValueOnce(failure())
.mockResolvedValue({ filesChanged: 0, durationMs: 5 });
const watcher = newWatcher(syncFn, { debounceMs: 25 });
watcher.start();
try {
await watcher.waitUntilReady();
__emitWatchEventForTests(testDir, 'src/removed-dir');
await waitFor(() => syncFn.mock.calls.length >= 2, 4000);
expect(syncFn.mock.calls.map(call => call[0])).toEqual([undefined, undefined]);
} finally {
watcher.stop();
}
});

it('follows a scoped sync with a full scan when a directory is removed mid-sync (#1964)', async () => {
let release!: () => void;
const firstRun = new Promise<void>(resolve => { release = resolve; });
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async paths => {
calls.push(paths);
if (calls.length === 1) await firstRun;
return { filesChanged: 0, durationMs: 5 };
};
const watcher = newWatcher(syncFn, { debounceMs: 25 });
watcher.start();
try {
await watcher.waitUntilReady();
fs.writeFileSync(path.join(testDir, 'src', 'a.ts'), 'export const a = 1;');
__emitWatchEventForTests(testDir, 'src/a.ts');
await waitFor(() => calls.length === 1, 4000);
__emitWatchEventForTests(testDir, 'src/removed-dir');
release();
await waitFor(() => calls.length >= 2, 4000);
expect(calls).toEqual([['src/a.ts'], undefined]);
} finally {
release();
watcher.stop();
}
});

it('follows a scoped sync with a full scan when scope changes mid-sync (#1964)', async () => {
let release!: () => void;
const firstRun = new Promise<void>(resolve => { release = resolve; });
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async paths => {
calls.push(paths);
if (calls.length === 1) await firstRun;
return { filesChanged: 0, durationMs: 5 };
};
const watcher = newWatcher(syncFn, { debounceMs: 25 });
watcher.start();
try {
await watcher.waitUntilReady();
fs.writeFileSync(path.join(testDir, 'src', 'a.ts'), 'export const a = 1;');
__emitWatchEventForTests(testDir, 'src/a.ts');
await waitFor(() => calls.length === 1, 4000);
fs.writeFileSync(path.join(testDir, '.gitignore'), 'src/ignored/\n');
__emitWatchEventForTests(testDir, '.gitignore');
release();
await waitFor(() => calls.length >= 2, 4000);
expect(calls).toEqual([['src/a.ts'], undefined]);
} finally {
release();
watcher.stop();
}
});

it('a lone file event fires on the quick window, well before the full debounce', async () => {
const calls: (string[] | undefined)[] = [];
const syncFn: SyncFn = async (paths?: string[]) => {
Expand Down
Loading