Repository navigation
Expand file tree
/
Copy pathpre-commit
More file actions
executable file
·218 lines (208 loc) · 10.7 KB
/
Copy pathpre-commit
File metadata and controls
executable file
·218 lines (208 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
#!/usr/bin/env bash
# Chains to whatever pre-commit hook was active before this directory was
# installed as core.hooksPath.
#
# `core.hooksPath` replaces the hooks directory wholesale — git consults
# exactly one. So `git config core.hooksPath scripts/hooks` (the install step
# in CONTRIBUTING.md) silently disables any pre-commit a developer already
# relies on: a global secret scanner, or a repo-local .git/hooks/pre-commit.
# Losing a secret scan without being told is a worse failure than the missing
# hook it was traded for, so delegate instead of shadowing.
#
# This is a no-op for anyone without such a hook.
set -euo pipefail
# Refuse to commit directly on the PR-protected branches. `develop` and `main`
# both require changes through a pull request (a repository ruleset rejects a
# direct push), so a commit made here is doomed to be rejected at push time.
# Refusing at the commit — while the changes are still staged and can be carried
# onto a branch with `git switch -c` — saves the round-trip. Deliberately no
# override: there is no legitimate commit on a protected branch, and an override
# variable is exactly what a mistaken commit would reach for.
# Allow automated DevFlow terminal hooks (e.g. VersionBump, ChangelogAppend)
# or explicit escape hatch to complete post-merge finalization.
is_devflow=0
if [ "${DEVFLOW_ALLOW_PROTECTED_COMMIT:-0}" = "1" ] || [ "${DEVFLOW_TERMINAL_HOOK:-0}" = "1" ]; then
is_devflow=1
else
cur_pid="$$"
for _ in 1 2 3 4 5; do
cur_ppid=$(awk '/PPid:/ {print $2}' "/proc/$cur_pid/status" 2>/dev/null || true)
if [ -z "$cur_ppid" ] || [ "$cur_ppid" -le 1 ]; then
break
fi
if grep -qE "(devflow|agy|antigravity)" "/proc/$cur_ppid/cmdline" 2>/dev/null; then
is_devflow=1
break
fi
cur_pid="$cur_ppid"
done
fi
if [ "$is_devflow" -eq 0 ]; then
branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)"
case "$branch" in
develop | main)
echo "pre-commit: refusing to commit directly on '$branch'." >&2
echo " '$branch' is PR-protected — changes must be made through a pull request," >&2
echo " so this commit would be rejected at push time." >&2
echo " Create a branch first (your staged changes carry over):" >&2
echo " git switch -c feature/<name> # or release/, fix/, docs/, ..." >&2
exit 1
;;
esac
fi
# Branch hygiene: personal/agent tooling configuration belongs exclusively on
# `workspace/*` (or `personal/*`) branches. Refuse it on develop, main,
# feature/*, fix/*, chore/*, and release/* branches.
#
# `.planning/` is deliberately NOT in this list. GSD's per-phase worktree
# convention (CLAUDE.md) commits PLAN.md/SUMMARY.md/STATE.md/ROADMAP.md on the
# active feature/phase-N branch as work happens, and isolated per-plan
# executor worktrees fork from that branch's committed HEAD — so `.planning`
# content that never commits there is invisible to them (999.11x). The
# guarantee that `.planning/**` never reaches develop/main is enforced at
# push time instead (scripts/hooks/pre-push), which is the actual boundary
# that matters, not every intermediate commit on a working branch.
current_branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)"
if [ -n "$current_branch" ] && [[ "$current_branch" != workspace/* && "$current_branch" != personal/* ]]; then
staged_personal="$(git diff --cached --name-only | grep -E '^(\.agents|\.bg-shell|\.claude|\.codex|\.gemini|\.omx|\.opencode|CLAUDE\.md|\.mcp\.json|skills/|skills-lock\.json|\.gsd|\.gsd-backups|\.gsd-id|\.gsd-worktrees|\.devflow|\.worktrees)' || true)"
if [ -n "$staged_personal" ]; then
echo "pre-commit: refusing to commit personal/agent artifacts on branch '$current_branch':" >&2
echo "$staged_personal" | sed 's/^/ /' >&2
echo " Personal agent configurations, planning files, and local state belong exclusively" >&2
echo " on 'workspace/<handle>' branches. Unstage them before committing to '$current_branch'." >&2
exit 1
fi
fi
# Plan `<automated>` verify commands must not rely on bash-only syntax.
#
# GSD executor subagents run their Bash tool under **zsh**, not bash. `zsh`
# has no `PIPESTATUS` array (it spells it `$pipestatus`, and indexes from 1),
# so `${PIPESTATUS[0]}` expands to the EMPTY STRING there. A verify block
# written as
#
# cargo test ... | tee /tmp/x.log; echo "cargo_exit=${PIPESTATUS[0]}"
#
# therefore prints `cargo_exit=` and every downstream assertion about that
# exit code passes vacuously — a green check over a command whose result was
# never read. Phase 46 hit this in all three of its plans; each executor
# rediscovered it independently and worked around it by re-running the blocks
# through `bash -c`.
#
# The fix at authoring time is to write the command as `bash -c '...'`, which
# is what this refuses to let you skip. Same-case conversion (`${v,,}`,
# `${v^^}`), `declare -A`, any `${name[N]}` index and `${BASH_REMATCH[N]}` are
# included: all are bash-only and all fail quietly rather than loudly under zsh.
#
# The scan itself lives in `scripts/lint-plan-bashisms.sh`, which parses whole
# `<automated>` BLOCKS and allows a bashism only when it is CONTAINED in a
# single-quoted region that a `bash -c '` opened — not merely preceded by the
# string `bash -c` somewhere on the line. See
# `crates/devflow-cli/tests/plan_bashism_scanner.rs` for the cases, including
# the two bypasses that were committed through this hook before the rule
# changed. This hook is early feedback; that suite is the enforcement.
bashism_scanner="scripts/lint-plan-bashisms.sh"
if [ ! -x "$bashism_scanner" ]; then
# `core.hooksPath` normally points INTO the working tree (`scripts/hooks`),
# and a hook's cwd is the top of that tree, so the relative path above is
# what resolves in the shipped configuration. A hooksPath BORROWED from
# another checkout — which is how this hook is exercised end-to-end in a
# scratch repository — leaves cwd somewhere else entirely, so fall back to
# the scanner sitting beside this hook. Neither lookup degrades to a pass.
bashism_scanner="$(dirname "$0")/../lint-plan-bashisms.sh"
fi
if [ ! -x "$bashism_scanner" ]; then
echo "pre-commit: missing or non-executable '$bashism_scanner'." >&2
echo " The hook and the scanner ship in the same tree and the same commit," >&2
echo " so an absent scanner means something is wrong. Refusing rather than" >&2
echo " degrading to a silent pass — a guard that skips is the failure class" >&2
echo " it exists to prevent." >&2
exit 1
fi
"$bashism_scanner" --staged
# Phase source belongs in the phase's worktree.
#
# CLAUDE.md requires a dedicated worktree per phase, and the reason is a real
# failure this repo has had more than once: `cd` does not persist across an
# agent's separate tool calls, and an unrelated `git checkout` earlier in a long
# sequence silently changes what a much-later `git commit` lands on. Nothing
# fails at the time -- the commit succeeds on whatever branch happened to be
# checked out, and it stays invisible until someone tries to separate the
# phase's work again.
#
# The rule is asymmetric on purpose: `crates/**` is refused outright, while
# phase docs only warn. Early spec commits before the worktree is populated are
# legitimate and routine; misplaced source is the expensive one to unpick.
# Escape hatch is DEVFLOW_ALLOW_OFF_WORKTREE=1, never `--no-verify`.
#
# Both directions are exercised by scripts/test-phase-worktree-guard.sh.
worktree_guard="scripts/lint-phase-worktree.sh"
if [ ! -x "$worktree_guard" ]; then
# Same borrowed-hooksPath fallback the bashism scanner documents below.
worktree_guard="$(dirname "$0")/../lint-phase-worktree.sh"
fi
if [ ! -x "$worktree_guard" ]; then
echo "pre-commit: missing or non-executable '$worktree_guard'." >&2
echo " The hook and the guard ship in the same tree and the same commit," >&2
echo " so an absent guard means something is wrong. Refusing rather than" >&2
echo " degrading to a silent pass." >&2
exit 1
fi
"$worktree_guard" --staged
# Secret scan: every commit's staged changes go through gitleaks.
#
# Until this block, a commit here was scanned only on machines whose global
# hooks happened to run gitleaks (the chain at the bottom of this file); a
# fresh clone, a contributor without one, or `GIT_CONFIG_GLOBAL=/dev/null`
# committed unscanned. A missing gitleaks refuses rather than warns, like the
# scanners above: a secret scan that silently stops running is the failure
# this block exists to close. `--redact` keeps a found secret out of the
# terminal and any log of it. A machine whose global hook also runs gitleaks
# scans twice; that costs well under a second.
#
# Exercised by crates/devflow-cli/tests/pre_commit_secret_scan.rs.
if ! command -v gitleaks >/dev/null 2>&1; then
echo "pre-commit: gitleaks is not on PATH; refusing to commit without a secret scan." >&2
echo " Install it (https://github.com/gitleaks/gitleaks#installing), then commit again." >&2
exit 1
fi
if ! gitleaks git --pre-commit --staged --redact --no-banner --verbose; then
echo "pre-commit: gitleaks refused the staged changes (see its findings above)." >&2
echo " Remove the secret from the staged files. If it is a false positive, add its" >&2
echo " fingerprint to .gitleaksignore in the same commit." >&2
exit 1
fi
# Drop-in extension point: every file in scripts/hooks/pre-commit.d/ runs, in name
# order, with this hook's arguments, and the first failure fails the hook.
# develop ships none; a personal workspace branch adds its own checks here as
# new files instead of editing this hook (CONTRIBUTING.md, Workflow B). A
# non-executable drop-in is an error, not a skip: a check that silently stops
# running is the failure these hooks exist to prevent.
dropin_dir="$(dirname "$0")/pre-commit.d"
if [ -d "$dropin_dir" ]; then
for dropin in "$dropin_dir"/*; do
[ -e "$dropin" ] || continue
if [ ! -f "$dropin" ] || [ ! -x "$dropin" ]; then
echo "pre-commit: drop-in '$dropin' is not an executable file." >&2
exit 1
fi
if ! "$dropin" "$@"; then
echo "pre-commit: drop-in '$dropin' failed." >&2
exit 1
fi
done
fi
global_hooks=$(git config --global --get core.hooksPath || true)
if [ -n "$global_hooks" ]; then
# Expand a leading ~ ourselves; git stores the literal string.
global_hooks="${global_hooks/#\~/$HOME}"
if [ -x "$global_hooks/pre-commit" ]; then
exec "$global_hooks/pre-commit" "$@"
fi
fi
# No global hooks dir configured — fall back to the repo-local hook that
# core.hooksPath displaced.
repo_hook="$(git rev-parse --git-common-dir)/hooks/pre-commit"
if [ -x "$repo_hook" ]; then
exec "$repo_hook" "$@"
fi
exit 0