objgitd can run a script after a successful push. The script lives inside the
repository at .objgit/hooks/receive-pack and runs in a restricted, in-process
shell — not a real operating-system shell. This document describes how to enable
hooks, what a hook can and cannot do, and how to write one.
Hooks are off by default. Start the server with -allow-hooks:
./objgitd -bucket $BUCKET -allow-push -allow-hooks
| Flag | Env | Default | Meaning |
|---|---|---|---|
-allow-hooks |
ALLOW_HOOKS |
false |
Run .objgit/hooks/receive-pack after a successful push |
-hook-timeout |
HOOK_TIMEOUT |
60s |
Wall-clock limit for a single hook run |
-wasm-path |
WASM_PATH |
/app/wasm/bin,/usr/libexec/objgit/bin |
Comma-separated directories of WASI programs for hooks |
-wasm-cache-dir |
WASM_CACHE_DIR |
empty | Directory that keeps compiled WASI programs across restarts |
-allow-hooks is independent of -allow-push, but a hook can only fire on a
push, so in practice you want both.
Each .wasm file in a -wasm-path directory is a hook command. The command
name is the file name without .wasm, so kustomize.wasm is kustomize.
The image puts the programs from bin/ in /usr/libexec/objgit/bin.
-wasm-path works like PATH:
- If two directories have a program with the same name, the first directory wins.
- The daemon skips a directory that does not exist.
- A program replaces a kefka command with the same name, such as
jq. - A program cannot replace
kube:applyortekton:pipelinerun.
The daemon lists the directories one time, at startup. It reads and compiles a
program on its first run. The compile of kustomize takes about 4 seconds, and
this time counts against -hook-timeout. With -wasm-cache-dir, the daemon
keeps the compiled programs on disk, and a run after a restart starts in
less than a second.
To add a program to a deployment:
- Compile the program for WASI preview 1, for example with
GOOS=wasip1 GOARCH=wasm go build. - Put the
.wasmfile in/app/wasm/bin, for example from a volume mount. - Restart
objgitd.
A program sees the hook filesystem at /. It has no network. It gets these
environment variables: PWD, HOME, and TMPDIR.
- On push only. The hook is named after the git service that triggered it,
and the only service that runs a hook is
receive-pack(push). Fetches, clones, and archives never run hooks. - After refs are updated. The hook runs after Git's status report is sent,
but before the push response closes. Its stdout and stderr stream to the
pushing client as
remote:lines when sideband is available. A hook cannot reject a push. This is a post-receive hook, not a pre-receive gate. - Once per changed branch. If a push updates or creates several branches, the hook runs once for each, with environment variables describing that branch (see below). Branch deletions are skipped (there is nothing to check out).
- Synchronously. The client waits for the hook to finish or reach
-hook-timeoutbefore the push response closes.
The script is read from the commit that was just pushed, so a hook travels with the branch — different branches can carry different hooks, and updating a hook is just another commit.
Hooks run in kefka, a
virtual bash interpreter. This is not a container, VM, or OS sandbox. It is
safe because of what it cannot reach, not because of kernel isolation:
- No system binaries. Kefka provides Go commands, WASM-backed uutils, and
the WASM programs
jo,jq,python3(alsopython),qjs, andrg. The WASI programs in-wasm-pathadd to these. Available coreutils includecat,ls,printf,head,tail,cut,sort,uniq,wc,tr,sha256sum,base64,base32,mkdir,cp,mv,rm,touch,date,sleep,seq, andexpr. There is nogit, package manager, compiler, orcurl. - Kustomize and Kubernetes.
kustomizerenders a bundle from/src. With-allow-kubernetes,kube:applyandtekton:pipelinerunsend objects to the cluster thatobjgitdruns in. See kubernetes-hooks.md. - No network. The only exception is the Kubernetes API, for the two
Kubernetes commands, when
-allow-kubernetesis on. - No host filesystem. The only files a hook can see are the two mounts below.
Standard bash syntax works: variables, if/for/while, pipes,
redirections, command substitution, [ ... ] tests, and &&/||.
| Path | Contents | Writable? |
|---|---|---|
/src |
The pushed commit, checked out. The shell starts here ($PWD). |
No — read-only |
/tmp |
Empty scratch space. Also $HOME and $TMPDIR. |
Yes |
/src is a live, lazy view of the git tree: files are fetched from object
storage as they are opened, so nothing is copied to disk up front. Everything
under /src is read-only. Writing scratch data — including shell
redirections like echo x > out — must target /tmp.
Important: a redirection into a read-only path aborts the script. For example
echo hi > /src/note.txtdoes not merely fail that one line; it stops the hook with a "read-only filesystem" error. Always redirect into/tmp.
Each run gets variables describing the branch that triggered it:
| Variable | Example | Notes |
|---|---|---|
OBJGIT_REPO |
/myproject.git |
Repository path |
OBJGIT_SERVICE |
receive-pack |
Always receive-pack |
OBJGIT_REF |
refs/heads/main |
Full ref name |
OBJGIT_BRANCH |
main |
Short branch name |
OBJGIT_OLD_SHA |
0000…0000 |
Previous tip; all zeros when the branch was created |
OBJGIT_NEW_SHA |
f43417… |
New tip |
OBJGIT_ADDED_FILES_JSON |
["new.txt"] |
Added paths, as a JSON array |
OBJGIT_CHANGED_FILES_JSON |
["README.md"] |
Changed paths, as a JSON array |
OBJGIT_DELETED_FILES_JSON |
["old.txt"] |
Deleted paths, as a JSON array |
OBJGIT_CHANGES_FILE |
/tmp/objgit-changes.json |
File containing all three arrays in a single JSON object |
The file lists describe the net change of the branch tip across the push.
If one commit adds a path and a later commit deletes it, that path does not
appear in these lists. A new branch compares its tip against an empty tree.
Renames appear as one added path and one deleted path. Mode-only changes
appear in changed. Whitespace and newlines in paths are JSON escaped;
parse the JSON rather than splitting on spaces or lines. A path with invalid
UTF-8 bytes is represented as /objgit/raw-path/base64url/ followed by the
unpadded base64url encoding of its raw bytes.
OBJGIT_CHANGES_FILE lives in the hook's writable /tmp filesystem and
contains the same complete lists. For example:
{ "added": ["new.txt"], "changed": ["README.md"], "deleted": ["old.txt"] }For compatibility with scripts written for stock git, the same information is
also fed on stdin as a single <old> <new> <ref> line.
Put the script at .objgit/hooks/receive-pack in your repository and commit it.
The executable bit is not required — objgitd reads the file's contents, not its
mode.
#!/usr/bin/env bash
# .objgit/hooks/receive-pack
echo "push to ${OBJGIT_REPO} ${OBJGIT_REF}: ${OBJGIT_OLD_SHA} -> ${OBJGIT_NEW_SHA}"
# /src is the checkout of the new commit; the shell starts there.
echo "top-level contents:"
ls /src
# Read a file out of the push.
if [ -f /src/go.mod ]; then
module="$(head -n 1 /src/go.mod | cut -d' ' -f2)"
echo "go module: ${module}"
fi
# Scratch work goes in /tmp.
manifest=/tmp/manifest.txt
echo "ref ${OBJGIT_REF}" > "${manifest}"
echo "sha ${OBJGIT_NEW_SHA}" >> "${manifest}"
cat "${manifest}"
echo "hook done"A copy of this example lives at
.objgit/hooks/receive-pack in this
repository.
You can open the hook sandbox as an interactive shell over SSH. Use it to try commands before you put them in a hook.
-
Start
objgitdwith-ssh-bindand-allow-hooks. -
Push the branch that you want to examine.
-
Connect with a terminal:
ssh -t -p 2222 git@host sh myproject.git -
To examine a branch other than the one that
HEADpoints to, give its name:ssh -t -p 2222 git@host sh myproject.git feature
The shell is the same as the one that a hook gets:
/srcis the tip commit of the branch, read-only, and the shell starts there./tmpis scratch space. At the start of each session, it holds onlyobjgit-changes.json.- The
OBJGIT_*variables describe the tip commit as if you pushed it.OBJGIT_OLD_SHAis its first parent, or all zeros for a root commit. - The file lists compare the tip commit with that first parent. For a root
commit, every file is in
OBJGIT_ADDED_FILES_JSON. - Each command gets the hook stdin line, so
read old new refworks.
The shell is different from a hook in these ways:
- A write into
/srcshows an error, but the session continues. -hook-timeoutapplies to each command, and not to the session.- Ctrl-C clears the line at the prompt. It does not stop a command that runs. The timeout stops that command.
To leave the shell, type exit or press Ctrl-D.
To open the shell, you need write access to the repository. If the server refuses the session, it shows one of these errors:
| Error | Cause |
|---|---|
sh is disabled; start objgitd with -allow-hooks to enable it |
The server runs without -allow-hooks. |
sh needs a terminal; use ssh -t |
The client did not request a PTY. |
access denied |
You do not have write access. |
repository "…" not found |
The repository does not exist. |
branch "…" not found |
The branch does not exist. |
HEAD is detached; name a branch: … |
HEAD is not a branch. Give a branch name. |
All hook activity is logged through the server's structured (slog) logger:
hook: running— a hook started, withrepo,service,ref, andsha.hook: finished— success, with the hook'sexitcode, capturedstdout, andstderr.hook: finished with errors— the hook exited non-zero, failed to parse, hit the timeout, or tried to write somewhere read-only. The error is attached under theerrkey.hook: no hook file in pushed tree(debug level) — the push had no.objgit/hooks/receive-pack, so nothing ran.
When sideband is unavailable, hook output is captured in the server log. Hook failure is logged, but it cannot undo the accepted push.
- No writable working tree:
/srcis strictly read-only and/tmpis the only scratch space. - No way to reject a push from a hook (it runs after the fact).
- No system tooling, network, or arbitrary executables — only kefka's registered commands. The Kubernetes commands reach only the in-cluster API server.
- Hook output reaches the pusher only when the client negotiated sideband.