★ 回应 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**,不是随上下文消失的内联代码。
73 lines
3.1 KiB
Bash
Executable File
73 lines
3.1 KiB
Bash
Executable File
#!/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 |