Git hooks
Bare-repo git hooks are how the push pipeline (S14) gets called.
shithub installs pre-receive (gates) and post-receive (enqueue)
on every repo. Both are tiny shell shims that exec into the same
shithubd binary the web/SSH/worker layers run.
Why shell shims and not direct binary symlinks
git invokes hooks with stdin piped, a particular cwd, and a controlled
env. The shim normalizes the entry point: exec /path/to/shithubd hook <name>. Hooks aren't symlinked because (a) macOS git treats some
symlinks oddly under hardened runtime, and (b) the shim explicitly
re-exec's so signals and exit codes propagate cleanly.
The shim is regenerated on every hooks.Install call — there's no
manual editing path, by design. If the binary moves (deploy upgrade,
versioned install path), run shithubd hooks reinstall --all to point
every repo at the new location.
Hook execution flow
git push ──▶ receive-pack
│
▼
pre-receive (one process per push)
│ stdin: "<old> <new> <ref>" lines
│ env: SHITHUB_USER_ID / _USERNAME / _REPO_ID / _REPO_FULL_NAME
│ SHITHUB_PROTOCOL / _REMOTE_IP / _REQUEST_ID
▼
exec shithubd hook pre-receive
│ exit 0 → accept
│ exit ≠ 0 → reject; stderr is shown to the pusher
▼
(push proceeds)
│
▼
post-receive (one process per push)
│ stdin: same shape
▼
exec shithubd hook post-receive
│ INSERT push_events row per ref
│ INSERT job (push:process)
│ NOTIFY shithub_jobs
│ exit 0
▼
worker picks it up via LISTEN
In-browser file-editor commits do not execute git hooks: they are built
with plumbing inside the already-bare repository and advance the branch
with git update-ref. To keep downstream behavior identical to pushed
commits, internal/repos/webedit runs the same branch-protection
enforcer before the ref update, inserts push_events.protocol = 'web'
after a successful CAS, enqueues push:process, and sends
NOTIFY shithub_jobs.
pre-receive contract
Implements the minimum gates described in S14. The full branch protection engine is S20.
Re-checks the following from the DB even though the env carries them (env can be stale on a long-lived SSH session):
- User not suspended (
users.suspended_at IS NULL). - Repo not archived (
repos.is_archived = false). - Repo not soft-deleted (
repos.deleted_at IS NULL).
Failures emit a shithub: ... line on stderr that git surfaces directly
to the pusher's terminal. Latency budget: <100ms p99.
post-receive contract
- Reads stdin, ignores empty/malformed lines.
- For each
<old> <new> <ref>line: INSERT apush_eventsrow with protocolhttporssh, then enqueue apush:processjob carrying the new event id. Web-editor commits use the same table with protocolweb. - Issues a single
NOTIFY shithub_jobsper push (workers wake on the next tx commit). - Exits 0 even on internal errors. The push has already landed; the pipeline is async — partial enqueue failures surface in the worker logs and a backstop reconciler (post-MVP) can re-process orphaned events.
Installation
- On repo create (
internal/repos/create.go::Create): runshooks.InstallafterRepoFS.InitBaresucceeds. The S11 plumbing initial commit does not fire hooks — that's correct, the contract is hooks fire on user-driven pushes only. - On deploy:
shithubd hooks reinstall --allwalks every active repo via the DB and re-installs. Single repos:--repo owner/name. - The binary path baked into the shim is
os.Executable()of the running shithubd at install time, resolved throughfilepath.Abs. Test fixtures that don't exercise hooks passShithubdPath: ""torepos.Create.Depsto skip installation.
Failure modes worth knowing
SHITHUB_*env not set (e.g. someone manually triggered a push via a path that bypassed S12/S13): pre-receive returns the "missing context" error. post-receive logs a warning and exits 0 — the push still lands.- DB unreachable from hook: pre-receive returns "server error"; the user sees a generic message, the push aborts. post-receive logs and exits 0; backstop reconciler will pick up the unprocessed push on next opportunity.
- Binary path drift between install and execution: the shim's hard-
coded path no longer exists. git will report "hook execution failed"
and abort the push. Operators recover with
hooks reinstall. - Stale shim from previous shithubd version:
Installis idempotent and overwrites; re-running on a deploy is the right move.
Deferred: hook DB role split
S14's spec calls for the hook to connect with a least-privilege Postgres
role distinct from the one the web server uses. S14 ships the dev path
(single role) and defers the split to S37 deploy automation. The full
GRANT recipe and the planned config plumbing live in
db-roles.md, and the bullet is on the S37
deliverables list so it doesn't fall off.
View source
| 1 | # Git hooks |
| 2 | |
| 3 | Bare-repo git hooks are how the push pipeline (S14) gets called. |
| 4 | shithub installs **pre-receive** (gates) and **post-receive** (enqueue) |
| 5 | on every repo. Both are tiny shell shims that exec into the same |
| 6 | `shithubd` binary the web/SSH/worker layers run. |
| 7 | |
| 8 | ## Why shell shims and not direct binary symlinks |
| 9 | |
| 10 | git invokes hooks with stdin piped, a particular cwd, and a controlled |
| 11 | env. The shim normalizes the entry point: `exec /path/to/shithubd hook |
| 12 | <name>`. Hooks aren't symlinked because (a) macOS git treats some |
| 13 | symlinks oddly under hardened runtime, and (b) the shim explicitly |
| 14 | re-exec's so signals and exit codes propagate cleanly. |
| 15 | |
| 16 | The shim is regenerated on every `hooks.Install` call — there's no |
| 17 | manual editing path, by design. If the binary moves (deploy upgrade, |
| 18 | versioned install path), run `shithubd hooks reinstall --all` to point |
| 19 | every repo at the new location. |
| 20 | |
| 21 | ## Hook execution flow |
| 22 | |
| 23 | ``` |
| 24 | git push ──▶ receive-pack |
| 25 | │ |
| 26 | ▼ |
| 27 | pre-receive (one process per push) |
| 28 | │ stdin: "<old> <new> <ref>" lines |
| 29 | │ env: SHITHUB_USER_ID / _USERNAME / _REPO_ID / _REPO_FULL_NAME |
| 30 | │ SHITHUB_PROTOCOL / _REMOTE_IP / _REQUEST_ID |
| 31 | ▼ |
| 32 | exec shithubd hook pre-receive |
| 33 | │ exit 0 → accept |
| 34 | │ exit ≠ 0 → reject; stderr is shown to the pusher |
| 35 | ▼ |
| 36 | (push proceeds) |
| 37 | │ |
| 38 | ▼ |
| 39 | post-receive (one process per push) |
| 40 | │ stdin: same shape |
| 41 | ▼ |
| 42 | exec shithubd hook post-receive |
| 43 | │ INSERT push_events row per ref |
| 44 | │ INSERT job (push:process) |
| 45 | │ NOTIFY shithub_jobs |
| 46 | │ exit 0 |
| 47 | ▼ |
| 48 | worker picks it up via LISTEN |
| 49 | ``` |
| 50 | |
| 51 | In-browser file-editor commits do not execute git hooks: they are built |
| 52 | with plumbing inside the already-bare repository and advance the branch |
| 53 | with `git update-ref`. To keep downstream behavior identical to pushed |
| 54 | commits, `internal/repos/webedit` runs the same branch-protection |
| 55 | enforcer before the ref update, inserts `push_events.protocol = 'web'` |
| 56 | after a successful CAS, enqueues `push:process`, and sends |
| 57 | `NOTIFY shithub_jobs`. |
| 58 | |
| 59 | ## pre-receive contract |
| 60 | |
| 61 | Implements the **minimum gates** described in S14. The full branch |
| 62 | protection engine is S20. |
| 63 | |
| 64 | Re-checks the following from the DB even though the env carries them |
| 65 | (env can be stale on a long-lived SSH session): |
| 66 | |
| 67 | * User not suspended (`users.suspended_at IS NULL`). |
| 68 | * Repo not archived (`repos.is_archived = false`). |
| 69 | * Repo not soft-deleted (`repos.deleted_at IS NULL`). |
| 70 | |
| 71 | Failures emit a `shithub: ...` line on stderr that git surfaces directly |
| 72 | to the pusher's terminal. Latency budget: <100ms p99. |
| 73 | |
| 74 | ## post-receive contract |
| 75 | |
| 76 | * Reads stdin, ignores empty/malformed lines. |
| 77 | * For each `<old> <new> <ref>` line: INSERT a `push_events` row with |
| 78 | protocol `http` or `ssh`, then enqueue a `push:process` job carrying |
| 79 | the new event id. Web-editor commits use the same table with protocol |
| 80 | `web`. |
| 81 | * Issues a single `NOTIFY shithub_jobs` per push (workers wake on the |
| 82 | next tx commit). |
| 83 | * Exits 0 even on internal errors. The push has already landed; the |
| 84 | pipeline is async — partial enqueue failures surface in the worker |
| 85 | logs and a backstop reconciler (post-MVP) can re-process orphaned |
| 86 | events. |
| 87 | |
| 88 | ## Installation |
| 89 | |
| 90 | * On repo create (`internal/repos/create.go::Create`): runs |
| 91 | `hooks.Install` after `RepoFS.InitBare` succeeds. The S11 plumbing |
| 92 | initial commit does *not* fire hooks — that's correct, the contract |
| 93 | is hooks fire on user-driven pushes only. |
| 94 | * On deploy: `shithubd hooks reinstall --all` walks every active repo |
| 95 | via the DB and re-installs. Single repos: `--repo owner/name`. |
| 96 | * The binary path baked into the shim is `os.Executable()` of the |
| 97 | running shithubd at install time, resolved through `filepath.Abs`. |
| 98 | Test fixtures that don't exercise hooks pass `ShithubdPath: ""` to |
| 99 | `repos.Create.Deps` to skip installation. |
| 100 | |
| 101 | ## Failure modes worth knowing |
| 102 | |
| 103 | * **`SHITHUB_*` env not set** (e.g. someone manually triggered a push |
| 104 | via a path that bypassed S12/S13): pre-receive returns the "missing |
| 105 | context" error. post-receive logs a warning and exits 0 — the push |
| 106 | still lands. |
| 107 | * **DB unreachable from hook**: pre-receive returns "server error"; |
| 108 | the user sees a generic message, the push aborts. post-receive logs |
| 109 | and exits 0; backstop reconciler will pick up the unprocessed push |
| 110 | on next opportunity. |
| 111 | * **Binary path drift between install and execution**: the shim's hard- |
| 112 | coded path no longer exists. git will report "hook execution failed" |
| 113 | and abort the push. Operators recover with `hooks reinstall`. |
| 114 | * **Stale shim from previous shithubd version**: `Install` is |
| 115 | idempotent and overwrites; re-running on a deploy is the right move. |
| 116 | |
| 117 | ## Deferred: hook DB role split |
| 118 | |
| 119 | S14's spec calls for the hook to connect with a least-privilege Postgres |
| 120 | role distinct from the one the web server uses. S14 ships the dev path |
| 121 | (single role) and defers the split to S37 deploy automation. The full |
| 122 | GRANT recipe and the planned config plumbing live in |
| 123 | [`db-roles.md`](./db-roles.md), and the bullet is on the S37 |
| 124 | deliverables list so it doesn't fall off. |