markdown · 5270 bytes Raw Blame History

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.

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 a push_events row with protocol http or ssh, then enqueue a push:process job carrying the new event id. Web-editor commits use the same table with protocol web.
  • Issues a single NOTIFY shithub_jobs per 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): runs hooks.Install after RepoFS.InitBare succeeds. 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 --all walks 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 through filepath.Abs. Test fixtures that don't exercise hooks pass ShithubdPath: "" to repos.Create.Deps to 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: Install is 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.