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.
This commit is contained in:
jason.woltje committed 2026-11-02 17:55:40 -06:00
1 parent 9256129228
commit 7e0d3c93f3
7 files changed
+54 -13

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
export 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();
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, access = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
+12 -5
View File
@@ -17,12 +17,19 @@ export async function git(repo, args, identity = {}) {
}
export async function inspectGit(repo) {
// Refuse gitdir indirection, alternates, links or hardlinks before git reads metadata.
const walk = directory => {
const st = lstatSync(directory);
// Everything below .git was named by a readdir, so a name that is gone when it is read was
// removed by a concurrent git process (for example a pack temp file). That refuses; it is
// never skipped or retried, because the metadata did not hold still while it was checked.
const changed = e => e.code === 'ENOENT'
? new ProjectError('project-repository', 'git metadata changed during inspection') : e;
const statEntry = path => { try { return lstatSync(path); } catch (e) { throw changed(e); } };
const namesIn = directory => { try { return readdirSync(directory); } catch (e) { throw changed(e); } };
// The root is not a listed name: a missing .git keeps its own error, as before.
const walk = (directory, st = lstatSync(directory)) => {
if (!st.isDirectory() || st.isSymbolicLink()) throw new ProjectError('project-repository', 'expected local .git directory');
for (const name of readdirSync(directory)) {
const path = join(directory, name), entry = lstatSync(path);
if (entry.isDirectory()) walk(path);
for (const name of namesIn(directory)) {
const path = join(directory, name), entry = statEntry(path);
if (entry.isDirectory()) walk(path, entry);
else if (!entry.isFile() || entry.nlink !== 1)
throw new ProjectError('project-repository', 'git metadata must contain regular private files');
}
+1 -1
View File
@@ -6,7 +6,7 @@ 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', repo, ...args], { encoding: 'utf8' }).trim();
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 }));
+1 -1
View File
@@ -8,7 +8,7 @@ const api = await import('../src/index.mjs').catch(e => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e;
});
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
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();
const actor = { definition: 'coder', instance: 'builder' };
const requirement = { recordVersion: 1, id: 'REQ-PROJECT-1', title: 'A requirement', text: 'Keep local evidence', status: 'draft' };
const decision = { recordVersion: 1, id: 'DEC-1', question: 'Which path?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', status: 'open', choice: null };
+37 -3
View File
@@ -1,11 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync, readFileSync, symlinkSync } from 'node:fs';
import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, symlinkSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import * as api from '../src/index.mjs';
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
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-spec-read-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -100,6 +101,39 @@ test('path history is bounded at 100 and explicitly marks truncation', async t =
assert.equal(result.log[0].commit, result.commit);
});
// Row 780: git can write and rename pack temp files while the metadata walk is running. The walk
// calls readdirSync and lstatSync through the builtin's live bindings, so the test replaces
// fs.readdirSync for one path and removes the named entry itself. The product then sees exactly
// what a concurrent git process produces, with no hook in production code.
test('a git metadata entry that vanishes during inspection refuses as project-repository, never raw ENOENT', async t => {
const { context, repo, commit } = await fixture(t);
const pack = join(repo, '.git/objects/pack'), temp = join(pack, 'tmp_idx_vanishes');
const directory = join(repo, '.git/objects/tmp_dir_vanishes');
writeFileSync(temp, 'x'); mkdirSync(directory);
assert.equal((await api.readSpec(context)).commit, commit, 'control: the extra entries alone do not refuse');
const real = fs.readdirSync, unlink = fs.unlinkSync, rmdir = fs.rmdirSync;
let armed = null;
fs.readdirSync = function (path, ...rest) {
if (armed === 'file' && String(path) === pack) { const names = real.call(this, path, ...rest); unlink(temp); return names; }
if (armed === 'directory' && String(path) === directory) rmdir(directory);
return real.call(this, path, ...rest);
};
syncBuiltinESMExports();
t.after(() => { fs.readdirSync = real; syncBuiltinESMExports(); });
for (const kind of ['file', 'directory']) {
armed = kind;
await assert.rejects(api.readSpec(context), error => {
assert.equal(error.code, 'project-repository', `${kind}: ${error.message}`);
assert.match(error.message, /changed during inspection/);
return true;
});
assert.equal(armed, kind);
armed = null;
writeFileSync(temp, 'x'); if (!fs.existsSync(directory)) mkdirSync(directory);
}
assert.equal(git(repo, 'rev-parse', 'HEAD'), commit);
});
test('committed UTF-8 BOM, Unicode and trailing newlines are returned without normalization', async t => {
const { context, save } = await fixture(t);
const value = '\uFEFF# North star 🧭\r\n\r\n';
@@ -117,7 +151,7 @@ test('D4: spec log refuses a commit subject larger than its 256 KiB read bound',
writeFileSync(message, 'X'.repeat(256 * 1024 + 1) + '\n');
writeFileSync(join(repo, 'spec/north-star.md'), '# Small spec\n');
execFileSync('git', ['-C', repo, 'add', '--', 'spec/north-star.md']);
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false',
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-c', 'commit.gpgsign=false',
'-c', 'user.name=jason', '-c', '[email protected]', '-C', repo, 'commit', '-F', message],
{ stdio: ['ignore', 'ignore', 'pipe'] });
await assert.rejects(api.readSpec({ business, project: 'stack', dataRoot: root }), { code: 'project-git' });
+1 -1
View File
@@ -8,7 +8,7 @@ import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { initProject, testerProjectArea } from '../src/index.mjs';
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
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();
function context(t, repo) {
const root = mkdtempSync(join(tmpdir(), 'project-tester-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
+1 -1
View File
@@ -39,7 +39,7 @@ export async function testerFixture(t, { enabled = true } = {}) {
writeFileSync(join(root, 'outside.md'), OUTSIDE);
symlinkSync(join(root, 'outside.md'), join(root, 'records/docs/out.md'));
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'add', 'docs/out.md'], { cwd: join(root, 'records') });
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'commit', '-m', 'Fixture symlink'], {
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', 'commit', '-m', 'Fixture symlink'], {
cwd: join(root, 'records'), env: { ...process.env, GIT_AUTHOR_NAME: 'jason', GIT_AUTHOR_EMAIL: 'fixture@invalid', GIT_COMMITTER_NAME: 'jason', GIT_COMMITTER_EMAIL: 'fixture@invalid' },
});
mkdirSync(f.dataRoot, { recursive: true, mode: 0o700 });