Fixture helpers that commit to a repository the product later inspects now pass maintenance.auto=false and gc.auto=0, as the product's own git calls already do, so a detached git maintenance run no longer writes pack temp files mid-walk. inspectGit now refuses a git metadata entry that vanishes between readdir and lstat (or between listing a directory and reading it) with project-repository, 'git metadata changed during inspection', instead of leaking a raw ENOENT. It still refuses rather than skipping or retrying. A missing .git keeps its existing error. A deterministic test removes the named entry from inside a replaced readdirSync, for both a file and a directory.
138 lines
8.8 KiB
JavaScript
138 lines
8.8 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import * as api from '../src/index.mjs';
|
|
// Row 148 (#1642): the human's file list and save. The agent writer's own tests stay in project.test.mjs.
|
|
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
async function fixture(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'project-human-file-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const context = { business: { id: 'demo', human: 'jason', projects: {
|
|
stack: { root: join(root, 'code'), repo: join(root, 'records') },
|
|
} }, project: 'stack', dataRoot: root };
|
|
const { repo } = await api.initProject(context);
|
|
const commitAs = (path, bytes, author) => {
|
|
writeFileSync(join(repo, path), bytes);
|
|
git(repo, 'add', '--', path);
|
|
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Fixture ' + path);
|
|
return git(repo, 'rev-parse', 'HEAD');
|
|
};
|
|
const opened = async path => (await api.readProjectFile({ ...context, path })).commit;
|
|
const save = async (path, content, base) => api.writeHumanFile({ ...context, path, content, base: base ?? await opened(path) });
|
|
return { context, repo, root, commitAs, opened, save };
|
|
}
|
|
const state = repo => ({ head: git(repo, 'rev-parse', 'HEAD'), status: git(repo, 'status', '--porcelain') });
|
|
|
|
test('the file list names the spec and Markdown directly under docs, as committed at HEAD', async t => {
|
|
const { context, repo, commitAs } = await fixture(t);
|
|
commitAs('docs/b-notes.md', '# B\n', 'jason');
|
|
mkdirSync(join(repo, 'docs/sub')); commitAs('docs/sub/deep.md', '# Deep\n', 'jason');
|
|
commitAs('docs/plain.txt', 'not markdown\n', 'jason');
|
|
writeFileSync(join(repo, 'docs/uncommitted.md'), '# Not committed\n');
|
|
const listed = await api.listProjectFiles(context);
|
|
assert.deepEqual(listed.files, [{ path: 'docs/README.md' }, { path: 'docs/b-notes.md' }, { path: 'spec/north-star.md' }]);
|
|
assert.equal(listed.head, git(repo, 'rev-parse', 'HEAD'));
|
|
assert.deepEqual(Object.keys(listed).sort(), ['files', 'head']);
|
|
await assert.rejects(api.listProjectFiles({ ...context, project: 'other' }), { code: 'project-not-declared' });
|
|
await assert.rejects(api.listProjectFiles({ ...context, path: 'docs/b-notes.md' }), { code: 'project-read-path' });
|
|
});
|
|
|
|
test('a save writes the typed text as one commit by the business human and touches nothing else', async t => {
|
|
const { context, repo, opened, save } = await fixture(t);
|
|
const text = '# North star\n\nLine one\r\nLine two with <b>html</b> and é.\n\n\n';
|
|
const before = state(repo).head, base = await opened('spec/north-star.md');
|
|
const saved = await save('spec/north-star.md', text, base);
|
|
assert.equal(saved.path, 'spec/north-star.md');
|
|
assert.equal(git(repo, 'rev-parse', 'HEAD'), saved.commit);
|
|
assert.equal(git(repo, 'rev-list', '--count', `${before}..HEAD`), '1');
|
|
assert.equal(git(repo, 'show', '-s', '--format=%an|%ae|%s', saved.commit), 'jason|[email protected]|Edit spec/north-star.md');
|
|
assert.deepEqual(git(repo, 'show', '--format=', '--name-only', saved.commit).split('\n'), ['spec/north-star.md']);
|
|
assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), text);
|
|
assert.equal(git(repo, 'status', '--porcelain'), '');
|
|
const read = await api.readProjectFile({ ...context, path: 'spec/north-star.md' });
|
|
assert.equal(read.content, text); assert.equal(read.commit, saved.commit);
|
|
assert.equal((await api.readSpec(context)).confirmed, true);
|
|
const doc = await save('docs/README.md', '# Docs, edited\n');
|
|
assert.equal(git(repo, 'show', '-s', '--format=%an|%s', doc.commit), 'jason|Edit docs/README.md');
|
|
assert.equal(git(repo, 'rev-list', '--count', `${saved.commit}..HEAD`), '1');
|
|
});
|
|
|
|
test('a stale base refuses and writes nothing, whoever made the newer commit', async t => {
|
|
const { repo, commitAs, opened, save } = await fixture(t);
|
|
const base = await opened('spec/north-star.md');
|
|
commitAs('spec/north-star.md', '# Agent edit\n', 'coder.builder');
|
|
const after = state(repo);
|
|
await assert.rejects(save('spec/north-star.md', '# Mine\n', base), { code: 'stale-base' });
|
|
assert.deepEqual(state(repo), after);
|
|
assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), '# Agent edit\n');
|
|
// A commit that only touched another file is not newer for this file.
|
|
const docBase = await opened('docs/README.md');
|
|
commitAs('docs/other.md', '# Other\n', 'jason');
|
|
assert.equal((await save('docs/README.md', '# Still mine\n', docBase)).path, 'docs/README.md');
|
|
// The head commit is not the file's last commit: a base taken from HEAD refuses.
|
|
const head = git(repo, 'rev-parse', 'HEAD');
|
|
await assert.rejects(save('spec/north-star.md', '# Wrong base\n', head), { code: 'stale-base' });
|
|
// The current last commit is accepted.
|
|
assert.equal((await save('spec/north-star.md', '# Reloaded\n')).path, 'spec/north-star.md');
|
|
});
|
|
|
|
test('only the spec and existing docs files are writable', async t => {
|
|
const { context, repo, opened, commitAs } = await fixture(t);
|
|
const base = await opened('spec/north-star.md');
|
|
mkdirSync(join(repo, 'docs/sub')); commitAs('docs/sub/deep.md', '# Deep\n', 'jason');
|
|
const after = state(repo);
|
|
for (const path of ['project.json', 'requirements/.gitkeep', 'decisions/x.json', 'templates/document.md', 'spec/other.md',
|
|
'docs/plain.txt', 'docs/sub/deep.md', '../outside.md', 'docs/../project.json', '/etc/passwd', 'docs/.hidden.md', 'docs/a\\b.md', '', null, 7])
|
|
await assert.rejects(api.writeHumanFile({ ...context, path, content: '# X\n', base }), { code: 'project-record-path' }, String(path));
|
|
await assert.rejects(api.writeHumanFile({ ...context, path: 'docs/new.md', content: '# New\n', base }), { code: 'project-read-missing' });
|
|
assert.deepEqual(state(repo), after);
|
|
});
|
|
|
|
test('content and base are checked before anything is written', async t => {
|
|
const { context, repo, opened } = await fixture(t);
|
|
const base = await opened('spec/north-star.md'), after = state(repo);
|
|
const write = (content, b) => api.writeHumanFile({ ...context, path: 'spec/north-star.md', content, base: b });
|
|
for (const content of ['', 'a\0b', 'lone \ud800 surrogate', 'x'.repeat(1024 * 1024 + 1), 'é'.repeat(600 * 1024), null, 5, { a: 1 }])
|
|
await assert.rejects(write(content, base), { code: 'project-record-invalid' });
|
|
for (const b of [undefined, '', 'HEAD', base.slice(1), base.toUpperCase(), `${base}0`, 5])
|
|
await assert.rejects(write('# Fine\n', b), { code: 'project-record-invalid' });
|
|
assert.deepEqual(state(repo), after);
|
|
const limit = await write('x'.repeat(1024 * 1024), base);
|
|
assert.match(limit.commit, /^[0-9a-f]{40}$/);
|
|
});
|
|
|
|
test('an unchanged save, a dirty tree and a busy writer refuse without a commit', async t => {
|
|
const { repo, opened, save } = await fixture(t);
|
|
const text = readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), base = await opened('spec/north-star.md');
|
|
const after = state(repo);
|
|
await assert.rejects(save('spec/north-star.md', text, base), { code: 'project-record-unchanged' });
|
|
writeFileSync(join(repo, 'docs/README.md'), '# Pending human edit\n');
|
|
await assert.rejects(save('spec/north-star.md', '# New\n', base), { code: 'project-dirty' });
|
|
git(repo, 'checkout', '--', 'docs/README.md');
|
|
writeFileSync(join(repo, '.git/mosaic-record.lock'), 'another writer');
|
|
await assert.rejects(save('spec/north-star.md', '# New\n', base), { code: 'project-busy' });
|
|
rmSync(join(repo, '.git/mosaic-record.lock'));
|
|
assert.deepEqual(state(repo), after);
|
|
});
|
|
|
|
test('a failed commit restores the exact prior bytes, including a byte-order mark, and leaves history alone', async t => {
|
|
const { context, repo, root, commitAs, opened } = await fixture(t);
|
|
const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('# With BOM\r\n')]);
|
|
commitAs('docs/bom.md', original, 'jason');
|
|
const base = await opened('docs/bom.md'), after = state(repo);
|
|
const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
|
|
const bin = join(root, 'bin'); mkdirSync(bin);
|
|
writeFileSync(join(bin, 'git'), `#!/bin/sh\nfor arg do [ "$arg" = commit ] && exit 71; done\nexec '${realGit}' "$@"\n`);
|
|
chmodSync(join(bin, 'git'), 0o755);
|
|
const saved = process.env.PATH;
|
|
try {
|
|
process.env.PATH = bin + ':' + saved;
|
|
await assert.rejects(api.writeHumanFile({ ...context, path: 'docs/bom.md', content: '# Lost\n', base }), { code: 'project-git' });
|
|
} finally { process.env.PATH = saved; }
|
|
assert.deepEqual(state(repo), after);
|
|
assert.ok(readFileSync(join(repo, 'docs/bom.md')).equals(original));
|
|
});
|