GitNexus/gitnexus/test/unit/watch-paths.test.ts
azizur100389 f8036ac349
feat(analyze): add explicit FTS opt-out (#3205)
* feat(analyze): add explicit FTS opt-out

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(analyze): treat flag and env FTS opt-out as one mode

Avoid a same-commit rebuild when only the skipReason discriminator
changes, and advertise disablement on dirty meta before leftover
indexes are wiped.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 14:05:28 +00:00

202 lines
8.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createWatchIgnorePredicate } from '../../src/config/ignore-service.js';
import { isRelevantWatchPath, resolveWatchOptions } from '../../src/cli/analyze-watch.js';
import * as git from '../../src/storage/git.js';
vi.mock('../../src/storage/git.js', () => ({
getCoreExcludesFilePath: vi.fn(),
getGitInfoExcludePath: vi.fn(),
}));
let repoPath: string;
beforeEach(async () => {
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-'));
vi.mocked(git.getCoreExcludesFilePath).mockReturnValue(null);
vi.mocked(git.getGitInfoExcludePath).mockReturnValue(null);
});
afterEach(async () => {
await fs.rm(repoPath, { recursive: true, force: true });
});
describe('watch path selection', () => {
it('forwards explicit FTS opt-out without changing the default', async () => {
const names = [
'GITNEXUS_MAX_FILE_SIZE',
'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
'GITNEXUS_VERBOSE',
] as const;
for (const name of names) vi.stubEnv(name, process.env[name]);
const baseline = { maxFileSize: undefined, workerTimeout: undefined, verbose: undefined };
try {
expect((await resolveWatchOptions(repoPath, { skipFts: true }, baseline)).skipFts).toBe(true);
expect((await resolveWatchOptions(repoPath, {}, baseline)).skipFts).toBeUndefined();
} finally {
vi.unstubAllEnvs();
}
});
it('accepts every scanner-admitted file instead of maintaining a second allow-list', () => {
expect(isRelevantWatchPath('src/service.ts')).toBe(true);
expect(isRelevantWatchPath('server/app.py')).toBe(true);
expect(isRelevantWatchPath('backend/project.csproj')).toBe(true);
expect(isRelevantWatchPath('.gitnexusrc')).toBe(true);
expect(isRelevantWatchPath('README.md')).toBe(true);
expect(isRelevantWatchPath('docs/guide.mdx')).toBe(true);
expect(isRelevantWatchPath('config/application-prod.yml')).toBe(true);
expect(isRelevantWatchPath('src/main/resources/application.properties')).toBe(true);
expect(isRelevantWatchPath('templates/page.html')).toBe(true);
expect(isRelevantWatchPath('templates/page.htm')).toBe(true);
expect(isRelevantWatchPath('views/page.ejs')).toBe(true);
expect(isRelevantWatchPath('views/page.hbs')).toBe(true);
expect(isRelevantWatchPath('views/page.blade.php')).toBe(true);
expect(
isRelevantWatchPath(
'src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports',
),
).toBe(true);
expect(isRelevantWatchPath('src/main/resources/META-INF/spring.factories')).toBe(true);
expect(isRelevantWatchPath('tsconfig.base.json')).toBe(true);
expect(isRelevantWatchPath('packages/api/tsconfig.build.json')).toBe(true);
expect(isRelevantWatchPath('schema.sql')).toBe(true);
expect(isRelevantWatchPath('Dockerfile')).toBe(true);
expect(isRelevantWatchPath('assets/logo.png')).toBe(true);
expect(isRelevantWatchPath('../outside.ts')).toBe(false);
expect(isRelevantWatchPath('C:\\outside.ts')).toBe(false);
});
it('honors hardcoded, gitignore, and explicit-unignore rules', async () => {
await fs.writeFile(
path.join(repoPath, '.gitignore'),
['generated/*', '!generated/', '!generated/keep.ts'].join('\n'),
);
const ignored = await createWatchIgnorePredicate(repoPath);
expect(ignored(path.join(repoPath, 'node_modules', 'pkg', 'index.ts'))).toBe(true);
expect(ignored(path.join(repoPath, 'generated'), true)).toBe(false);
expect(ignored(path.join(repoPath, 'generated', 'drop.ts'))).toBe(true);
expect(ignored(path.join(repoPath, 'generated', 'keep.ts'))).toBe(false);
expect(ignored(path.join(repoPath, 'src', 'keep.ts'))).toBe(false);
expect(ignored(path.resolve(repoPath, '..', 'outside.ts'))).toBe(true);
});
it('does not partially mutate environment state when a reloaded config is invalid', async () => {
const names = [
'GITNEXUS_MAX_FILE_SIZE',
'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
'GITNEXUS_VERBOSE',
] as const;
const original = Object.fromEntries(names.map((name) => [name, process.env[name]]));
try {
await fs.writeFile(
path.join(repoPath, '.gitnexusrc'),
JSON.stringify({ maxFileSize: '2048', workerTimeout: '90', workers: '2' }),
);
const baseline = { maxFileSize: '512', workerTimeout: '30000', verbose: undefined };
await resolveWatchOptions(repoPath, {}, baseline);
expect(process.env.GITNEXUS_MAX_FILE_SIZE).toBe('2048');
expect(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS).toBe('90000');
await fs.writeFile(
path.join(repoPath, '.gitnexusrc'),
JSON.stringify({ maxFileSize: '4096', workerTimeout: '120', workers: '0' }),
);
await expect(resolveWatchOptions(repoPath, {}, baseline)).rejects.toThrow(
'--workers must be a positive integer',
);
expect(process.env.GITNEXUS_MAX_FILE_SIZE).toBe('2048');
expect(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS).toBe('90000');
} finally {
for (const name of names) {
const value = original[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
});
it('ignores unsupported repository defaults but rejects explicit unsupported CLI flags', async () => {
await fs.writeFile(
path.join(repoPath, '.gitnexusrc'),
JSON.stringify({
embeddings: true,
defaultBranch: 'develop',
skipAgentsMd: false,
skipSkills: false,
stats: true,
springActuator: './actuator',
}),
);
const ignored: string[][] = [];
await expect(
resolveWatchOptions(
repoPath,
{},
{
maxFileSize: undefined,
workerTimeout: undefined,
verbose: undefined,
},
(names) => ignored.push([...names]),
),
).resolves.toMatchObject({ skipAgentsMd: true, skipSkills: true });
expect(ignored).toEqual([
['embeddings', 'defaultBranch', 'skipAgentsMd', 'skipSkills', 'stats', 'springActuator'],
]);
const unsupportedCliOptions: Array<[Parameters<typeof resolveWatchOptions>[1], string]> = [
[{ embeddings: true }, '--embeddings'],
[{ defaultBranch: 'develop' }, '--default-branch'],
[{ skipAgentsMd: true }, '--skip-agents-md'],
[{ skipSkills: true }, '--skip-skills'],
[{ stats: false }, '--no-stats'],
[{ springActuator: './actuator' }, '--spring-actuator'],
// Rejected for the same reason as the Actuator path: the watcher reacts
// to source changes and nothing watches a document directory, so
// accepting the flag would read the documents once and then serve a
// stale answer for the rest of the session.
[{ asyncapiSpec: './docs/asyncapi' }, '--asyncapi-spec'],
];
for (const [options, flag] of unsupportedCliOptions) {
await expect(
resolveWatchOptions(repoPath, options, {
maxFileSize: undefined,
workerTimeout: undefined,
verbose: undefined,
}),
).rejects.toThrow(`analyze --watch does not support ${flag}`);
}
});
it('rejects a watch file-size threshold above the parser ceiling', async () => {
await expect(
resolveWatchOptions(
repoPath,
{ maxFileSize: '32769' },
{
maxFileSize: undefined,
workerTimeout: undefined,
verbose: undefined,
},
),
).rejects.toThrow('maxFileSize must not exceed 32768');
});
it.skipIf(process.platform === 'win32')(
'rejects repository ignore files that are final-file symlinks',
async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-outside-'));
try {
const target = path.join(outside, 'ignore');
await fs.writeFile(target, 'secret.ts\n');
await fs.symlink(target, path.join(repoPath, '.gitignore'), 'file');
await expect(createWatchIgnorePredicate(repoPath)).rejects.toThrow(/symbolic link/);
} finally {
await fs.rm(outside, { recursive: true, force: true });
}
},
);
});