Skip to content

Commit eca7f6e

Browse files
committed
fix(hooks): block amend of pushed commits and non-fast-forward pushes
Add defense-in-depth against rewriting published history: 1. prepare-commit-msg hook: blocks --amend when HEAD is reachable from any remote tracking branch. Detects amend via $2="commit" $3="HEAD" (--no-edit path). Errs on the side of safety for the indistinguishable -c HEAD/-C HEAD edge case. 2. pre-push hook: hard-blocks non-fast-forward pushes as a safety net for cases the prepare-commit-msg hook can't catch (e.g. --amend -m, where -m masks amend detection). 3. Agent prompt: adds git safety rules — never amend pushed commits, use single -m with embedded newlines, retry without --amend if a hook rejects the commit. 4. conventional-commits skill: documents the single -m pattern. 5. Shell tests: prepare-commit-msg_test.sh (5 cases) and updated install-hooks_test.sh for the new hook. Plan-by: glm-5.2 Acked-by: deepseek-v4-pro Signed-off-by: kyau <git@kyaulabs.com>
1 parent 145d8f3 commit eca7f6e

6 files changed

Lines changed: 356 additions & 11 deletions

File tree

.github/hooks/pre-push

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,52 @@
33

44
set -euo pipefail
55

6+
RED=$'\033[1;31m'
67
YELLOW=$'\033[1;33m'
78
ORANGE=$'\033[38;5;208m'
89
DIM=$'\033[2m'
910
BOLD=$'\033[1m'
1011
CYAN=$'\033[36m'
1112
RESET=$'\033[0m'
1213

13-
# ── No-squash check ──────────────────────────────────────────────────────────
14-
# Warn if pushing a single-commit branch that looks like a squash.
15-
# This is a soft gate — the push proceeds, but the warning is prominent.
16-
# The git history serves as the development and evaluation log; squashing
17-
# destroys the record of what was tried and abandoned.
18-
19-
while read -r _local_ref local_oid _remote_ref remote_oid; do
14+
while read -r local_ref local_oid _ remote_oid; do
2015
# Skip tag pushes and branch deletions
2116
if [ -z "$local_oid" ] || [ "$local_oid" = "0000000000000000000000000000000000000000" ]; then
2217
continue
2318
fi
2419

25-
# Compute the range: commits not yet on the remote
26-
range=""
20+
# ── Non-fast-forward check (hard gate) ─────────────────────────────────
21+
# Block pushes that would rewrite published history. amend, rebase, or
22+
# reset on a pushed commit triggers this — fix local history before pushing.
23+
if [ -n "$remote_oid" ] && [ "$remote_oid" != "0000000000000000000000000000000000000000" ]; then
24+
# git merge-base exits 0 if remote_oid is an ancestor of local_oid
25+
# (fast-forward). We run it inside an if to avoid triggering set -e.
26+
set +e
27+
git merge-base --is-ancestor "$remote_oid" "$local_oid" 2>/dev/null
28+
ancestor_rc=$?
29+
set -e
30+
if [ "$ancestor_rc" -ne 0 ]; then
31+
branch_name="${local_ref#refs/heads/}"
32+
cat >&2 <<EOF
33+
34+
${RED}╭─ BLOCKED Non-fast-forward push to ${branch_name}${RESET}
35+
${RED}${RESET}
36+
${RED}${RESET} Rewriting published history is prohibited.${RESET}
37+
${RED}${RESET} The remote has commits that would be overwritten.${RESET}
38+
${RED}${RESET}
39+
${RED}${RESET} This can happen if a pushed commit was amended, rebased,${RESET}
40+
${RED}${RESET} or reset. Fix your local history before pushing.${RESET}
41+
${RED}${RESET}
42+
${RED}╰──────────────────────────────────────────────────────────────────╯${RESET}
43+
44+
EOF
45+
exit 1
46+
fi
47+
fi
48+
49+
# ── No-squash check (soft gate) ───────────────────────────────────────
50+
# Warn if pushing a single-commit branch that looks like a squash.
51+
# The push proceeds, but the warning is prominent.
2752
if [ -n "$remote_oid" ] && [ "$remote_oid" != "0000000000000000000000000000000000000000" ]; then
2853
range="${remote_oid}..${local_oid}"
2954
else

.github/hooks/prepare-commit-msg

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
# $KYAULabs: prepare-commit-msg kyau@nova 2026/07/07 -0700 Exp $
3+
4+
set -euo pipefail
5+
6+
COMMIT_SOURCE="${2:-}"
7+
COMMIT_SHA="${3:-}"
8+
9+
# Block --amend of pushed commits.
10+
#
11+
# Git passes $2 = "commit" and $3 = "HEAD" (literal) for --amend without
12+
# -m. However, -c HEAD and -C HEAD also pass $3 = "HEAD" — indistinguishable
13+
# from --amend here. The hook errs on the side of safety and blocks both when
14+
# HEAD is pushed. Workaround: use -c <sha> / -C <sha> with an explicit SHA.
15+
#
16+
# When -m is used with --amend, $2 is "message" — indistinguishable from a
17+
# regular commit in this hook. The pre-push hook's non-fast-forward block
18+
# is the safety net for that case.
19+
if [ "$COMMIT_SOURCE" = "commit" ] && [ "$COMMIT_SHA" = "HEAD" ]; then
20+
# Check if the commit being amended has been pushed to any remote
21+
if git branch -r --contains HEAD 2>/dev/null | grep -q .; then
22+
cat >&2 <<EOF
23+
✗ Cannot amend a commit that has been pushed to a remote.
24+
HEAD is reachable from a remote tracking branch.
25+
Use a regular commit ('git commit', not '--amend') to add changes.
26+
If you need to fix the commit message for an unpushed commit, amend
27+
is safe — but this commit has already been published.
28+
EOF
29+
exit 1
30+
fi
31+
fi
32+
33+
exit 0
34+
35+
# vim: ft=sh sts=4 sw=4 ts=4 et :

