#!/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