mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-16 23:43:12 +00:00
* feat(analyze): add incremental watch mode * fix(watch): harden control file reads * fix(watch): contain refresh errors and bound reads * fix(watch): stream strict control file reads * fix(watch): harden refresh recovery and lifecycle * fix(watch): report ignored repository defaults * fix(analyze): preserve signal exit semantics * style(analyze): format signal exit helper * test(config): exercise descriptor growth guard * test(watch): await source event before rename * fix(watch): keep live-index retries honest and ignore analyzer writes Hold retry backoff when events merge, stop only after a live-index mutation, skip .gitnexus self-writes, and reject the remaining one-shot watch flags. Export impact-risk scoring from gitnexus-shared so consumers can share the same scale. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(watch): contain queue edge cases after review Preserve overflow-only refreshes, contain synchronous refresh failures, and mark successful atomic publication before later operations can fail. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
348 lines
9.9 KiB
TypeScript
348 lines
9.9 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { WATCH_FULL_REFRESH_PATH, WatchRefreshQueue } from '../../src/cli/watch-queue.js';
|
|
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
describe('WatchRefreshQueue', () => {
|
|
it('propagates an initial refresh failure without reporting it as retryable', async () => {
|
|
const onError = vi.fn();
|
|
const queue = new WatchRefreshQueue(
|
|
async () => {
|
|
throw new Error('initial analyze failed');
|
|
},
|
|
onError,
|
|
10,
|
|
);
|
|
|
|
await expect(queue.runInitial()).rejects.toThrow('initial analyze failed');
|
|
expect(onError).not.toHaveBeenCalled();
|
|
await queue.close();
|
|
});
|
|
|
|
it('debounces and deduplicates rapid writes', async () => {
|
|
vi.useFakeTimers();
|
|
const batches: readonly string[][] = [];
|
|
const mutable = batches as string[][];
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => mutable.push([...paths]),
|
|
() => {},
|
|
100,
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
queue.enqueue('src/b.ts');
|
|
await vi.advanceTimersByTimeAsync(99);
|
|
expect(batches).toEqual([]);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await queue.waitForIdle();
|
|
|
|
expect(batches).toEqual([['src/a.ts', 'src/b.ts']]);
|
|
});
|
|
|
|
it('queues edits made during a refresh and never overlaps writers', async () => {
|
|
vi.useFakeTimers();
|
|
let releaseFirst!: () => void;
|
|
let active = 0;
|
|
let peak = 0;
|
|
const batches: string[][] = [];
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => {
|
|
active++;
|
|
peak = Math.max(peak, active);
|
|
batches.push([...paths]);
|
|
if (batches.length === 1) await new Promise<void>((resolve) => (releaseFirst = resolve));
|
|
active--;
|
|
},
|
|
() => {},
|
|
100,
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
queue.enqueue('src/b.ts');
|
|
queue.enqueue('src/c.ts');
|
|
releaseFirst();
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
await queue.waitForIdle();
|
|
|
|
expect(peak).toBe(1);
|
|
expect(batches).toEqual([['src/a.ts'], ['src/b.ts', 'src/c.ts']]);
|
|
});
|
|
|
|
it('retries a failed refresh without dropping its batch', async () => {
|
|
vi.useFakeTimers();
|
|
const errors: string[][] = [];
|
|
const successful: string[][] = [];
|
|
let attempts = 0;
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => {
|
|
attempts++;
|
|
if (attempts === 1) throw new Error('failed');
|
|
successful.push([...paths]);
|
|
},
|
|
(_error, paths) => errors.push([...paths]),
|
|
10,
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(errors).toEqual([['src/a.ts']]);
|
|
expect(successful).toEqual([]);
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await queue.waitForIdle();
|
|
|
|
expect(errors).toEqual([['src/a.ts']]);
|
|
expect(successful).toEqual([['src/a.ts']]);
|
|
});
|
|
|
|
it('parks pre-ready events until the initial refresh completes', async () => {
|
|
vi.useFakeTimers();
|
|
const batches: string[][] = [];
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => batches.push([...paths]),
|
|
() => {},
|
|
50,
|
|
{ holdEventsUntilInitialRefresh: true },
|
|
);
|
|
|
|
queue.enqueue('src/during-walk.ts');
|
|
await vi.advanceTimersByTimeAsync(80);
|
|
expect(batches).toEqual([]);
|
|
|
|
await queue.runInitial();
|
|
expect(batches).toEqual([[]]);
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
await queue.waitForIdle();
|
|
expect(batches).toEqual([[], ['src/during-walk.ts']]);
|
|
});
|
|
|
|
it('backs off repeated failures instead of spinning at the debounce interval', async () => {
|
|
vi.useFakeTimers();
|
|
let attempts = 0;
|
|
const queue = new WatchRefreshQueue(
|
|
async () => {
|
|
attempts++;
|
|
if (attempts < 4) throw new Error('still unavailable');
|
|
},
|
|
() => {},
|
|
10,
|
|
{ retryBaseDelayMs: 100, retryMaxDelayMs: 400 },
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(attempts).toBe(1);
|
|
await vi.advanceTimersByTimeAsync(99);
|
|
expect(attempts).toBe(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(attempts).toBe(2);
|
|
await vi.advanceTimersByTimeAsync(199);
|
|
expect(attempts).toBe(2);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(attempts).toBe(3);
|
|
await vi.advanceTimersByTimeAsync(399);
|
|
expect(attempts).toBe(3);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await queue.waitForIdle();
|
|
expect(attempts).toBe(4);
|
|
});
|
|
|
|
it('merges an event during retry backoff without shortening the retry delay', async () => {
|
|
vi.useFakeTimers();
|
|
const batches: string[][] = [];
|
|
let attempts = 0;
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => {
|
|
attempts++;
|
|
if (attempts === 1) throw new Error('failed');
|
|
batches.push([...paths]);
|
|
},
|
|
() => {},
|
|
10,
|
|
{ retryBaseDelayMs: 1_000 },
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(attempts).toBe(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
queue.enqueue('src/b.ts');
|
|
await vi.advanceTimersByTimeAsync(899);
|
|
expect(attempts).toBe(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await queue.waitForIdle();
|
|
|
|
expect(attempts).toBe(2);
|
|
expect(batches).toEqual([['src/a.ts', 'src/b.ts']]);
|
|
});
|
|
|
|
it('contains a throwing error reporter for a detached refresh', async () => {
|
|
vi.useFakeTimers();
|
|
const queue = new WatchRefreshQueue(
|
|
async () => {
|
|
throw new Error('refresh failed');
|
|
},
|
|
async () => {
|
|
throw new Error('reporting failed');
|
|
},
|
|
10,
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
await queue.close();
|
|
await expect(queue.waitForIdle()).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('contains a synchronously throwing refresh and retries its batch', async () => {
|
|
vi.useFakeTimers();
|
|
const errors: string[][] = [];
|
|
const successful: string[][] = [];
|
|
let attempts = 0;
|
|
const queue = new WatchRefreshQueue(
|
|
(paths) => {
|
|
attempts++;
|
|
if (attempts === 1) throw new Error('synchronous refresh failure');
|
|
successful.push([...paths]);
|
|
return Promise.resolve();
|
|
},
|
|
(_error, paths) => errors.push([...paths]),
|
|
10,
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(errors).toEqual([['src/a.ts']]);
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await queue.waitForIdle();
|
|
|
|
expect(attempts).toBe(2);
|
|
expect(successful).toEqual([['src/a.ts']]);
|
|
});
|
|
|
|
it('closes cleanly when a refresh failure triggers shutdown', async () => {
|
|
vi.useFakeTimers();
|
|
let closePromise: Promise<void> | undefined;
|
|
const queue = new WatchRefreshQueue(
|
|
async () => {
|
|
throw new Error('stop after failure');
|
|
},
|
|
() => {
|
|
closePromise = queue.close();
|
|
},
|
|
10,
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
expect(closePromise).toBeDefined();
|
|
await expect(closePromise).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('flushes by max wait even when writes never become quiet', async () => {
|
|
vi.useFakeTimers();
|
|
const batches: string[][] = [];
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => batches.push([...paths]),
|
|
() => {},
|
|
100,
|
|
{ maxWaitMs: 250 },
|
|
);
|
|
|
|
queue.enqueue('src/0.ts');
|
|
await vi.advanceTimersByTimeAsync(90);
|
|
queue.enqueue('src/1.ts');
|
|
await vi.advanceTimersByTimeAsync(90);
|
|
queue.enqueue('src/2.ts');
|
|
await vi.advanceTimersByTimeAsync(70);
|
|
await queue.waitForIdle();
|
|
|
|
expect(batches).toEqual([['src/0.ts', 'src/1.ts', 'src/2.ts']]);
|
|
});
|
|
|
|
it('bounds high-cardinality paths while retaining priority control files', async () => {
|
|
vi.useFakeTimers();
|
|
const batches: string[][] = [];
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => batches.push([...paths]),
|
|
() => {},
|
|
10,
|
|
{
|
|
maxPendingPaths: 2,
|
|
isPriorityPath: (filePath) => filePath === '.gitnexusrc',
|
|
},
|
|
);
|
|
|
|
for (let index = 0; index < 20; index++) queue.enqueue(`src/${index}.ts`);
|
|
queue.enqueue('.gitnexusrc');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await queue.waitForIdle();
|
|
|
|
expect(batches).toEqual([[WATCH_FULL_REFRESH_PATH, '.gitnexusrc', 'src/1.ts']]);
|
|
});
|
|
|
|
it('bounds a flood of distinct priority paths', async () => {
|
|
vi.useFakeTimers();
|
|
const batches: string[][] = [];
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => batches.push([...paths]),
|
|
() => {},
|
|
10,
|
|
{
|
|
maxPendingPaths: 2,
|
|
isPriorityPath: (filePath) => filePath.endsWith('/.gitignore'),
|
|
},
|
|
);
|
|
|
|
for (let index = 0; index < 20; index++) queue.enqueue(`packages/${index}/.gitignore`);
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await queue.waitForIdle();
|
|
|
|
expect(batches).toEqual([
|
|
[WATCH_FULL_REFRESH_PATH, 'packages/0/.gitignore', 'packages/1/.gitignore'],
|
|
]);
|
|
});
|
|
|
|
it('does not report overflow for a duplicate at capacity', async () => {
|
|
vi.useFakeTimers();
|
|
const batches: string[][] = [];
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => batches.push([...paths]),
|
|
() => {},
|
|
10,
|
|
{ maxPendingPaths: 2 },
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
queue.enqueue('src/b.ts');
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await queue.waitForIdle();
|
|
|
|
expect(batches).toEqual([['src/a.ts', 'src/b.ts']]);
|
|
});
|
|
|
|
it('runs a full refresh when the pending-path limit is zero', async () => {
|
|
vi.useFakeTimers();
|
|
const batches: string[][] = [];
|
|
const queue = new WatchRefreshQueue(
|
|
async (paths) => batches.push([...paths]),
|
|
() => {},
|
|
10,
|
|
{ maxPendingPaths: 0 },
|
|
);
|
|
|
|
queue.enqueue('src/a.ts');
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await queue.waitForIdle();
|
|
|
|
expect(batches).toEqual([[WATCH_FULL_REFRESH_PATH]]);
|
|
});
|
|
});
|