Files
ECC/tests/lib/astra-review-scope.test.js
T
kenima-arcandClaude Fable 5.1 4018d3dc56 fix(astra-review): harden report writing and make tests portable
Findings from three /astra-review rounds on the previous commit:

- writeReport: write to an exclusively created 0600 temp file in the
  destination directory and rename it into place. Removes the lstat/write
  race, replaces (never follows) a pre-existing symlink, and tightens
  permissions on an existing world-readable report. Full-write semantics via
  writeFileSync on the descriptor; temp file removed on any failure.
- scope: normalize the git toplevel and cwd with path.resolve before
  realpath (git reports forward slashes on Windows) and display file paths
  git-style with forward slashes.
- tests: build fixture roots with path.resolve/path.join so containment
  checks hold on Windows; skip POSIX permission and symlink assertions where
  the platform cannot support them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 00:07:00 +09:00

551 lines
22 KiB
JavaScript

/**
* Tests for scripts/lib/astra-review/scope.js
*
* Run with: node tests/lib/astra-review-scope.test.js
*/
'use strict';
const assert = require('assert');
const path = require('path');
const scope = require('../../scripts/lib/astra-review/scope');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
passed++;
} catch (err) {
console.log(` ✗ ${name}`);
console.log(` Error: ${err.message}`);
failed++;
}
}
// Git reports slash-separated paths; the filesystem side must be native so
// these fixtures hold on Windows as well as POSIX.
const ROOT = path.resolve('/repo');
const R = (...parts) => path.join(ROOT, ...parts);
const OUTSIDE = path.resolve('/outside');
const REGULAR_FILE = () => ({ isSymbolicLink: () => false, isFile: () => true });
const enoent = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
const lstatOnly = (present) => (file) => (present.includes(file) ? REGULAR_FILE() : enoent());
const HEAD_RESOLVES = [/^rev-parse -q --verify HEAD\^\{commit\}$/, 'headsha\n'];
function fakeGit(responses, { unbornHead = false } = {}) {
const calls = [];
const rules = unbornHead ? responses : [...responses, HEAD_RESOLVES];
const runGit = (args) => {
calls.push(args);
const key = args.join(' ');
for (const [pattern, value] of rules) {
if (pattern.test(key)) return value;
}
return '';
};
return { runGit, calls };
}
console.log('=== Testing astra-review/scope.js ===\n');
test('parseScope defaults to uncommitted changes', () => {
assert.deepStrictEqual(scope.parseScope({}), { kind: 'uncommitted', value: null });
});
test('parseScope rejects more than one scope selector', () => {
assert.throws(
() => scope.parseScope({ base: 'main', commit: 'abc' }),
/only one of/
);
});
test('parseScope rejects git-option-like or blank revisions', () => {
assert.throws(() => scope.parseScope({ base: '--output=/tmp/x' }), /invalid base/);
assert.throws(() => scope.parseScope({ commit: 'a b' }), /invalid commit/);
});
test('parseScope accepts revision expressions such as HEAD~1 and main^', () => {
assert.strictEqual(scope.parseScope({ commit: 'HEAD~1' }).value, 'HEAD~1');
assert.strictEqual(scope.parseScope({ base: 'origin/main^' }).value, 'origin/main^');
});
test('collectChanges keeps file names with spaces and non-ASCII characters intact', () => {
const git = fakeGit([
[/^diff --cached --name-only -z HEAD --$/, ' lead.js\0日本語 ファイル.js\0'],
[/^diff --no-ext-diff --no-textconv --cached HEAD --$/, '+d\n'],
]);
const result = scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, [' lead.js', '日本語 ファイル.js']);
});
test('collectChanges (uncommitted) merges staged, unstaged, and untracked changes', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^diff --cached --name-only -z HEAD --$/, 'src/staged.js\0src/both.js\0'],
[/^diff --name-only -z --$/, 'src/both.js\0src/unstaged.js\0'],
[/^ls-files --others --exclude-standard -z$/, 'src/new.js\0'],
[/^diff --no-ext-diff --no-textconv --cached HEAD --$/, '+staged change\n'],
[/^diff --no-ext-diff --no-textconv --$/, '+unstaged change\n'],
[/^diff --no-ext-diff --no-textconv --no-index -- \/dev\/null src\/new\.js$/, '+brand new\n'],
]);
const result = scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, ['src/staged.js', 'src/both.js', 'src/unstaged.js', 'src/new.js']);
assert.ok(result.diff.includes('+staged change'));
assert.ok(result.diff.includes('+unstaged change'));
assert.ok(result.diff.includes('+brand new'));
assert.strictEqual(result.root, ROOT);
assert.strictEqual(result.truncated, false);
});
test('collectChanges still reports a staged change that was reverted only in the working tree', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^diff --cached --name-only -z HEAD --$/, 'src/a.js\0'],
[/^diff --no-ext-diff --no-textconv --cached HEAD --$/, '+staged only\n'],
[/^diff --name-only -z --$/, 'src/a.js\0'],
[/^diff --no-ext-diff --no-textconv --$/, '-staged only\n'],
]);
const result = scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, ['src/a.js']);
assert.ok(result.diff.includes('+staged only'));
});
test('collectChanges runs git from the repository root, not the current subdirectory', () => {
const cwds = [];
const runGit = (args, cwd) => {
cwds.push(cwd);
if (args.join(' ') === 'rev-parse --show-toplevel') return '/repo\n';
return '';
};
scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit, cwd: R('scripts') });
assert.strictEqual(cwds[0], R('scripts'));
assert.ok(cwds.slice(1).every((cwd) => cwd === ROOT), `expected ${ROOT}, got ${cwds.slice(1)}`);
});
test('collectChanges against a base branch diffs merge-base to the working tree, plus untracked files', () => {
const git = fakeGit([
[/^merge-base main HEAD$/, 'mb123\n'],
[/^diff --cached --name-only -z mb123 --$/, 'lib/x.js\0'],
[/^ls-files --others --exclude-standard -z$/, 'lib/new.js\0'],
[/^diff --no-ext-diff --no-textconv --cached mb123 --$/, '+x\n'],
[/^diff --no-ext-diff --no-textconv --no-index -- \/dev\/null lib\/new\.js$/, '+new\n'],
]);
const result = scope.collectChanges({ kind: 'base', value: 'main' }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, ['lib/x.js', 'lib/new.js']);
assert.ok(result.diff.includes('+x'));
assert.ok(result.diff.includes('+new'));
});
test('collectChanges for a commit resolves the revision, then diffs against its first parent', () => {
const git = fakeGit([
[/^rev-parse --verify --end-of-options HEAD~1\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1 parent2\n'],
[/^diff --no-renames --name-only -z parent1 abc1234 --$/, 'a.js\0b.js\0'],
[/^diff --no-ext-diff --no-textconv parent1 abc1234 --$/, '+commit diff\n'],
]);
const result = scope.collectChanges({ kind: 'commit', value: 'HEAD~1' }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, ['a.js', 'b.js']);
assert.strictEqual(result.diff, '+commit diff\n');
});
test('collectChanges for a root commit falls back to diff-tree --root and git show', () => {
const git = fakeGit([
[/^rev-parse --verify --end-of-options root0\^\{commit\}$/, 'a00b000\n'],
[/^rev-list --parents -n 1 a00b000$/, 'a00b000\n'],
[/^diff-tree --no-renames --no-commit-id --root --name-only -r -z a00b000$/, 'init.js\0'],
[/^show --no-ext-diff --no-textconv --format= a00b000 --$/, '+init\n'],
]);
const result = scope.collectChanges({ kind: 'commit', value: 'root0' }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, ['init.js']);
assert.strictEqual(result.diff, '+init\n');
});
test('collectChanges for explicit files reads file contents', () => {
const readFile = (file) => `content of ${path.basename(file)}`;
const result = scope.collectChanges(
{ kind: 'files', value: ['one.js', 'two.js'] },
{ runGit: () => '', readFile, cwd: ROOT, lstatSync: REGULAR_FILE }
);
assert.deepStrictEqual(result.files, ['one.js', 'two.js']);
assert.ok(result.diff.includes('content of one.js'));
assert.ok(result.diff.includes('=== two.js ==='));
});
test('collectChanges resolves explicit relative paths against the invocation directory, displayed root-relative', () => {
const read = [];
const readFile = (file) => { read.push(file); return 'x'; };
const runGit = (args) => (args.join(' ') === 'rev-parse --show-toplevel' ? '/repo\n' : '');
const result = scope.collectChanges(
{ kind: 'files', value: ['AGENTS.md'] },
{ runGit, readFile, cwd: R('docs', 'zh-CN'), lstatSync: REGULAR_FILE }
);
assert.deepStrictEqual(read, [R('docs', 'zh-CN', 'AGENTS.md')]);
assert.deepStrictEqual(result.files, ['docs/zh-CN/AGENTS.md']);
assert.ok(result.diff.includes('=== docs/zh-CN/AGENTS.md ==='));
});
test('collectChanges compares the index against the empty tree when HEAD is unborn', () => {
const git = fakeGit([
[/^diff --cached --name-only -z 4b825dc642cb6eb9a060e54bf8d69288fbee4904 --$/, 'first.js\0'],
[/^diff --no-ext-diff --no-textconv --cached 4b825dc642cb6eb9a060e54bf8d69288fbee4904 --$/, '+first\n'],
[/^ls-files --others --exclude-standard -z$/, 'draft.js\0'],
[/^diff --no-ext-diff --no-textconv --no-index -- \/dev\/null draft\.js$/, '+draft\n'],
], { unbornHead: true });
const result = scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, ['first.js', 'draft.js']);
assert.ok(result.diff.includes('+first'));
assert.ok(result.diff.includes('+draft'));
});
test('collectChanges truncates oversized diffs and flags it', () => {
const big = 'x'.repeat(scope.MAX_DIFF_BYTES + 100);
const git = fakeGit([
[/^diff --cached --name-only -z HEAD --$/, 'big.js\0'],
[/^diff --no-ext-diff --no-textconv --cached HEAD --$/, big],
]);
const result = scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit: git.runGit });
assert.strictEqual(result.truncated, true);
assert.ok(Buffer.byteLength(result.diff, 'utf8') <= scope.MAX_DIFF_BYTES + 200);
});
test('collectChanges returns empty result when nothing changed', () => {
const git = fakeGit([]);
const result = scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, []);
assert.strictEqual(result.diff, '');
});
test('collectChanges returns a recovery hint naming the resolved commit and parent', () => {
const git = fakeGit([
[/^rev-parse --verify --end-of-options HEAD~1\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1\n'],
]);
const result = scope.collectChanges({ kind: 'commit', value: 'HEAD~1' }, { runGit: git.runGit });
assert.ok(result.recovery.includes('git diff parent1 abc1234 --'));
assert.ok(result.recovery.includes('git show abc1234:'));
});
test('collectChanges returns a recovery hint for index and working tree on uncommitted reviews', () => {
const git = fakeGit([]);
const result = scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit: git.runGit });
assert.ok(result.recovery.includes('git diff --cached HEAD --'));
assert.ok(result.recovery.includes('git show :<path>'));
assert.ok(result.recovery.includes('git diff --'));
});
test('collectChanges returns a recovery hint that reads files directly for explicit files', () => {
const result = scope.collectChanges(
{ kind: 'files', value: ['a.js'] },
{ runGit: () => '', readFile: () => 'x', cwd: ROOT, lstatSync: REGULAR_FILE }
);
assert.ok(/read/i.test(result.recovery));
});
test('parseScope accepts --files-from-commit as its own scope kind', () => {
assert.deepStrictEqual(scope.parseScope({ filesFromCommit: 'HEAD' }), { kind: 'files-from-commit', value: 'HEAD' });
assert.throws(() => scope.parseScope({ filesFromCommit: 'HEAD', commit: 'HEAD' }), /only one of/);
});
test('files-from-commit reviews current contents of touched files and keeps still-deleted ones with their hunk', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^rev-parse --verify --end-of-options HEAD\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1\n'],
[/^diff --no-renames --name-only -z parent1 abc1234 --$/, 'kept.js\0gone-later.js\0'],
[/^diff --no-ext-diff --no-textconv parent1 -- gone-later\.js$/, '-was here\n'],
]);
const readFile = (file) => `now: ${path.basename(file)}`;
const result = scope.collectChanges({ kind: 'files-from-commit', value: 'HEAD' }, { runGit: git.runGit, readFile, cwd: R('sub'), lstatSync: lstatOnly([R('kept.js')]) });
assert.deepStrictEqual(result.files, ['kept.js', 'gone-later.js']);
assert.ok(result.diff.includes('now: kept.js'));
assert.ok(result.diff.includes('=== gone-later.js === (absent now'));
assert.ok(result.diff.includes('-was here'));
});
test('files-from-commit handles a root commit', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^rev-parse --verify --end-of-options a00b000\^\{commit\}$/, 'a00b000\n'],
[/^rev-list --parents -n 1 a00b000$/, 'a00b000\n'],
[/^diff-tree --no-renames --no-commit-id --root -r --name-only -z a00b000$/, 'init.js\0'],
]);
const result = scope.collectChanges({ kind: 'files-from-commit', value: 'a00b000' }, { runGit: git.runGit, readFile: () => 'x', cwd: ROOT, lstatSync: REGULAR_FILE });
assert.deepStrictEqual(result.files, ['init.js']);
});
test('files-from-commit includes a file the commit deleted once it has been restored', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^rev-parse --verify --end-of-options HEAD\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1\n'],
[/^diff --no-renames --name-only -z parent1 abc1234 --$/, 'restored.js\0'],
]);
const result = scope.collectChanges(
{ kind: 'files-from-commit', value: 'HEAD' },
{ runGit: git.runGit, readFile: () => 'back', cwd: ROOT, lstatSync: lstatOnly([R('restored.js')]) }
);
assert.deepStrictEqual(result.files, ['restored.js']);
});
test('collectFiles serializes symlinks instead of following them', () => {
let readCalled = false;
const result = scope.collectChanges(
{ kind: 'files', value: ['link.js'] },
{
runGit: (args) => (args.join(' ') === 'rev-parse --show-toplevel' ? '/repo\n' : ''),
cwd: ROOT,
lstatSync: () => ({ isSymbolicLink: () => true, isFile: () => false }),
readlinkSync: () => '/etc/passwd',
readFile: () => { readCalled = true; return 'secret'; },
}
);
assert.strictEqual(readCalled, false);
assert.ok(result.diff.includes('symlink -> /etc/passwd'));
assert.ok(!result.diff.includes('secret'));
});
test('collectFiles refuses paths that resolve outside the repository root', () => {
assert.throws(
() => scope.collectChanges(
{ kind: 'files', value: ['../outside.js'] },
{
runGit: (args) => (args.join(' ') === 'rev-parse --show-toplevel' ? '/repo\n' : ''),
cwd: ROOT,
lstatSync: () => ({ isSymbolicLink: () => false, isFile: () => true }),
readFile: () => 'x',
}
),
/outside the repository/
);
});
test('collectFiles refuses non-regular files such as directories', () => {
assert.throws(
() => scope.collectChanges(
{ kind: 'files', value: ['src'] },
{
runGit: (args) => (args.join(' ') === 'rev-parse --show-toplevel' ? '/repo\n' : ''),
cwd: ROOT,
lstatSync: () => ({ isSymbolicLink: () => false, isFile: () => false }),
readFile: () => 'x',
}
),
/not a regular file/
);
});
test('collectFiles refuses files under a symlinked parent directory that resolves outside the repo', () => {
assert.throws(
() => scope.collectChanges(
{ kind: 'files', value: ['external/secret.txt'] },
{
runGit: (args) => (args.join(' ') === 'rev-parse --show-toplevel' ? '/repo\n' : ''),
cwd: ROOT,
realpathSync: (target) => (target === R('external') ? OUTSIDE : target),
lstatSync: REGULAR_FILE,
readFile: () => 'secret',
}
),
/outside the repository/
);
});
test('files-from-commit keeps unresolved deletions in scope with their deletion diff', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^rev-parse --verify --end-of-options HEAD\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1\n'],
[/^diff --no-renames --name-only -z parent1 abc1234 --$/, 'removed.js\0'],
[/^diff --no-ext-diff --no-textconv parent1 -- removed\.js$/, 'deleted file mode 100644\n-old code\n'],
]);
const result = scope.collectChanges(
{ kind: 'files-from-commit', value: 'HEAD' },
{ runGit: git.runGit, readFile: () => 'x', cwd: ROOT, lstatSync: lstatOnly([]) }
);
assert.deepStrictEqual(result.files, ['removed.js']);
assert.ok(result.diff.includes('absent now'));
assert.ok(result.diff.includes('-old code'));
assert.ok(result.recovery.includes('git diff parent1 -- <path>'), 'absent paths need a git-based recovery hint');
});
test('files-from-commit labels a file the commit added and the repair removed as nothing left to diff', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^rev-parse --verify --end-of-options HEAD\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1\n'],
[/^diff --no-renames --name-only -z parent1 abc1234 --$/, 'added-then-removed.js\0'],
]);
const result = scope.collectChanges(
{ kind: 'files-from-commit', value: 'HEAD' },
{ runGit: git.runGit, readFile: () => 'x', cwd: ROOT, lstatSync: lstatOnly([]) }
);
assert.deepStrictEqual(result.files, ['added-then-removed.js']);
assert.ok(result.diff.includes('since removed'));
});
test('files-from-commit treats ENOTDIR (parent directory replaced by a file) as absent', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^rev-parse --verify --end-of-options HEAD\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1\n'],
[/^diff --no-renames --name-only -z parent1 abc1234 --$/, 'config\0config/settings.js\0'],
[/^diff --no-ext-diff --no-textconv parent1 -- config\/settings\.js$/, '-old setting\n'],
]);
const lstatSync = (file) => {
if (file === R('config')) return REGULAR_FILE();
throw Object.assign(new Error('ENOTDIR'), { code: 'ENOTDIR' });
};
const result = scope.collectChanges(
{ kind: 'files-from-commit', value: 'HEAD' },
{ runGit: git.runGit, readFile: () => 'now a file', cwd: ROOT, lstatSync }
);
assert.deepStrictEqual(result.files, ['config', 'config/settings.js']);
assert.ok(result.diff.includes('now a file'));
assert.ok(result.diff.includes('-old setting'));
});
test('files-from-commit treats a dangling symlink as present and serializes the link itself', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^rev-parse --verify --end-of-options HEAD\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1\n'],
[/^diff --no-renames --name-only -z parent1 abc1234 --$/, 'link.js\0'],
]);
let readCalled = false;
const result = scope.collectChanges(
{ kind: 'files-from-commit', value: 'HEAD' },
{
runGit: git.runGit,
cwd: ROOT,
lstatSync: () => ({ isSymbolicLink: () => true, isFile: () => false }),
readlinkSync: () => 'missing-target.js',
readFile: () => { readCalled = true; return 'x'; },
}
);
assert.deepStrictEqual(result.files, ['link.js']);
assert.strictEqual(readCalled, false);
assert.ok(result.diff.includes('symlink -> missing-target.js'));
assert.ok(!result.diff.includes('still deleted'));
});
test('files-from-commit describes a submodule (gitlink) directory instead of failing', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^rev-parse --verify --end-of-options HEAD\^\{commit\}$/, 'abc1234\n'],
[/^rev-list --parents -n 1 abc1234$/, 'abc1234 parent1\n'],
[/^diff --no-renames --name-only -z parent1 abc1234 --$/, 'vendor/lib\0'],
[/^diff --no-ext-diff --no-textconv --submodule=short parent1 -- vendor\/lib$/, '-Subproject commit aaa\n+Subproject commit bbb\n'],
]);
const lstatSync = () => ({ isSymbolicLink: () => false, isFile: () => false, isDirectory: () => true });
const result = scope.collectChanges(
{ kind: 'files-from-commit', value: 'HEAD' },
{ runGit: git.runGit, cwd: ROOT, lstatSync, readFile: () => { throw new Error('must not read a directory'); } }
);
assert.deepStrictEqual(result.files, ['vendor/lib']);
assert.ok(result.diff.includes('directory or submodule'));
assert.ok(result.diff.includes('Subproject commit bbb'));
assert.ok(result.diff.includes('parent1'), 'baseline is the reviewed commit parent, not HEAD');
});
test('collectChanges reports an untracked nested repository instead of diffing it', () => {
const git = fakeGit([
[/^rev-parse --show-toplevel$/, '/repo\n'],
[/^ls-files --others --exclude-standard -z$/, 'nested-repo/\0plain.js\0'],
[/^diff --no-ext-diff --no-textconv --no-index -- \/dev\/null plain\.js$/, '+plain\n'],
]);
const result = scope.collectChanges({ kind: 'uncommitted', value: null }, { runGit: git.runGit });
assert.deepStrictEqual(result.files, ['nested-repo/', 'plain.js']);
assert.ok(result.diff.includes('nested repository'));
assert.ok(result.diff.includes('+plain'));
assert.ok(!git.calls.some((args) => args.includes('nested-repo/') && args.includes('--no-index')));
});
test('defaultRunGit distinguishes a no-index access failure from a genuine difference', () => {
const accessError = () => ({ status: 1, stdout: '', stderr: 'error: Could not access nested-repo/' });
assert.throws(
() => scope.defaultRunGit(['diff', '--no-index', '--', '/dev/null', 'nested-repo/'], ROOT, {}, accessError),
/Could not access/
);
const realDiff = () => ({ status: 1, stdout: '+x\n', stderr: '' });
assert.strictEqual(scope.defaultRunGit(['diff', '--no-index', '--', '/dev/null', 'x'], ROOT, {}, realDiff), '+x\n');
});
test('defaultRunGit fails loudly when a file inventory overflows the buffer', () => {
const overflow = () => ({ error: Object.assign(new Error('x'), { code: 'ENOBUFS' }), stdout: 'partial.js\0', status: null });
assert.throws(
() => scope.defaultRunGit(['ls-files', '--others', '-z'], ROOT, {}, overflow),
/exceeded .* inventory would be incomplete/
);
});
test('defaultRunGit returns partial output only for diff payloads marked partialOk', () => {
const overflow = () => ({ error: Object.assign(new Error('x'), { code: 'ENOBUFS' }), stdout: '+partial', status: null });
const out = scope.defaultRunGit(['diff', 'HEAD', '--'], ROOT, { partialOk: true }, overflow);
assert.strictEqual(out, '+partial');
});
test('defaultRunGit returns empty output for a failed probe when allowFailure is set', () => {
const failed = () => ({ status: 128, stdout: '', stderr: 'fatal: bad revision' });
assert.strictEqual(scope.defaultRunGit(['rev-parse', '-q', '--verify', 'HEAD'], ROOT, { allowFailure: true }, failed), '');
assert.throws(() => scope.defaultRunGit(['rev-parse', '-q', '--verify', 'HEAD'], ROOT, {}, failed), /bad revision/);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);