From 5aa616134cf04895cb7db5f27d8ff684f163f368 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:34:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Hook=20=E9=93=BE=E7=BB=A7?= =?UTF-8?q?=E6=89=BF=E4=BB=93=E5=BA=93=E5=AE=9A=E4=BD=8D=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9C=9F=E5=AE=9E=E4=BB=93=E5=BA=93=E8=A2=AB?= =?UTF-8?q?=E5=A4=B9=E5=85=B7=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .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,记录链接工作树注入形态与配置修复步骤 --- .husky/pre-commit | 3 + .husky/pre-push | 3 + docs/project-memory/shared-memory/pitfalls.md | 3 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + scripts/check-repository-ci.sh | 4 + scripts/git-hooks.test.mjs | 88 +++++++++++++++++-- 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 78fe78bb9..65c4d6e28 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,4 @@ +# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd; +# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES npm run format:staged diff --git a/.husky/pre-push b/.husky/pre-push index fdb72ecc2..2044c66f1 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1,4 @@ +# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd; +# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES npm run check:pre-push-master -- "$@" diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 444798e15..a34032ac2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5614,5 +5614,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 在 hook 内运行临时仓库测试时,`cwd` 不会覆盖继承的 `GIT_DIR`、`GIT_WORK_TREE` 或 `GIT_INDEX_FILE`。未隔离的 Git/lint-staged 子进程可能向真实仓库提交 fixture,甚至把测试版 ESLint、Prettier 配置带入主分支。 - fixture 子进程统一清除 `GIT_*` 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。 +- 2026-09-16 复核:从链接工作树 `git push`/`git commit` 时,Git 注入 `GIT_DIR=<主仓库>/.git/worktrees/`、`GIT_WORK_TREE`、`GIT_INDEX_FILE`,husky → npm → `check:repository-ci` → 夹具测试整链条继承。夹具 `git config user.name "Git Hooks Test"` 会写进共享 `.git/config`(此后所有提交 author 变成 `Git Hooks Test`);夹具 `git init` 按是否带 `GIT_WORK_TREE` 分别写成 `core.bare=true`(`fatal: this operation must be run in a work tree`)或 `core.worktree=<临时夹具目录>`(`git status` 实际在操作临时目录)。 +- 处理:钩子与门禁入口先 `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES`;夹具 Git 调用在命令前自检 `rev-parse --show-toplevel` 等于夹具目录,落到外部仓库立即失败;守卫用例的子进程必须真的继承 `GIT_DIR`,否则断言会空转。 +- 验证:`git config --show-origin --get user.name` 出现 `file:.git/config Git Hooks Test`、`git rev-parse --show-toplevel` 指向 `%TEMP%\genarrative-pre-push-*\repo` 都是被污染的确定性证据;被 `core.worktree` 劫持期间执行的 `git pull` 会把检出写进临时目录,真实工作树整体落后(本次 93 个文件),配置修好后用 `git checkout HEAD -- .` 回填。 - Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 - 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index b8ea283a1..b426bb287 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -4,6 +4,8 @@ Git hook 的临时仓库测试必须清除子进程继承的仓库定位环境(例如 `GIT_DIR`、`GIT_WORK_TREE`、`GIT_INDEX_FILE`);仅设置 `cwd` 不能隔离 Git。回归应从带这些变量的外层仓库运行,验证外层引用、索引与配置不变。临时测试文件必须留在独立目录并清理,不得通过测试生成主仓库提交或覆盖 ESLint、Prettier 配置。修复 lint 配置时保留原有规则、忽略范围与零警告门禁,不以关闭规则代替排障。 +链接工作树(`git worktree`)里执行 `git push`、`git commit` 时,Git 会把 `GIT_DIR`(指向该工作树的管理目录)、`GIT_WORK_TREE`、`GIT_INDEX_FILE` 注入 Hook,`npm`、`lint-staged` 与测试夹具整链条继承;夹具的 `git config` 会写到共享 `.git/config`(此后所有提交 author 变成夹具身份),夹具的 `git init` 会重写 `core.bare` 或 `core.worktree`。因此 `.husky/pre-commit`、`.husky/pre-push` 与 `scripts/check-repository-ci.sh` 入口必须 `unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES`,夹具 Git 调用还要自检 `git rev-parse --show-toplevel` 等于夹具目录,落到外部仓库时直接失败。真实仓库被污染后的修复顺序:备份 `.git/config` → `git config --unset core.worktree` → 确认 `core.bare=false` → `git config --unset user.name` 与 `user.email` 恢复全局身份 → 核对 `.git/worktrees/*/config.worktree` → 用 `git checkout HEAD -- .` 回填被劫持期间漏掉的检出(未跟踪文件不动)。 + Stdb 发布以 root 准备文件、再切换 `spacetimedb` 用户执行时,WASM 必须放在服务用户可遍历的父目录下。生产脚本在 `/var/tmp` 创建随机私有目录并移交给发布用户,退出时清理;不能仅修改子目录所有权后继续从 `/root` 下读取。发布成功的判据仍是完整 Full Build 的 Stdb、API、Web 发布及退出维护模式全部成功。 更新时间:`2026-08-05` diff --git a/scripts/check-repository-ci.sh b/scripts/check-repository-ci.sh index b3a91d42b..70d156b64 100755 --- a/scripts/check-repository-ci.sh +++ b/scripts/check-repository-ci.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +# 链接工作树推送时 Git 会向 Hook 注入 GIT_DIR 等仓库定位变量;npm 链上的测试夹具会继承 +# 它们并绕过 cwd 写到真实仓库,这里统一清除,保证门禁只作用于当前工作树。 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES + base_ref="${SPACETIME_SCHEMA_BASE_REF:-${1:-}}" head_ref="${REPOSITORY_CI_HEAD_REF:-${2:-HEAD}}" diff --git a/scripts/git-hooks.test.mjs b/scripts/git-hooks.test.mjs index 295973c8c..fded755c8 100644 --- a/scripts/git-hooks.test.mjs +++ b/scripts/git-hooks.test.mjs @@ -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 });