mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 05:57:40 +02:00
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>
551 lines
22 KiB
JavaScript
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);
|