Skip to content
Merged
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
18 changes: 17 additions & 1 deletion src/core/file-watcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import chokidar from 'chokidar';
import path from 'path';
import { getSupportedExtensions } from '../utils/language-detection.js';

export interface FileWatcherOptions {
rootPath: string;
Expand All @@ -8,6 +10,19 @@ export interface FileWatcherOptions {
onChanged: () => void;
}

const TRACKED_EXTENSIONS = new Set(
getSupportedExtensions().map((extension) => extension.toLowerCase())
);

const TRACKED_METADATA_FILES = new Set(['.gitignore']);

function isTrackedSourcePath(filePath: string): boolean {
const basename = path.basename(filePath).toLowerCase();
if (TRACKED_METADATA_FILES.has(basename)) return true;
const extension = path.extname(filePath).toLowerCase();
return extension.length > 0 && TRACKED_EXTENSIONS.has(extension);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reindex on extensionless metadata changes

The new extension filter returns early for any path without an extension, which means edits to files like .gitignore never trigger auto-refresh. CodebaseIndexer.scanFiles applies .gitignore when building the indexed file set, so changing ignore rules can add/remove indexed files, but with this gate the index remains stale until an unrelated tracked source edit (or manual refresh) occurs.

Useful? React with 👍 / 👎.

}

/**
* Watch rootPath for source file changes and call onChanged (debounced).
* Returns a stop() function that cancels the debounce timer and closes the watcher.
Expand All @@ -16,7 +31,8 @@ export function startFileWatcher(opts: FileWatcherOptions): () => void {
const { rootPath, debounceMs = 2000, onChanged } = opts;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;

const trigger = () => {
const trigger = (filePath: string) => {
if (!isTrackedSourcePath(filePath)) return;
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = undefined;
Expand Down
145 changes: 145 additions & 0 deletions tests/auto-refresh-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import { startFileWatcher } from '../src/core/file-watcher.js';
import { createAutoRefreshController } from '../src/core/auto-refresh.js';
import { CodebaseIndexer } from '../src/core/indexer.js';
import {
CODEBASE_CONTEXT_DIRNAME,
KEYWORD_INDEX_FILENAME
} from '../src/constants/codebase-context.js';

type IndexStatus = 'idle' | 'indexing' | 'ready' | 'error';

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function getKeywordChunks(raw: unknown): Array<Record<string, unknown>> {
if (Array.isArray(raw)) {
return raw.filter(isRecord);
}
if (!isRecord(raw)) return [];
if (!Array.isArray(raw.chunks)) return [];
return raw.chunks.filter(isRecord);
}

async function readIndexedContent(rootPath: string): Promise<string> {
const indexPath = path.join(rootPath, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME);
const raw = JSON.parse(await fs.readFile(indexPath, 'utf-8')) as unknown;
const chunks = getKeywordChunks(raw);
return chunks
.map((chunk) => (typeof chunk.content === 'string' ? chunk.content : ''))
.join('\n');
}

async function waitFor(
condition: () => Promise<boolean>,
timeoutMs: number,
intervalMs: number
): Promise<void> {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < timeoutMs) {
try {
if (await condition()) return;
lastError = undefined;
} catch (error) {
lastError = error;
}
await sleep(intervalMs);
}
const reason =
lastError instanceof Error && lastError.message
? ` Last transient error: ${lastError.message}`
: '';
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${reason}`);
}

describe('Auto-refresh E2E', () => {
let tempDir: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-refresh-e2e-'));
await fs.mkdir(path.join(tempDir, 'src'), { recursive: true });
await fs.writeFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'watch-test' }));
await fs.writeFile(path.join(tempDir, 'src', 'app.ts'), 'export const token = "INITIAL_TOKEN";\n');
});

afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});

it('updates index after a file edit without manual refresh_index', async () => {
await new CodebaseIndexer({
rootPath: tempDir,
config: { skipEmbedding: true }
}).index();

const initialContent = await readIndexedContent(tempDir);
expect(initialContent).toContain('INITIAL_TOKEN');
expect(initialContent).not.toContain('UPDATED_TOKEN');

const autoRefresh = createAutoRefreshController();
let indexStatus: IndexStatus = 'ready';
let incrementalRuns = 0;

const runIncrementalIndex = async (): Promise<void> => {
if (indexStatus === 'indexing') return;
indexStatus = 'indexing';

try {
await new CodebaseIndexer({
rootPath: tempDir,
config: { skipEmbedding: true },
incrementalOnly: true
}).index();
indexStatus = 'ready';
} catch (error) {
indexStatus = 'error';
throw error;
}

if (autoRefresh.consumeQueuedRefresh(indexStatus)) {
incrementalRuns++;
void runIncrementalIndex();
}
};

const stopWatcher = startFileWatcher({
rootPath: tempDir,
debounceMs: 200,
onChanged: () => {
const shouldRunNow = autoRefresh.onFileChange(indexStatus === 'indexing');
if (!shouldRunNow) return;
incrementalRuns++;
void runIncrementalIndex();
}
});

try {
await sleep(250);
await fs.writeFile(path.join(tempDir, 'src', 'app.ts'), 'export const token = "UPDATED_TOKEN";\n');

await waitFor(
async () => {
const content = await readIndexedContent(tempDir);
return content.includes('UPDATED_TOKEN');
Comment on lines +131 to +132

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry transient index reads during E2E polling

The polling callback reads and parses the keyword index on each iteration, but it does not handle transient read/parse failures while incremental indexing rewrites that file. In those moments readIndexedContent can throw (e.g., temporary ENOENT or partial JSON), and waitFor exits immediately instead of retrying, making this test flaky under slower I/O timing.

Useful? React with 👍 / 👎.

},
15000,
200
);

const updatedContent = await readIndexedContent(tempDir);
expect(updatedContent).toContain('UPDATED_TOKEN');
expect(incrementalRuns).toBeGreaterThan(0);
} finally {
stopWatcher();
}
}, 20000);
});
44 changes: 44 additions & 0 deletions tests/file-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,48 @@ describe('FileWatcher', () => {
await new Promise((resolve) => setTimeout(resolve, debounceMs + 200));
expect(callCount).toBe(0);
}, 5000);

it('ignores changes to non-tracked file extensions', async () => {
const debounceMs = 250;
let callCount = 0;

const stop = startFileWatcher({
rootPath: tempDir,
debounceMs,
onChanged: () => {
callCount++;
}
});

try {
await new Promise((resolve) => setTimeout(resolve, 100));
await fs.writeFile(path.join(tempDir, 'notes.txt'), 'this should be ignored');
await new Promise((resolve) => setTimeout(resolve, debounceMs + 700));
expect(callCount).toBe(0);
} finally {
stop();
}
}, 5000);

it('triggers on .gitignore changes', async () => {
const debounceMs = 250;
let callCount = 0;

const stop = startFileWatcher({
rootPath: tempDir,
debounceMs,
onChanged: () => {
callCount++;
}
});

try {
await new Promise((resolve) => setTimeout(resolve, 100));
await fs.writeFile(path.join(tempDir, '.gitignore'), 'dist/\n');
await new Promise((resolve) => setTimeout(resolve, debounceMs + 700));
expect(callCount).toBe(1);
} finally {
stop();
}
}, 5000);
});
Loading