-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathpost-commit
More file actions
executable file
·163 lines (154 loc) · 7.63 KB
/
Copy pathpost-commit
File metadata and controls
executable file
·163 lines (154 loc) · 7.63 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
#!/usr/bin/env sh
# ============================================================================
# agentmap — git post-commit hook
#
# Rebuilds .claude/agentmap/map.json after each commit so the map an agent
# reads is never stale. Runs in the background and detached so it never slows
# the commit.
#
# Guards:
# - Skips during rebase / merge / cherry-pick / bisect (avoids rebuilding on
# every replayed commit — the map rebuilds once the operation finishes and
# you commit normally).
# - No-ops cleanly if Node or agentmap.mjs is missing.
# - nvm caveat: git hooks run in a non-login shell that does not source nvm
# (or ~/.bashrc / ~/.zshrc), so `node` may be absent on PATH. The
# `command -v node || exit 0` guard below no-ops cleanly in that case.
#
# Install: copy to .git/hooks/post-commit and `chmod +x` it (see hooks/INSTALL.md).
# ============================================================================
# Resolve the repo root from the hook's own location (.git/hooks/post-commit).
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
GITDIR="$(git rev-parse --git-dir 2>/dev/null)" || exit 0
# Guard: don't rebuild while a multi-commit operation is replaying commits.
for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD BISECT_LOG REVERT_HEAD; do
if [ -e "$GITDIR/$state" ]; then
exit 0
fi
done
# ---------------------------------------------------------------------------
# Runner resolution — security rationale:
#
# NO repo-local script is trusted by default. A working-tree ./agentmap.mjs
# is attacker-plantable: any branch or PR can add that file, and this hook
# fires on the victim's next commit — arbitrary code execution without
# touching any obviously sensitive file (./scripts/agentmap.mjs was removed
# for the same reason in 0.4.0).
#
# Set AGENTMAP_HOOK_ALLOW_LOCAL=1 to opt IN to running ./agentmap.mjs —
# intended only for developing agentmap itself. Everyone else gets the
# installed package: the repo's node_modules bin, a PATH binary verified to
# resolve to @raymondchins/agentmap, or npx with the scoped name.
#
# We cd "$ROOT" before running, so use RELATIVE paths — avoids word-splitting on
# spaces in the repo path (POSIX sh has no arrays; quoting $RUNNER at invocation
# would bundle cmd+args into one token and break argument passing).
# ---------------------------------------------------------------------------
RUNNER=""
# 1) Repo-local script — explicit opt-in only (dogfooding agentmap itself).
if [ "$AGENTMAP_HOOK_ALLOW_LOCAL" = "1" ] && [ -f "$ROOT/agentmap.mjs" ]; then
RUNNER="node ./agentmap.mjs"
fi
# 2) Project-local install of the scoped package.
if [ -z "$RUNNER" ] && [ -x "$ROOT/node_modules/.bin/agentmap" ]; then
RUNNER="./node_modules/.bin/agentmap"
fi
# 3) PATH binary — only when it resolves to the scoped package (npm bin
# symlinks point into .../@raymondchins/agentmap/; an unrelated `agentmap`
# binary or a PATH-planted impostor does not).
if [ -z "$RUNNER" ] && command -v agentmap >/dev/null 2>&1; then
BIN="$(command -v agentmap)"
TARGET="$(readlink "$BIN" 2>/dev/null || printf '%s' "$BIN")"
case "$TARGET" in
*@raymondchins/agentmap*) RUNNER="agentmap" ;;
esac
fi
# 4) npx — MUST use the scoped package name to avoid the unrelated agentmap@0.11.0.
if [ -z "$RUNNER" ] && command -v npx >/dev/null 2>&1; then
RUNNER="npx --no-install @raymondchins/agentmap"
fi
[ -n "$RUNNER" ] || exit 0
# Need Node for the .mjs / node_modules paths; nvm users may not have it in
# hook PATH — no-op cleanly.
case "$RUNNER" in
node*|./node_modules/*) command -v node >/dev/null 2>&1 || exit 0 ;;
esac
# Rebuild detached + silenced so the commit returns instantly.
#
# Single-instance lock + hard timeout. The rebuild is backgrounded and outlives
# the commit shell, so a run that hangs is reparented to init and keeps burning
# CPU with nothing left to reap it — observed in the wild as 21 minutes of CPU
# in 21 minutes of wall time (a full core) before it was killed by hand, with a
# fresh orphan stacking on every subsequent commit.
#
# mkdir is atomic, so it doubles as the lock: if a previous rebuild is still
# running, this commit skips instead of piling on. Override the cap with
# AGENTMAP_HOOK_TIMEOUT (seconds); a normal rebuild finishes in 1-3s.
LOCK="$GITDIR/agentmap.lock"
# Clear a lock orphaned by a killed run (older than 10 minutes) so a single bad
# rebuild cannot disable auto-refresh permanently.
if [ -d "$LOCK" ] && [ -z "$(find "$LOCK" -maxdepth 0 -mmin -10 2>/dev/null)" ]; then
rmdir "$LOCK" 2>/dev/null
fi
mkdir "$LOCK" 2>/dev/null || exit 0
# The timeout must reach DESCENDANTS, not just $_am_pid. Runner #4 is
# `npx --no-install @raymondchins/agentmap`, which execs a child `node
# .../node_modules/.bin/agentmap` — so $_am_pid is the npx wrapper and the real
# work is a grandchild. Signalling only the wrapper leaves that grandchild
# reparented to init, still spinning a full core: observed in the wild as
# `npm exec @raymondchins/agentmap` (pid 5361) whose child `node .../bin/agentmap`
# (pid 7386) survived the wrapper's death and had to be killed by hand.
#
# Process GROUPS cannot carry this. Job control is not usable in a git hook:
# dash (Ubuntu's /bin/sh) leaves a background job in the invoking shell's group
# even under `set -m` — measured pid 33422 with pgid 33387 — so `kill -- -$_am_pid`
# there either fails or, worse, signals this hook and git along with it. bash
# gives pgid == pid and would work, but a fix that only holds on macOS is not a
# fix. pgrep needs no controlling terminal and behaves the same on both.
#
# Collect the tree deepest-first so children are signalled before their parents,
# and snapshot it BEFORE signalling: once the wrapper dies its children are
# reparented to init and `pgrep -P` can no longer find them. Where pgrep is
# absent (Git for Windows) the walk yields just $_am_pid and this degrades to the
# old single-pid behaviour rather than erroring.
_am_descendants() {
_am_gen="$1" _am_all="$1" _am_depth=0
while [ -n "$_am_gen" ] && [ "$_am_depth" -lt 8 ]; do
_am_gen=$(for _am_p in $_am_gen; do pgrep -P "$_am_p" 2>/dev/null; done | tr '\n' ' ')
[ -n "$_am_gen" ] && _am_all="$_am_gen $_am_all"
_am_depth=$((_am_depth + 1))
done
printf '%s' "$_am_all"
}
# After the map, precompute the call-edge index so `--callers` answers from cache
# (~77ms) instead of a live type-checker walk (~1500ms). It is a SEPARATE step
# because it costs ~4x a map rebuild (~9s on 250 files) — acceptable here, where
# it is detached, lock-guarded and timeout-capped and no one is waiting, but not
# acceptable inline on an interactive query. Set AGENTMAP_HOOK_EDGES=0 to skip it
# (on a very large repo, or to keep post-commit CPU to a minimum); --callers then
# simply falls back to the live walk, which is what it has always done.
_am_edges() {
[ "${AGENTMAP_HOOK_EDGES:-1}" = "1" ] || return 0
$RUNNER --build-edges
}
(
cd "$ROOT" || { rmdir "$LOCK" 2>/dev/null; exit 0; }
{ $RUNNER && _am_edges; } >/dev/null 2>&1 &
_am_pid=$!
# SIGTERM first so a healthy run can flush and exit; SIGKILL only if it ignores
# it. A pid could in principle be recycled inside that 5s window; the exposure
# is the same as the previous single-pid form and the alternative (re-walking a
# tree whose root is already dead) finds nothing at all.
(
sleep "${AGENTMAP_HOOK_TIMEOUT:-120}"
_am_tree=$(_am_descendants "$_am_pid")
for _am_k in $_am_tree; do kill -TERM "$_am_k" 2>/dev/null; done
sleep 5
for _am_k in $_am_tree; do kill -KILL "$_am_k" 2>/dev/null; done
) >/dev/null 2>&1 &
_am_wd=$!
wait "$_am_pid" 2>/dev/null
kill "$_am_wd" 2>/dev/null
rmdir "$LOCK" 2>/dev/null
) &
exit 0