GitNexus/gitnexus/test/integration/filesystem-walker.test.ts
ChamHerry 2a3d14057a
fix(analyze): prevent cache-hit native workers from aborting (#1751)
* fix(analyze): prevent cache-hit native workers from aborting

Delay parse worker startup until a cache miss requires it, fall back to sequential parsing when initial worker readiness fails, and preserve analyzer diagnostics/progress when heap respawn captures child output.

Constraint: Node 25 and tree-sitter/N-API worker initialization can abort before ready, while warm-cache analysis should not start workers at all.

Rejected: Treating status-134/SIGABRT as heap OOM unconditionally | native worker aborts require distinct recovery guidance and stderr/stdout evidence.

Rejected: cli-progress noTTYOutput for respawn progress | it appends newline frames instead of preserving one-line redraw UX.

Confidence: high

Scope-risk: moderate

Directive: Keep parse-worker creation behind confirmed cache misses and preserve TTY-style progress when respawn pipes stderr for crash classification.

Tested: GitNexus impact analysis for ensureHeap, runChunkedParseAndResolve, createWorkerPool, WorkerPool, walkRepositoryPaths; GitNexus detect_changes scoped to staged worktree; targeted vitest for analyze respawn, parse lazy cache, filesystem walker, worker pool; npx tsc --noEmit; npm run build; NODE_OPTIONS='--max-old-space-size=8192' npm test.

Not-tested: Windows terminal rendering and published npm package install path.

* ci(docker): tolerate slower arm64 TypeScript builds

Docker PR builds run gitnexus prepare under QEMU for linux/arm64, where the fixed 120s TypeScript timeout can kill otherwise healthy builds. Increase the default timeout and allow GITNEXUS_BUILD_TIMEOUT_MS to tune slower environments without changing the build steps.

Constraint: PR #1751 Docker Build & Push gitnexus failed with spawnSync /bin/sh ETIMEDOUT while running node_modules/.bin/tsc in scripts/build.js.\nRejected: Rerunning CI only | the failure was the build script's deterministic timeout boundary under arm64 emulation, not a code assertion.\nConfidence: high\nScope-risk: narrow\nDirective: Keep build timeout changes in scripts/build.js configurable; do not hide real compiler failures, only allow slower successful compiles to finish.\nTested: GitNexus impact for gitnexus/scripts/build.js reported LOW; gitnexus detect_changes reported 1 changed file, 0 affected processes, low risk; git diff --check; gitnexus npm run build.\nNot-tested: GitHub Docker arm64 build rerun before pushing; local Docker multi-platform build under QEMU.

* fix(analyze): truncate respawn progress safely

Preserve complete ANSI escape sequences and grapheme boundaries when the respawn progress terminal shim truncates wrapped output, so the shim does not emit dangling escape bytes or split surrogate pairs while keeping raw writes untouched.

Constraint: Claude review on PR #1751 flagged `s.slice(0, width)` in createAnsiPipeTerminal.write() as a latent terminal-corruption risk.
Rejected: Adding a display-width dependency | a local helper is sufficient for this narrow respawn terminal shim and avoids new dependency churn.
Rejected: Changing silent status-134 classification | current tests already document the output-less 134 fallback as heap guidance.
Confidence: high
Scope-risk: narrow
Directive: Keep respawn terminal writes ANSI-aware and preserve rawWrite bypass semantics for callers that intentionally write control sequences.
Tested: GitNexus impact for createAnsiPipeTerminal reported LOW; GitNexus detect_changes reported 2 changed files, 3 affected processes, medium risk; targeted vitest for analyze respawn progress and heap respawn; gitnexus npx tsc --noEmit; prettier check for changed files; eslint for changed files.
Not-tested: Full npm test suite; manual terminal rendering on Windows.

---------

Co-authored-by: wangxc <wangxc_a_bj@si-tech.com.cn>
2026-05-21 16:17:02 +01:00

591 lines
23 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import {
walkRepositoryPaths,
readFileContents,
} from '../../src/core/ingestion/filesystem-walker.js';
import { _resetMaxFileSizeWarnings } from '../../src/core/ingestion/utils/max-file-size.js';
import { _captureLogger } from '../../src/core/logger.js';
describe('filesystem-walker', () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-test-'));
// Create test directory structure
await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fs.mkdir(path.join(tmpDir, 'src', 'components'), { recursive: true });
await fs.mkdir(path.join(tmpDir, 'node_modules', 'lodash'), { recursive: true });
await fs.mkdir(path.join(tmpDir, '.git'), { recursive: true });
await fs.writeFile(path.join(tmpDir, 'src', 'index.ts'), 'export const main = () => {}');
await fs.writeFile(path.join(tmpDir, 'src', 'utils.ts'), 'export const helper = () => {}');
await fs.writeFile(
path.join(tmpDir, 'src', 'components', 'Button.tsx'),
'export const Button = () => <div/>',
);
await fs.writeFile(
path.join(tmpDir, 'node_modules', 'lodash', 'index.js'),
'module.exports = {}',
);
await fs.writeFile(path.join(tmpDir, '.git', 'HEAD'), 'ref: refs/heads/main');
await fs.writeFile(path.join(tmpDir, 'package.json'), '{}');
await fs.writeFile(
path.join(tmpDir, 'src', 'image.png'),
Buffer.from([0x89, 0x50, 0x4e, 0x47]),
);
});
afterAll(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
/* best-effort */
}
});
describe('walkRepositoryPaths', () => {
it('discovers source files', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('src/index.ts'))).toBe(true);
expect(paths.some((p) => p.includes('src/utils.ts'))).toBe(true);
});
it('discovers nested files', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('components/Button.tsx'))).toBe(true);
});
it('skips node_modules', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.every((p) => !p.includes('node_modules'))).toBe(true);
});
it('skips .git directory', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.every((p) => !p.includes('.git/'))).toBe(true);
});
it('returns file sizes', async () => {
const files = await walkRepositoryPaths(tmpDir);
for (const file of files) {
expect(typeof file.size).toBe('number');
expect(file.size).toBeGreaterThan(0);
}
});
it('calls progress callback', async () => {
const onProgress = vi.fn();
await walkRepositoryPaths(tmpDir, onProgress);
expect(onProgress).toHaveBeenCalled();
});
// ─── Unhappy paths ────────────────────────────────────────────────
it('throws or returns empty for non-existent directory', async () => {
try {
const files = await walkRepositoryPaths('/nonexistent/path/xyz123');
// If it doesn't throw, it should return empty
expect(files).toEqual([]);
} catch (err: any) {
expect(err).toBeDefined();
}
});
it('returns empty for directory with only ignored files', async () => {
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-empty-'));
await fs.mkdir(path.join(emptyDir, '.git'), { recursive: true });
await fs.writeFile(path.join(emptyDir, '.git', 'HEAD'), 'ref: refs/heads/main');
try {
const files = await walkRepositoryPaths(emptyDir);
expect(files).toEqual([]);
} finally {
await fs.rm(emptyDir, { recursive: true, force: true });
}
});
it('returns empty for truly empty directory', async () => {
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-truly-empty-'));
try {
const files = await walkRepositoryPaths(emptyDir);
expect(files).toEqual([]);
} finally {
await fs.rm(emptyDir, { recursive: true, force: true });
}
});
});
describe('.gitignore support', () => {
let gitignoreDir: string;
beforeAll(async () => {
gitignoreDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-gitignore-'));
// Create directory structure
await fs.mkdir(path.join(gitignoreDir, 'src'), { recursive: true });
await fs.mkdir(path.join(gitignoreDir, 'data', 'cache'), { recursive: true });
await fs.mkdir(path.join(gitignoreDir, 'logs'), { recursive: true });
// Source files (should be indexed)
await fs.writeFile(
path.join(gitignoreDir, 'src', 'index.ts'),
'export const main = () => {}',
);
await fs.writeFile(
path.join(gitignoreDir, 'src', 'utils.ts'),
'export const helper = () => {}',
);
// Data files (should be ignored via .gitignore)
await fs.writeFile(path.join(gitignoreDir, 'data', 'cache', 'file.json'), '{}');
await fs.writeFile(path.join(gitignoreDir, 'logs', 'app.log'), 'log entry');
// .gitignore
await fs.writeFile(path.join(gitignoreDir, '.gitignore'), 'data/\nlogs/\n');
});
afterAll(async () => {
await fs.rm(gitignoreDir, { recursive: true, force: true });
});
it('excludes directories listed in .gitignore', async () => {
const files = await walkRepositoryPaths(gitignoreDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
// Source files should be present
expect(paths.some((p) => p.includes('src/index.ts'))).toBe(true);
expect(paths.some((p) => p.includes('src/utils.ts'))).toBe(true);
// Ignored directories should not be present
expect(paths.every((p) => !p.includes('data/'))).toBe(true);
expect(paths.every((p) => !p.includes('logs/'))).toBe(true);
});
it('still applies hardcoded ignore list alongside .gitignore', async () => {
// Add node_modules (hardcoded ignore) to verify both work
await fs.mkdir(path.join(gitignoreDir, 'node_modules', 'pkg'), { recursive: true });
await fs.writeFile(
path.join(gitignoreDir, 'node_modules', 'pkg', 'index.js'),
'module.exports = {}',
);
const files = await walkRepositoryPaths(gitignoreDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.every((p) => !p.includes('node_modules'))).toBe(true);
expect(paths.every((p) => !p.includes('data/'))).toBe(true);
await fs.rm(path.join(gitignoreDir, 'node_modules'), { recursive: true, force: true });
});
});
describe('.gitnexusignore support', () => {
let nexusignoreDir: string;
beforeAll(async () => {
nexusignoreDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-nexusignore-'));
await fs.mkdir(path.join(nexusignoreDir, 'src'), { recursive: true });
await fs.mkdir(path.join(nexusignoreDir, 'local', 'grafana'), { recursive: true });
await fs.writeFile(
path.join(nexusignoreDir, 'src', 'index.ts'),
'export const main = () => {}',
);
await fs.writeFile(path.join(nexusignoreDir, 'local', 'grafana', 'module.js'), 'var x = 1;');
// Only .gitnexusignore, no .gitignore
await fs.writeFile(path.join(nexusignoreDir, '.gitnexusignore'), 'local/\n');
});
afterAll(async () => {
await fs.rm(nexusignoreDir, { recursive: true, force: true });
});
it('excludes directories listed in .gitnexusignore', async () => {
const files = await walkRepositoryPaths(nexusignoreDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('src/index.ts'))).toBe(true);
expect(paths.every((p) => !p.includes('local/'))).toBe(true);
});
});
describe('combined .gitignore + .gitnexusignore', () => {
let combinedDir: string;
beforeAll(async () => {
combinedDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-combined-'));
await fs.mkdir(path.join(combinedDir, 'src'), { recursive: true });
await fs.mkdir(path.join(combinedDir, 'data'), { recursive: true });
await fs.mkdir(path.join(combinedDir, 'local', 'plugins'), { recursive: true });
await fs.writeFile(path.join(combinedDir, 'src', 'index.ts'), 'export const main = () => {}');
await fs.writeFile(path.join(combinedDir, 'data', 'dump.json'), '{}');
await fs.writeFile(path.join(combinedDir, 'local', 'plugins', 'module.js'), 'var x = 1;');
await fs.writeFile(path.join(combinedDir, '.gitignore'), 'data/\n');
await fs.writeFile(path.join(combinedDir, '.gitnexusignore'), 'local/\n');
});
afterAll(async () => {
await fs.rm(combinedDir, { recursive: true, force: true });
});
it('excludes directories from both files', async () => {
const files = await walkRepositoryPaths(combinedDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('src/index.ts'))).toBe(true);
expect(paths.every((p) => !p.includes('data/'))).toBe(true);
expect(paths.every((p) => !p.includes('local/'))).toBe(true);
});
});
describe('GITNEXUS_NO_GITIGNORE env var', () => {
let envDir: string;
beforeAll(async () => {
envDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-noignore-'));
await fs.mkdir(path.join(envDir, 'src'), { recursive: true });
await fs.mkdir(path.join(envDir, 'data'), { recursive: true });
await fs.writeFile(path.join(envDir, 'src', 'index.ts'), 'export const main = () => {}');
await fs.writeFile(path.join(envDir, 'data', 'dump.json'), '{}');
await fs.writeFile(path.join(envDir, '.gitignore'), 'data/\n');
});
afterAll(async () => {
await fs.rm(envDir, { recursive: true, force: true });
});
it('excludes gitignored directory by default', async () => {
const files = await walkRepositoryPaths(envDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.every((p) => !p.includes('data/'))).toBe(true);
});
it('includes gitignored directory when GITNEXUS_NO_GITIGNORE is set', async () => {
const original = process.env.GITNEXUS_NO_GITIGNORE;
process.env.GITNEXUS_NO_GITIGNORE = '1';
try {
const files = await walkRepositoryPaths(envDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.includes('data/dump.json'))).toBe(true);
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_NO_GITIGNORE;
} else {
process.env.GITNEXUS_NO_GITIGNORE = original;
}
}
});
});
describe('readFileContents', () => {
it('reads file contents by relative paths', async () => {
const contents = await readFileContents(tmpDir, ['src/index.ts', 'src/utils.ts']);
expect(contents.get('src/index.ts')).toContain('main');
expect(contents.get('src/utils.ts')).toContain('helper');
});
it('handles empty path list', async () => {
const contents = await readFileContents(tmpDir, []);
expect(contents.size).toBe(0);
});
it('skips non-existent files gracefully', async () => {
const contents = await readFileContents(tmpDir, ['nonexistent.ts']);
expect(contents.size).toBe(0);
});
// ─── Unhappy paths ────────────────────────────────────────────────
it('skips multiple non-existent files gracefully', async () => {
const contents = await readFileContents(tmpDir, ['a.ts', 'b.ts', 'c.ts']);
expect(contents.size).toBe(0);
});
it('handles binary file content without crashing', async () => {
const contents = await readFileContents(tmpDir, ['src/image.png']);
// May return content or skip — should not throw
expect(contents.size).toBeLessThanOrEqual(1);
});
});
describe('large file skip threshold (#991)', () => {
let sizeDir: string;
const BIG_FILE = 'src/big.ts';
const BIG_FILE_BYTES = 600 * 1024;
const ORIGINAL_ENV = process.env.GITNEXUS_MAX_FILE_SIZE;
let cap: ReturnType<typeof _captureLogger>;
beforeAll(async () => {
sizeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-test-'));
await fs.mkdir(path.join(sizeDir, 'src'), { recursive: true });
await fs.writeFile(path.join(sizeDir, 'src', 'small.ts'), 'export const x = 1;');
await fs.writeFile(path.join(sizeDir, BIG_FILE), 'x'.repeat(BIG_FILE_BYTES));
});
afterAll(async () => {
await fs.rm(sizeDir, { recursive: true, force: true });
});
beforeEach(() => {
delete process.env.GITNEXUS_MAX_FILE_SIZE;
_resetMaxFileSizeWarnings();
cap = _captureLogger();
});
afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.GITNEXUS_MAX_FILE_SIZE;
} else {
process.env.GITNEXUS_MAX_FILE_SIZE = ORIGINAL_ENV;
}
cap.restore();
});
it('skips a 600KB file by default', async () => {
const files = await walkRepositoryPaths(sizeDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).toContain('src/small.ts');
expect(paths).not.toContain(BIG_FILE);
});
it('includes the 600KB file when GITNEXUS_MAX_FILE_SIZE=1024', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = '1024';
const files = await walkRepositoryPaths(sizeDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).toContain(BIG_FILE);
});
it('falls back to default and warns once on invalid GITNEXUS_MAX_FILE_SIZE', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = 'abc';
const files = await walkRepositoryPaths(sizeDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).not.toContain(BIG_FILE);
const invalidWarnings = cap
.records()
.filter((r) => String(r.msg ?? '').includes('must be a positive integer'));
expect(invalidWarnings).toHaveLength(1);
});
it('omits the "generated/vendored" suffix when threshold is overridden', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = '1';
await walkRepositoryPaths(sizeDir);
const skipWarnings = cap.records().filter((r) => String(r.msg ?? '').includes('Skipped '));
expect(skipWarnings.length).toBeGreaterThan(0);
for (const r of skipWarnings) {
expect(String(r.msg ?? '')).not.toContain('generated/vendored');
}
});
it('keeps the "generated/vendored" suffix under the default threshold', async () => {
await walkRepositoryPaths(sizeDir);
const skipWarnings = cap.records().filter((r) => String(r.msg ?? '').includes('Skipped '));
expect(skipWarnings.length).toBeGreaterThan(0);
expect(String(skipWarnings[0].msg ?? '')).toContain('generated/vendored');
});
// Regression: issue #1659. The skipped-paths list and the
// GITNEXUS_MAX_FILE_SIZE hint must appear by default, otherwise users
// see "Skipped N large files" with no actionable detail and misdiagnose
// missing IMPORTS/CALLS edges as a resolver bug.
it('lists the skipped path by default (not gated behind GITNEXUS_VERBOSE)', async () => {
await walkRepositoryPaths(sizeDir);
const pathWarnings = cap.records().filter((r) => String(r.msg ?? '').includes(BIG_FILE));
expect(pathWarnings.length).toBeGreaterThan(0);
});
it('emits a GITNEXUS_MAX_FILE_SIZE hint when running with the default cap', async () => {
await walkRepositoryPaths(sizeDir);
const hint = cap
.records()
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
expect(hint.length).toBe(1);
});
it('omits the GITNEXUS_MAX_FILE_SIZE hint when an override is active', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = '1';
await walkRepositoryPaths(sizeDir);
const hint = cap
.records()
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
expect(hint.length).toBe(0);
});
// Edge case from the #1661 adversarial review: setting GITNEXUS_MAX_FILE_SIZE
// to the same value as the default (512KB) used to still print the hint
// because the byte comparison resolved to equal. The hint should care
// about whether the operator set the env var, not what value they chose.
it('omits the GITNEXUS_MAX_FILE_SIZE hint when the override equals the default value', async () => {
process.env.GITNEXUS_MAX_FILE_SIZE = '512';
await walkRepositoryPaths(sizeDir);
const hint = cap
.records()
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
expect(hint.length).toBe(0);
});
it('routes large-file notices through console.warn while analyze progress is active', async () => {
const originalProgressActive = process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1';
await walkRepositoryPaths(sizeDir);
const messages = warnSpy.mock.calls.map(([msg]) => String(msg));
expect(messages.some((m) => m.includes('Skipped 1 large files'))).toBe(true);
expect(messages.some((m) => m.includes(BIG_FILE))).toBe(true);
expect(cap.records().filter((r) => String(r.msg ?? '').includes('Skipped '))).toHaveLength(
0,
);
} finally {
warnSpy.mockRestore();
if (originalProgressActive === undefined) {
delete process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE;
} else {
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = originalProgressActive;
}
}
});
});
describe('large file skip preview cap (#1659)', () => {
let manyDir: string;
const ORIGINAL_ENV = process.env.GITNEXUS_MAX_FILE_SIZE;
const ORIGINAL_VERBOSE = process.env.GITNEXUS_VERBOSE;
let cap: ReturnType<typeof _captureLogger>;
beforeAll(async () => {
manyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-many-'));
await fs.mkdir(path.join(manyDir, 'src'), { recursive: true });
// 8 files >512KB so the preview-cap path (5) is exercised.
for (let i = 0; i < 8; i++) {
await fs.writeFile(path.join(manyDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
}
});
afterAll(async () => {
await fs.rm(manyDir, { recursive: true, force: true });
});
beforeEach(() => {
delete process.env.GITNEXUS_MAX_FILE_SIZE;
delete process.env.GITNEXUS_VERBOSE;
_resetMaxFileSizeWarnings();
cap = _captureLogger();
});
afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.GITNEXUS_MAX_FILE_SIZE;
} else {
process.env.GITNEXUS_MAX_FILE_SIZE = ORIGINAL_ENV;
}
if (ORIGINAL_VERBOSE === undefined) {
delete process.env.GITNEXUS_VERBOSE;
} else {
process.env.GITNEXUS_VERBOSE = ORIGINAL_VERBOSE;
}
cap.restore();
});
it('truncates the path list to 5 and mentions GITNEXUS_VERBOSE when over the cap', async () => {
await walkRepositoryPaths(manyDir);
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
expect(pathLines.length).toBe(5);
const more = cap
.records()
.filter((r) => String(r.msg ?? '').includes('and 3 more (set GITNEXUS_VERBOSE=1'));
expect(more.length).toBe(1);
});
// Boundary check from the #1661 adversarial review: the SKIPPED_PREVIEW_CAP
// comparison is `<=`, so 5 paths should list all five without a truncation
// line and 6 paths should list exactly five plus "...and 1 more". Tested
// explicitly so a future off-by-one refactor (`<=` → `<`) fails fast.
it('lists all paths and omits the truncation line at exactly 5 skipped files', async () => {
const fiveDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-five-'));
try {
await fs.mkdir(path.join(fiveDir, 'src'), { recursive: true });
for (let i = 0; i < 5; i++) {
await fs.writeFile(path.join(fiveDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
}
await walkRepositoryPaths(fiveDir);
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
expect(pathLines.length).toBe(5);
const more = cap.records().filter((r) => String(r.msg ?? '').includes('...and '));
expect(more.length).toBe(0);
} finally {
await fs.rm(fiveDir, { recursive: true, force: true });
}
});
it('lists exactly 5 paths plus "...and 1 more" at exactly 6 skipped files', async () => {
const sixDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-six-'));
try {
await fs.mkdir(path.join(sixDir, 'src'), { recursive: true });
for (let i = 0; i < 6; i++) {
await fs.writeFile(path.join(sixDir, 'src', `big${i}.ts`), 'x'.repeat(600 * 1024));
}
await walkRepositoryPaths(sixDir);
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
expect(pathLines.length).toBe(5);
const more = cap
.records()
.filter((r) => String(r.msg ?? '').includes('and 1 more (set GITNEXUS_VERBOSE=1'));
expect(more.length).toBe(1);
} finally {
await fs.rm(sixDir, { recursive: true, force: true });
}
});
it('lists every skipped path when GITNEXUS_VERBOSE=1', async () => {
process.env.GITNEXUS_VERBOSE = '1';
await walkRepositoryPaths(manyDir);
const pathLines = cap.records().filter((r) => /^\s*-\s/.test(String(r.msg ?? '')));
expect(pathLines.length).toBe(8);
const more = cap.records().filter((r) => String(r.msg ?? '').includes('and '));
expect(more.length).toBe(0);
});
// Issue #1659 follow-up (PR #1661 review): paths were pushed in fs.stat
// completion order, so the default preview could vary between runs on
// the same repo. The implementation sorts skippedLargePaths before
// slicing, so the listed paths come out in sorted order, which is the
// stable contract operators can rely on.
it('lists skipped paths in sorted order (deterministic preview)', async () => {
process.env.GITNEXUS_VERBOSE = '1';
await walkRepositoryPaths(manyDir);
const pathLines = cap
.records()
.map((r) => String(r.msg ?? ''))
.filter((m) => /^\s*-\s/.test(m))
.map((m) => m.replace(/^\s*-\s*/, ''));
expect(pathLines).toEqual([...pathLines].sort());
// sanity-check we actually saw all 8 of the manyDir fixture
expect(pathLines).toEqual([
'src/big0.ts',
'src/big1.ts',
'src/big2.ts',
'src/big3.ts',
'src/big4.ts',
'src/big5.ts',
'src/big6.ts',
'src/big7.ts',
]);
});
});
});