.opencode/skills/conventional-commits/SKILL.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,22 @@ The hook blocks the commit if the format is invalid.
122122
Config: `commitlint.config.js` extends `@commitlint/config-conventional`,
123123
with a custom `type-enum` that adds `build`, `patch`, and `ignore` to the
124124
standard set.
125+
126+
## Passing the Message to Git
127+
128+
> [!IMPORTANT]
129+
> Pass the full commit message as a **single `-m`** argument with embedded
130+
> newlines. Do **not** use multiple `-m` flags — git inserts blank lines
131+
> between them, which breaks commitlint's trailer detection (`git interpret-trailers --parse` requires that trailers be contiguous with the body).
132+
133+
```bash
134+
# CORRECT — single -m with $'...\n...' embedded newlines
135+
git commit -S -m $'type[scope]: subject\n\nBody paragraph.\n\nPlan-by: model\nAcked-by: model\nSigned-off-by: user <email>'
136+
137+
# WRONG — multiple -m flags insert blank lines between each, breaking trailers
138+
git commit -S -m "type[scope]: subject" -m "Body." -m "Plan-by: model" -m "Acked-by: model"
139+
```
140+
141+
If the commit fails due to the commit-msg hook, the commit was **not created**.
142+
Retry with `git commit` (not `--amend`), fixing the message format. There is
143+
nothing to amend because the commit was never made.

opencode.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"git push *": "deny"
2020
}
2121
},
22-
"prompt": "You are the primary build agent for a KYAULabs PHP project.\n\nStack: PHP 8.5+ (no MVC, no router), MariaDB, nginx, SCSS + vanilla JS, Pest v4.\n\n`AGENTS.md` (loaded every session) is the authoritative source for stack, boundaries, directory structure, hard boundaries, indentation, and the skills/agents/commands available. Do not restate those rules to the user — just enforce them.\n\nEnforcement posture (these are the triggers you actively gate on):\n- Any new feature or bug fix requiring tests → invoke the @tdd agent (Red → Green → Refactor, vertical slices).\n- Before creating or modifying any source file → load the rcs-header skill (RCS header + vim modeline on every file).\n- Before domain-coupled work → read CONTEXT.md; if missing, suggest /prime. Update it when domain terms or entities change.\n- Non-trivial or cross-cutting change → suggest an @architect review before implementing; write an ADR when a decision is hard to reverse.\n- Frontend visual work → load the frontend-design skill first.\n- Auth, session, SQL, or user-input handling → load the security-coding skill first.\n- Follow the full pipeline when applicable: brainstorming → prototype (if needed) → writing-plans → executing-plans → @tdd → verification-before-completion → /check → @code-review. See AGENTS.md for the complete flow.\n- Context management → if the session is approaching degradation (long conversation, many file reads), load .opencode/docs/context-management.md and follow its guidance (compact with a hint, use subagents, or /handoff and start fresh)."
22+
"prompt": "You are the primary build agent for a KYAULabs PHP project.\n\nStack: PHP 8.5+ (no MVC, no router), MariaDB, nginx, SCSS + vanilla JS, Pest v4.\n\n`AGENTS.md` (loaded every session) is the authoritative source for stack, boundaries, directory structure, hard boundaries, indentation, and the skills/agents/commands available. Do not restate those rules to the user — just enforce them.\n\nEnforcement posture (these are the triggers you actively gate on):\n- Any new feature or bug fix requiring tests → invoke the @tdd agent (Red → Green → Refactor, vertical slices).\n- Before creating or modifying any source file → load the rcs-header skill (RCS header + vim modeline on every file).\n- Before domain-coupled work → read CONTEXT.md; if missing, suggest /prime. Update it when domain terms or entities change.\n- Non-trivial or cross-cutting change → suggest an @architect review before implementing; write an ADR when a decision is hard to reverse.\n- Frontend visual work → load the frontend-design skill first.\n- Auth, session, SQL, or user-input handling → load the security-coding skill first.\n- Follow the full pipeline when applicable: brainstorming → prototype (if needed) → writing-plans → executing-plans → @tdd → verification-before-completion → /check → @code-review. See AGENTS.md for the complete flow.\n- Context management → if the session is approaching degradation (long conversation, many file reads), load .opencode/docs/context-management.md and follow its guidance (compact with a hint, use subagents, or /handoff and start fresh).\n- Git safety → never amend a pushed commit (check: `git branch -r --contains HEAD`). Never use multiple `-m` flags — pass the full message as a single `-m` with `$'...\n...'` embedded newlines (multiple `-m` insert blank lines that break commitlint's trailer detection). If a commit fails due to a hook, retry with `git commit` (not `--amend`) — the commit was never created, so there is nothing to amend."
2323
},
2424
"plan": {
2525
"mode": "primary",

tests/Shell/install-hooks_test.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ rm -rf "$T1"
8989
# ── Test 2: All hooks committed as 100755 ─────────────────────────────────────
9090

9191
echo "── Test 2: Hooks committed as 100755 ──"
92-
for hook in commit-msg post-checkout post-merge pre-commit pre-push; do
92+
for hook in commit-msg post-checkout post-merge pre-commit pre-push prepare-commit-msg; do
9393
mode=$(git -C "$REPO_ROOT" ls-files -s ".github/hooks/$hook" 2>/dev/null | awk '{print $1}')
9494
if [ "$mode" = "100755" ]; then
9595
pass "$hook is 100755"

0 commit comments

Comments
 (0)