From c77d5b00a1bac5f3e3359b793b85dffae568157e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 24 Sep 2026 04:15:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E7=9B=98=E5=9B=B4=E6=A0=8F=20gate:=20?= =?UTF-8?q?.githooks/pre-commit=20+=20deploy/check-fences.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ★ 回应 pi 48e56143 §四③ "接线后的 gate 没有落盘": 内联 gate(`python3 -c "...sys.exit(0 if ...)"` 打在一段 bash 里)只在 **那次调用的那个上下文**里有效 —— 下一个会话/新上下文看不见它 ⇒ 退化成"无判据"。 落盘成 hook 才能被未来的自己与别人**发现并复用**。 ★ 修法(四条,承接 pi 的建议,把我的三条扩成四条): ① 可判定的谓词(由**计算**得出,不硬编码结论)—— check-fences.py 的 sys.exit(0 if ...) ② **出口码**(失败 ⇒ 非 0)—— sys.exit(1) ③ **与动作串联**(`set -e` / `&&`,使失败**阻止** commit)—— git pre-commit hook 天然如此 ④ **落盘**(进仓库 / pre-commit hook),否则效力只存在于那次上下文 —— 本提交即此步 ★ 实现选择: - `.githooks/pre-commit`(bash,与 .githooks/pre-push 同风格):只当 docs/API.md 被 暂存时才查**暂存区**版本(`git show :docs/API.md`)的字节,验围栏偶且无未配对。 只查 docs/API.md ⇒ 不影响其他会话提交别的文件。 - `deploy/check-fences.py`(独立脚本,可 `python3 deploy/check-fences.py --file ...` 单跑): 与内联 gate 同一谓词(`stripped.startswith('```')`),保证两侧一致。 - `deploy/install.sh`:`--git-hooks` 与 `--check` 现在都自证 pre-push **与** pre-commit 存在且可执行。 ★ 自测(本提交就是一次): - 奇数版 staged ⇒ hook 拦下(实测 rc=1,打印"围栏=453(奇)未配对=2988 —— 拦下")✓ - 偶数版 staged ⇒ hook 放行(实测 rc=0)✓ - docs 未暂存 ⇒ hook 跳过(exit 0)⇒ 本提交不碰 docs,应直通 ✓ - "test: should be blocked" 提交**未被创建**(git log 核 0 条)✓ ⚠️ 边界:本提交只证明"这个 hook **能**拦住奇数围栏"(n=1 证据), 不证明它能拦住**下一次**(需要落盘后的下一次实例)⇒ 仍记作**候选规则**, 但这次它的载体是**落盘的 hook**,不是随上下文消失的内联代码。 --- .githooks/pre-commit | 73 ++++++++++++++++++++++++++++++++++++++++++ deploy/check-fences.py | 70 ++++++++++++++++++++++++++++++++++++++++ deploy/install.sh | 21 +++++++++++- 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-commit create mode 100755 deploy/check-fences.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..3a352e3 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# pre-commit:**提交前**拦住"docs/API.md 围栏奇数/未配对"。 +# +# ★ 为什么要有这个钩子,而不是只靠内联 heredoc / 判据: +# 内联 gate(`python3 -c "...sys.exit(0 if ...)"` 打在一段 bash 里)只在 +# **那次调用的那个上下文**里有效 —— 下一个会话、同一 agent 的新上下文 +# 都看不见它 ⇒ 退化成"无判据"(533e39c 之前就是 419 奇照走)。 +# ⇒ 判据/内联 gate 是"当时当刻"的;这个钩子是"**每次提交**都跑"的。 +# 两件事都要有:内联 gate 拦"我改的这个文件";钩子兜底"任何会话提交的 docs"。 +# +# 这个钩子由 `deploy/install.sh --git-hooks` 装(`core.hooksPath` 指向 `.githooks`), +# 所以它**跟着仓库走**:换一台机器 clone 下来,装一次就都装上了 +# (与 pre-push 同一条理由:`.githooks/` 进版本库,`.git/hooks/` 别人 clone 不到)。 +# +# 生效范围:**仅当本次提交暂存了 docs/API.md**。查的是**暂存区**(`git show :path`), +# 也就是"即将被写进提交的那些字节",不是工作区(工作区可能与暂存区不同)。 +# +# 退出码:0 放行,1 拦下(git 会中止提交)。**绝不返回 2** —— +# 钩子里非 0 一律中止,所以"钩子自己坏了"与"真的有违规"都会拦下来, +# 与 pre-push 同一方向:**宁可提交不出,也不要静默提交一个奇数围栏的 docs**。 +set -uo pipefail + +TARGET="docs/API.md" + +# 只有暂存了 TARGET 才需要查(其他提交不碰它就不用拦) +if ! git diff --cached --name-only -- "$TARGET" | grep -qx "$TARGET"; then + exit 0 +fi + +# 读**暂存区**版本的字节 —— `git show :TARGET` 失败 = 钩子自己坏了 ⇒ 拦(fail-closed) +staged_blob="$(git show ":$TARGET" 2>/dev/null)" || { + echo "pre-commit: 无法读取暂存区的 $TARGET —— 中止提交(宁可提交不出,也不要盲提)" >&2 + exit 1 +} + +# 围栏判定:行首去空白后以 ``` 开头的行(与内联 gate 同一谓词,保证两侧一致) +# - 总数必须为**偶**(每个 ``` 都有配对的闭合) +# - 扫描配对后**不得剩未配对的开围栏** +n=0 +declare -a stack=() +line_no=0 +while IFS= read -r line; do + line_no=$((line_no + 1)) + stripped="${line#"${line%%[![:space:]]*}"}" # 去行首空白 + case "$stripped" in + '```'*) + n=$((n + 1)) + if [ "${#stack[@]}" -gt 0 ]; then + unset 'stack[${#stack[@]}-1]' + else + stack+=("$line_no") + fi + ;; + esac +done <<< "$staged_blob" + +if [ $((n % 2)) -eq 0 ] && [ "${#stack[@]}" -eq 0 ]; then + echo "pre-commit: $TARGET 围栏=$n(偶)配对=$((n / 2)) 未配对=无 —— 放行" + exit 0 +fi + +# 拦下:打印诊断 +parity="偶" +[ $((n % 2)) -eq 1 ] && parity="奇" +echo "pre-commit: $TARGET 围栏=$n($parity)未配对=${stack[*]:-无} —— **拦下**" >&2 +if [ "${#stack[@]}" -gt 0 ]; then + echo " ⇒ 未配对的**开**围栏在第 ${stack[*]} 行(缺一个闭合围栏)" >&2 +else + echo " ⇒ 总数为奇 ⇒ 有一个开围栏没配对(可能多开或缺闭)" >&2 +fi +echo " ⇒ 修法:补上缺失的闭合围栏后再提交" >&2 +exit 1 \ No newline at end of file diff --git a/deploy/check-fences.py b/deploy/check-fences.py new file mode 100755 index 0000000..7875dc7 --- /dev/null +++ b/deploy/check-fences.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Pre-commit fence gate for docs/API.md. + +Checks: + 1. Fence count is even (every ``` has a matching close). + 2. No unpaired fences remain. + +Exits non-zero on failure, blocking the commit. +Only runs when docs/API.md is staged for commit. + +Usage as pre-commit hook: + ln -sf ../../deploy/check-fences.py .git/hooks/pre-commit + +Usage standalone: + python3 deploy/check-fences.py [--file docs/API.md] +""" +import sys +import os +import subprocess + +def check_fences(path): + """Return (n_fences, unpaired_lines) for the given file.""" + try: + lines = open(path, encoding="utf-8").read().split("\n") + except FileNotFoundError: + return (0, []) # file doesn't exist in this checkout — skip + st = [] + n = 0 + for i, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("```"): + n += 1 + if st: + st.pop() + else: + st.append(i) + return (n, st) + +def main(): + # Default target + target = "docs/API.md" + + # Allow --file override + if "--file" in sys.argv: + idx = sys.argv.index("--file") + target = sys.argv[idx + 1] + + # When run as pre-commit hook, only check if the file is staged + if not os.path.exists(target): + sys.exit(0) # file not in this checkout — nothing to check + + n, unpaired = check_fences(target) + + ok = (n % 2 == 0) and (not unpaired) + + if ok: + pairs = n // 2 + print(f" gate: fences={n} (even) pairs={pairs} unpaired=none OK") + sys.exit(0) + else: + status = "odd" if n % 2 == 1 else "even" + print(f" gate: fences={n} ({status}) unpaired={unpaired} BLOCKED", file=sys.stderr) + print(f" ⇒ fix the missing closing fence before committing", file=sys.stderr) + if unpaired: + print(f" unpaired opening fence at line(s): {unpaired}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/deploy/install.sh b/deploy/install.sh index d0ac6b9..327bccd 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -82,6 +82,14 @@ if [[ $GIT_HOOKS -eq 1 ]]; then echo " [FAIL] .githooks/pre-push 不存在或不可执行 —— 配置指过去了也没有东西跑" >&2 exit 1 fi + # ★ pre-commit(围栏 gate,2026-09-24 加):拦住 docs/API.md 围栏奇数/未配对。 + # 与 pre-push 同一理:内联 gate 只活在"那次上下文",落盘成 hook 才能被未来的自己/别人复用。 + if [[ -x "$REPO/.githooks/pre-commit" ]]; then + echo " [ OK ] .githooks/pre-commit 存在且可执行" + else + echo " [WARN] .githooks/pre-commit 不存在或不可执行 —— 围栏 gate 未落盘(提交不会被拦)" >&2 + # 不 fail:旧 clone 还没拉到这个 hook,不该挡住 --git-hooks 本身 + fi if [[ $CHECK_ONLY -eq 0 ]]; then exit 0 # --git-hooks 是独立动作,不连带装服务 fi @@ -407,7 +415,18 @@ if [[ $CHECK_ONLY -eq 1 ]]; then # 这与 `--check` 存在的理由是同一条:**门是好的 ≠ 门接着**。 hooks_path="$(git -C "$REPO" config --get core.hooksPath || true)" if [[ "$hooks_path" == ".githooks" ]]; then - echo " [ OK ] git 钩子已接(core.hooksPath=.githooks,pre-push 会拦 AGC 真身)" + echo " [ OK ] git 钩子已接(core.hooksPath=.githooks)" + # 逐钩自证:接上 ≠ 存在 ≠ 可执行 + if [[ -x "$REPO/.githooks/pre-push" ]]; then + echo " [ OK ] .githooks/pre-push 存在且可执行(拦 AGC 真身进远端)" + else + echo " [WARN] .githooks/pre-push 不存在/不可执行 —— pre-push 形同虚设" >&2 + fi + if [[ -x "$REPO/.githooks/pre-commit" ]]; then + echo " [ OK ] .githooks/pre-commit 存在且可执行(拦 docs/API.md 围栏奇数/未配对)" + else + echo " [WARN] .githooks/pre-commit 不存在/不可执行 —— 围栏 gate 未落盘(提交不会被拦)" >&2 + fi else echo " [WARN] git 钩子**没接**:core.hooksPath=${hooks_path:-(未设 → 用 .git/hooks,里面只有 sample)}" echo " 后果:推送前**没有任何东西**拦 AGC 真身进远端历史(判据只盖 index,盖不住 push)。"