修复 Hook 链继承仓库定位变量导致真实仓库被夹具污染
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m30s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m47s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m53s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m8s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m48s
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / Frontend tests (push) Failing after 4m6s
Project CI / Repository checks (push) Successful in 3m57s
Project CI / Native shell tests (push) Successful in 7m40s
Project CI / Backend tests (push) Successful in 8m27s
Project CI / AI game creator shell web tests (push) Successful in 12m53s

- .husky/pre-commit 与 .husky/pre-push 入口清除 GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE 等仓库定位变量

- scripts/check-repository-ci.sh 增加同样的清理,保证本地门禁与 CI 走同一套隔离

- scripts/git-hooks.test.mjs 夹具 Git 调用前自检仓库归属并禁用 Hook,命令落到外部仓库时直接失败

- 守卫用例的子进程必须真的继承 GIT_DIR,避免隔离断言空转

- 更新开发运维文档与 shared-memory/pitfalls.md,记录链接工作树注入形态与配置修复步骤
This commit is contained in:
kdletters
2026-09-16 10:34:32 +08:00
parent 187b66c3b1
commit 5aa616134c
6 changed files with 98 additions and 5 deletions
+83 -5
View File
@@ -5,6 +5,7 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
@@ -20,6 +21,8 @@ const packageJson = JSON.parse(
);
test('pre-commit hook fixes staged imports and formatting without swallowing unstaged work', () => {
assertGuardChildInheritsPoisonedEnvironment();
assert.equal(packageJson.scripts.prepare, 'husky');
assert.equal(packageJson.scripts['format:staged'], 'lint-staged');
assert.deepEqual(packageJson['lint-staged'], {
@@ -29,9 +32,10 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns
],
'*.rs': ['node scripts/lint-staged-rustfmt.mjs'],
});
assert.equal(
assertHookClearsGitEnvironment(
'pre-commit',
readFileSync(join(repoRoot, '.husky', 'pre-commit'), 'utf8'),
'npm run format:staged\n',
'npm run format:staged',
);
const tempRepo = createTempDirectory('genarrative-git-hooks-');
@@ -162,6 +166,8 @@ test('pre-commit hook fixes staged imports and formatting without swallowing uns
});
test('pre-push runs repository parity only for master updates', () => {
assertGuardChildInheritsPoisonedEnvironment();
const tempDir = createTempDirectory('genarrative-pre-push-');
try {
const npmLog = join(tempDir, 'repo', 'npm.log');
@@ -291,10 +297,12 @@ test('hook fixtures do not mutate the calling linked worktree or its index', ()
env: {
...isolatedGitEnvironment(),
NODE_TEST_CONTEXT: undefined,
GENARRATIVE_HOOK_GUARD_CHILD: '1',
GIT_DIR: gitDir,
GIT_COMMON_DIR: join(outerRepo, '.git'),
GIT_WORK_TREE: worktree,
GIT_INDEX_FILE: indexPath,
GIT_PREFIX: '',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.worktree',
GIT_CONFIG_VALUE_0: worktree,
@@ -353,7 +361,15 @@ test('Gitea Repository checks and master pre-push share the same repository comm
);
assert.match(repositoryScript, /npm run build/u);
assert.match(repositoryScript, /git diff --check/u);
assert.equal(prePushHook, 'npm run check:pre-push-master -- "$@"\n');
assertHookClearsGitEnvironment(
'pre-push',
prePushHook,
'npm run check:pre-push-master -- "$@"',
);
assert.match(
repositoryScript,
/^unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR/mu,
);
});
function readFileOrEmpty(path) {
@@ -376,11 +392,38 @@ function toBashPath(path) {
}
function git(cwd, ...args) {
return execFileSync('git', args, {
const env = isolatedGitEnvironment();
assertFixtureRepository(cwd, env);
return execFileSync(
'git',
['--no-pager', '-c', `core.hooksPath=${nullDevice}`, ...args],
{
cwd,
encoding: 'utf8',
env,
},
);
}
// 夹具命令必须落在夹具自己的仓库:继承的 GIT_DIR/GIT_WORK_TREE 优先级高于 cwd,曾让夹具把
// 身份、core.bare 与 core.worktree 写进调用方的真实仓库。
function assertFixtureRepository(cwd, env) {
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
encoding: 'utf8',
env: isolatedGitEnvironment(),
env,
});
if (probe.status !== 0) {
// 此时夹具目录还不是仓库(例如首次 git init),命令本身会把它建起来。
return;
}
const toplevel = realpathSync.native(probe.stdout.trim());
const expected = realpathSync.native(resolve(cwd));
assert.equal(
toplevel,
expected,
`夹具 Git 命令会落到外部仓库:cwd=${expected} toplevel=${toplevel}`,
);
}
function isolatedGitEnvironment() {
@@ -391,6 +434,41 @@ function isolatedGitEnvironment() {
);
}
// 守卫用例把本文件跑在毒化环境里;子进程必须真的继承 GIT_DIR,否则断言会空转。
function assertGuardChildInheritsPoisonedEnvironment() {
if (process.env.GENARRATIVE_HOOK_GUARD_CHILD !== '1') {
return;
}
assert.ok(process.env.GIT_DIR, '守卫子进程必须继承 GIT_DIR');
assert.equal(
Object.hasOwn(isolatedGitEnvironment(), 'GIT_DIR'),
false,
'夹具子进程环境必须清除 GIT_DIR',
);
}
function assertHookClearsGitEnvironment(hookName, contents, command) {
const lines = contents.trimEnd().split('\n');
assert.equal(
lines.at(-1),
command,
`${hookName} 最后一行必须保持原有钩子命令`,
);
const unsetLine = lines.find((line) => line.startsWith('unset '));
assert.ok(unsetLine, `${hookName} 必须先清除 Git 注入的仓库定位变量`);
const cleared = new Set(unsetLine.split(/\s+/u));
for (const variable of [
'GIT_DIR',
'GIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_COMMON_DIR',
]) {
assert.ok(cleared.has(variable), `${hookName} 必须清除 ${variable}`);
}
}
const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
function createTempDirectory(prefix) {
const tempRoot = join(homedir(), 'data', 'tmp');
mkdirSync(tempRoot, { recursive: true });