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:
1 parent
9256129228
commit
7e0d3c93f3
7 files changed
+54
-13
No files matched your search
@@ -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-'));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user