Files
stack/packages/project/tests/human-file.test.mjs
jason.woltje 7e0d3c93f3 Fix project inspectGit race with git maintenance (Vikunja #780)
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.
2026-11-02 17:55:40 -06:00

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));
});