1b4cb3c45e
统一比较Git恢复文件时的换行符 将WSL bash测试使用的临时工具路径转换为可执行路径 确保master推送门禁在Windows与Unix环境下均可重复验证
288 lines
8.8 KiB
JavaScript
288 lines
8.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import {
|
|
chmodSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
import { test } from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const packageJson = JSON.parse(
|
|
readFileSync(join(repoRoot, 'package.json'), 'utf8'),
|
|
);
|
|
|
|
test('pre-commit hook fixes staged imports and formatting without swallowing unstaged work', () => {
|
|
assert.equal(packageJson.scripts.prepare, 'husky');
|
|
assert.equal(packageJson.scripts['format:staged'], 'lint-staged');
|
|
assert.deepEqual(packageJson['lint-staged'], {
|
|
'*.{js,mjs,cjs,ts,tsx}': [
|
|
'node scripts/lint-staged-eslint.mjs',
|
|
'prettier --write',
|
|
],
|
|
});
|
|
assert.equal(
|
|
readFileSync(join(repoRoot, '.husky', 'pre-commit'), 'utf8'),
|
|
'npm run format:staged\n',
|
|
);
|
|
|
|
const tempRepo = mkdtempSync(join(tmpdir(), 'genarrative-git-hooks-'));
|
|
try {
|
|
git(tempRepo, 'init', '--quiet');
|
|
git(tempRepo, 'config', 'user.email', 'git-hooks-test@example.invalid');
|
|
git(tempRepo, 'config', 'user.name', 'Git Hooks Test');
|
|
writeFileSync(
|
|
join(tempRepo, '.eslintrc.cjs'),
|
|
`module.exports = ${JSON.stringify({
|
|
root: true,
|
|
ignorePatterns: ['ignored/**'],
|
|
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
|
plugins: ['simple-import-sort'],
|
|
rules: { 'simple-import-sort/imports': 'error' },
|
|
})};\n`,
|
|
);
|
|
writeFileSync(
|
|
join(tempRepo, '.prettierrc.json'),
|
|
JSON.stringify({ singleQuote: true, semi: true, trailingComma: 'all' }),
|
|
);
|
|
symlinkSync(
|
|
join(repoRoot, 'node_modules'),
|
|
join(tempRepo, 'node_modules'),
|
|
'dir',
|
|
);
|
|
|
|
const sourcePath = join(tempRepo, 'sample.ts');
|
|
const partialPath = join(tempRepo, 'partial.ts');
|
|
const ignoredDir = join(tempRepo, 'ignored');
|
|
const ignoredPath = join(ignoredDir, 'legacy.ts');
|
|
mkdirSync(ignoredDir);
|
|
writeFileSync(
|
|
sourcePath,
|
|
"import { alpha } from './alpha';\nimport { zebra } from './zebra';\n\nvoid alpha;\nvoid zebra;\n",
|
|
);
|
|
writeFileSync(partialPath, 'const original = 1;\nconst keep = 2;\n');
|
|
writeFileSync(
|
|
ignoredPath,
|
|
"import { zebra } from './zebra';\nimport { alpha } from './alpha';\nvoid zebra; void alpha;\n",
|
|
);
|
|
git(
|
|
tempRepo,
|
|
'add',
|
|
'sample.ts',
|
|
'partial.ts',
|
|
'ignored/legacy.ts',
|
|
'.eslintrc.cjs',
|
|
'.prettierrc.json',
|
|
);
|
|
git(
|
|
tempRepo,
|
|
'-c',
|
|
'commit.gpgsign=false',
|
|
'commit',
|
|
'--quiet',
|
|
'-m',
|
|
'baseline',
|
|
);
|
|
|
|
writeFileSync(
|
|
sourcePath,
|
|
"import { zebra } from './zebra';\nimport { alpha } from './alpha';\nconst staged={value:1}\nvoid zebra; void alpha; void staged;\n",
|
|
);
|
|
writeFileSync(partialPath, 'const original={value:1}\nconst keep = 2;\n');
|
|
writeFileSync(
|
|
ignoredPath,
|
|
"import { zebra } from './zebra';\nimport { alpha } from './alpha';\nvoid zebra; void alpha;\n// staged ignored change\n",
|
|
);
|
|
git(tempRepo, 'add', 'sample.ts', 'partial.ts', 'ignored/legacy.ts');
|
|
writeFileSync(
|
|
partialPath,
|
|
'const original={value:1}\nconst keep={unstaged:true}\n',
|
|
);
|
|
|
|
const lintStagedConfig = {
|
|
'*.{js,mjs,cjs,ts,tsx}': [
|
|
`node ${JSON.stringify(join(repoRoot, 'scripts', 'lint-staged-eslint.mjs'))}`,
|
|
'prettier --write',
|
|
],
|
|
};
|
|
|
|
const lintStaged = spawnSync(
|
|
process.execPath,
|
|
[
|
|
join(repoRoot, 'node_modules', 'lint-staged', 'bin', 'lint-staged.js'),
|
|
'--config',
|
|
'-',
|
|
],
|
|
{
|
|
cwd: tempRepo,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${join(repoRoot, 'node_modules', '.bin')}${delimiter}${process.env.PATH ?? ''}`,
|
|
},
|
|
input: JSON.stringify(lintStagedConfig),
|
|
},
|
|
);
|
|
|
|
assert.equal(
|
|
lintStaged.status,
|
|
0,
|
|
`${lintStaged.stdout ?? ''}${lintStaged.stderr ?? ''}`,
|
|
);
|
|
assert.equal(
|
|
git(tempRepo, 'show', ':sample.ts'),
|
|
"import { alpha } from './alpha';\nimport { zebra } from './zebra';\nconst staged = { value: 1 };\nvoid zebra;\nvoid alpha;\nvoid staged;\n",
|
|
);
|
|
assert.equal(
|
|
git(tempRepo, 'show', ':partial.ts'),
|
|
'const original = { value: 1 };\nconst keep = 2;\n',
|
|
);
|
|
assert.equal(
|
|
readFileSync(partialPath, 'utf8').replaceAll('\r\n', '\n'),
|
|
'const original = { value: 1 };\nconst keep={unstaged:true}\n',
|
|
);
|
|
assert.equal(
|
|
git(tempRepo, 'show', ':ignored/legacy.ts'),
|
|
"import { zebra } from './zebra';\nimport { alpha } from './alpha';\nvoid zebra;\nvoid alpha;\n// staged ignored change\n",
|
|
);
|
|
} finally {
|
|
rmSync(tempRepo, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
test('pre-push runs repository parity only for master updates', () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-pre-push-'));
|
|
try {
|
|
const npmLog = join(tempDir, 'repo', 'npm.log');
|
|
const tempRepo = join(tempDir, 'repo');
|
|
mkdirSync(tempRepo);
|
|
git(tempRepo, 'init', '--quiet');
|
|
git(tempRepo, 'config', 'user.email', 'git-hooks-test@example.invalid');
|
|
git(tempRepo, 'config', 'user.name', 'Git Hooks Test');
|
|
writeFileSync(join(tempRepo, 'tracked.txt'), 'baseline\n');
|
|
git(tempRepo, 'add', 'tracked.txt');
|
|
git(
|
|
tempRepo,
|
|
'-c',
|
|
'commit.gpgsign=false',
|
|
'commit',
|
|
'--quiet',
|
|
'-m',
|
|
'baseline',
|
|
);
|
|
const localSha = git(tempRepo, 'rev-parse', 'HEAD').trim();
|
|
writeFileSync(
|
|
join(tempRepo, 'git'),
|
|
`#!/usr/bin/env bash
|
|
if [[ "$1" == 'rev-parse' && "$2" == 'HEAD' ]]; then
|
|
printf '%s\\n' '${localSha}'
|
|
exit 0
|
|
fi
|
|
if [[ "$1" == 'diff' ]]; then exit 0; fi
|
|
exit 1
|
|
`,
|
|
);
|
|
chmodSync(join(tempRepo, 'git'), 0o755);
|
|
writeFileSync(
|
|
join(tempRepo, 'pre-push-master.sh'),
|
|
readFileSync(join(repoRoot, 'scripts', 'pre-push-master.sh'), 'utf8'),
|
|
);
|
|
writeFileSync(
|
|
join(tempRepo, 'npm'),
|
|
'#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> npm.log\n',
|
|
);
|
|
chmodSync(join(tempRepo, 'npm'), 0o755);
|
|
const spawnHook = (input) => {
|
|
writeFileSync(join(tempRepo, 'push.input'), input);
|
|
return spawnSync(
|
|
'bash',
|
|
[
|
|
'-c',
|
|
'PATH="$PWD:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; export PATH; source ./pre-push-master.sh origin example.invalid < push.input',
|
|
],
|
|
{
|
|
cwd: tempRepo,
|
|
encoding: 'utf8',
|
|
env: process.env,
|
|
},
|
|
);
|
|
};
|
|
const featurePush = spawnHook(
|
|
'refs/heads/feature 1111111111111111111111111111111111111111 refs/heads/feature 2222222222222222222222222222222222222222\n',
|
|
);
|
|
assert.equal(featurePush.status, 0, featurePush.stderr);
|
|
assert.equal(readFileOrEmpty(npmLog), '');
|
|
|
|
const masterPush = spawnHook(
|
|
`refs/heads/master ${localSha} refs/heads/master 2222222222222222222222222222222222222222\n`,
|
|
);
|
|
assert.equal(masterPush.status, 0, masterPush.stderr);
|
|
assert.equal(
|
|
readFileSync(npmLog, 'utf8'),
|
|
`run check:repository-ci -- 2222222222222222222222222222222222222222 ${localSha}\n`,
|
|
);
|
|
} finally {
|
|
rmSync(tempDir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
test('Gitea Repository checks and master pre-push share the same repository command', () => {
|
|
const workflow = readFileSync(
|
|
join(repoRoot, '.gitea', 'workflows', 'project-ci.yml'),
|
|
'utf8',
|
|
);
|
|
const repositoryScript = readFileSync(
|
|
join(repoRoot, 'scripts', 'check-repository-ci.sh'),
|
|
'utf8',
|
|
);
|
|
const prePushHook = readFileSync(
|
|
join(repoRoot, '.husky', 'pre-push'),
|
|
'utf8',
|
|
);
|
|
|
|
assert.match(workflow, /run: npm run check:repository-ci/u);
|
|
assert.equal(
|
|
(workflow.match(/npm run check:repository-ci/gu) ?? []).length,
|
|
1,
|
|
);
|
|
assert.match(repositoryScript, /npm run lint/u);
|
|
assert.match(
|
|
repositoryScript,
|
|
/SPACETIME_SCHEMA_BASE_REF="\$\{base_ref\}" npm run lint/u,
|
|
);
|
|
assert.match(repositoryScript, /npm run build/u);
|
|
assert.match(repositoryScript, /npm run check:content/u);
|
|
assert.match(repositoryScript, /git diff --check/u);
|
|
assert.equal(prePushHook, 'npm run check:pre-push-master -- "$@"\n');
|
|
});
|
|
|
|
function readFileOrEmpty(path) {
|
|
try {
|
|
return readFileSync(path, 'utf8');
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
return '';
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function toBashPath(path) {
|
|
const windowsDrive = /^([A-Za-z]):[\\/](.*)$/u.exec(path);
|
|
if (windowsDrive) {
|
|
return `/mnt/${windowsDrive[1].toLowerCase()}/${windowsDrive[2].replaceAll('\\', '/')}`;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function git(cwd, ...args) {
|
|
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
}
|