arche / wiki / Hooks

Hooks

Arche has hooks at two distinct levels. Client-side hooks run on your local machine as part of the snapshot lifecycle. Server-side hooks run on the forge as part of the push boundary. The two systems are independent and have different semantics, but the goal is the same: let you attach automation to the moments where change crosses a meaningful threshold.

Webhooks are a third category, described separately at the end. They are HTTP callbacks rather than shell scripts, triggered by pushes, and managed through the web interface.

Pre-snap and Post-snap Hooks

Client-side hooks are configured in .arche/config.toml under the [hooks] section:

[hooks]
pre-snap  = [".archehooks/pre-snap/lint.sh", "run_tests.sh"]
post-snap = [".archehooks/post-snap/notify.sh"]

Each entry is a shell command string. Paths are relative to the repository root. Arche invokes each hook using $SHELL -c <cmd> (falling back to /bin/sh if $SHELL is not set), with the working directory set to the repository root. Hooks run sequentially in the order listed.

Pre-snap hooks run before the snapshot is taken. If any hook exits with a non-zero code, the entire snap is aborted and nothing is written to the store. The stdout and stderr of each hook stream to the terminal as the hook runs, so you see output in real time.

Post-snap hooks run after a successful snapshot. They are non-blocking: the snapshot has already been committed by the time the hook runs, so a failure in a post-snap hook prints an error to stderr but does not roll anything back. Post-snap hooks are appropriate for notifications, index updates, or other side effects that should not block the snapshot from completing.

Both sets of hooks are stored in the local config and are never synced or pushed. They are a per-working-copy concern.

Restricted Re-snapshot After Pre-snap Hooks

There is a subtlety to how Arche handles files modified by pre-snap hooks. After the hooks finish but before the snapshot is taken, Arche re-hashes the working copy — but only the files that were already in the diff when the pre-snap hooks started. If a hook modifies a file that was not part of the original diff, that modification is left in the working copy as an uncommitted change, and a warning is printed.

This is a deliberate constraint. The alternative, re-scanning the entire working copy after each hook run, would make snapshot timing unpredictable in large repositories and would silently expand the scope of a snapshot beyond what the user saw when they initiated it. The restricted re-snapshot model means that hooks can safely auto-format the files you are already changing, but they cannot smuggle in unrelated changes.

If you want a hook to update files that were not already modified, take two snapshots: one to capture the hook's output as an amendment, and one for the original change. Or restructure the hook to operate only on the files it receives.

Versioned Hooks and the Install Model

The .archehooks/ directory is tracked in the repository tree. It conventionally contains scripts under .archehooks/pre-snap/ and .archehooks/post-snap/, versioned alongside the code they check or extend. This lets teams distribute hooks with the repository.

Arche never executes scripts from .archehooks/ automatically. Installing a versioned hook is an explicit act. This is intentional: auto-executing arbitrary scripts on checkout or clone would be a security liability. The install step is your acknowledgement that you have read the script and trust it.

To see what hooks are available in the repository and which are currently installed:

arche hooks list

This shows every hook file under .archehooks/, along with a notation indicating whether it is already installed in your local config.

To install a hook, run:

arche hooks install [name]

This prints the full content of the script and prompts for confirmation. On confirmation, it adds the hook to the appropriate section of .arche/config.toml. No installation happens silently.

To see whether the working-copy version of a hook differs from the committed version — useful when you want to know what changed before reinstalling:

arche hooks diff

This shows a unified diff of each installed hook against its last-committed version in .archehooks/. If a hook was updated in a commit you just pulled, this tells you exactly what changed before you decide whether to reinstall.

Server Hooks

A forge administrator can configure shell hooks that run on the server during push operations. These are set in the server's config.toml:

[hooks]
pre-receive  = "/srv/hooks/pre-receive.sh"
update       = "/srv/hooks/update.sh"
post-receive = "/srv/hooks/post-receive.sh"
timeout_sec  = 30

All three hooks follow the same calling convention. They receive three positional arguments: the ref name (bookmark name), the old commit hex, and the new commit hex. They also receive a single line on stdin in the format oldHex newHex refName, which is the same format used by Git post-receive hooks. This makes it straightforward to reuse Git hook scripts on an Arche server.

The pre-receive hook runs once per push, before any changes are written. If it exits non-zero, the entire push is rejected and the client receives an error. This is the right place for policy enforcement that applies to every bookmark being updated, such as checking for a valid GPG signature or verifying that the push does not remove public history.

The update hook runs once per bookmark being updated, after pre-receive but still before anything is committed. If it exits non-zero, that specific bookmark update is rejected. Other bookmarks in the same push can still succeed. Use update hooks for per-bookmark policy: enforcing naming conventions, preventing direct pushes to protected bookmarks, or checking CI status.

The post-receive hook runs after the push is fully committed, in a background goroutine. It cannot reject the push. If it times out or exits non-zero, the error is logged server-side, but the client has already received a successful response. Post-receive is intended for notifications, triggering CI, or updating indexes.

The timeout_sec value applies to all three server hooks. Any hook process that does not exit within the timeout is killed. For pre-receive and update, this means the push is rejected with a timeout error.

Per-repo Shell Hooks and the Security Revocation

Global server hooks apply to every repository on the forge. For cases where a specific repository needs its own post-receive script, there is a per-repo configuration option:

[repo.myrepo]
allow_shell_hooks = true
post-receive = "/srv/hooks/myrepo-post-receive.sh"

allow_shell_hooks is a security gate. It must be explicitly enabled for a repository before any per-repo shell hook will run. When enabled, the named script runs after every successful push to that repository, with the same calling convention as the global post-receive hook.

There is a hard rule: allow_shell_hooks is automatically revoked whenever a collaborator with push or admin access is added to the repository. This happens immediately, and it clears the per-repo post-receive script path as well. The reason is that running a shell script controlled by the server on behalf of an untrusted push is a trust boundary. Once other users can push to the repository, the shell hook execution model assumes a level of trust that may no longer hold. If you re-add collaborators and still need per-repo hooks, an administrator must explicitly re-enable allow_shell_hooks after reviewing the trust picture.

The global server hooks are not subject to this restriction, since they are under administrator control and apply uniformly.

Webhooks

Webhooks are per-repository HTTP callbacks configured through the web interface at /<repo>/settings/webhooks. When a push lands on the repository, Arche sends an HTTP POST to each configured URL with a JSON payload describing the push:

{
  "repo":       "myrepo",
  "pusher":     "alice",
  "push_id":    "01926b3f-...",
  "bookmark":   "main",
  "old_commit": "a3f8c2...",
  "new_commit": "7d1e40...",
  "commits":    [...]
}

The commits array contains up to 50 commits from a DAG walk between old_commit and new_commit. The push_id is a UUID v7, which encodes the push timestamp and is monotonically sortable.

Every delivery is signed with HMAC-SHA256 using the secret you configure when creating the webhook. The signature is sent in the X-Arche-Signature: sha256=<hex> header, and the event type is sent in X-Arche-Event: push. Verify the signature before trusting the payload.

Arche retries failed deliveries with exponential backoff, up to five attempts. The full delivery history is stored per webhook, accessible at /<repo>/settings/webhooks/<id>/deliveries. Any delivery can be replayed from the delivery history page, which is useful for debugging a new endpoint without making a fresh push.