// Package hooks is spec.sr.ht's receive path: the git hooks a space's bare // repository runs on every push, and the daemon-side RPC endpoint they call. // // # Why the hooks are service code // // bleve is single-writer and the running specsrht daemon holds the index open, // so a separate hook process cannot reindex; and validation needs the same // schema and policy logic the API uses, which must not be duplicated in a // shell script that will drift from it. Both hooks are therefore thin shims // that RPC into the daemon over a unix socket, calling [service.Service] // entry points. Nothing in this package shells out to git. // // Daemon availability is consequently part of the push path, so the behaviour // is stated rather than discovered: **fail closed**. If the daemon is // unreachable, unresponsive, or answers anything this package cannot parse, // the push is rejected. A rejected push is recoverable in one command; a // silently unvalidated, unindexed one is a corruption discovered weeks later. // // # Which hook does what // // Three hooks are installed, not two, and the reason is a property of git that // the design did not account for. Both facts below were verified against // git 2.55 rather than inferred from the documentation: // // 1. GIT_PUSH_OPTION_COUNT / GIT_PUSH_OPTION_ are set for `pre-receive` // and `post-receive` only. githooks(5) documents them under exactly those // two hooks, and `update` observably runs without them. So `update` alone // cannot see --push-option=skip-validation. // 2. During `pre-receive` the pushed objects are still in receive-pack's // quarantine directory (GIT_QUARANTINE_PATH), reachable only through the // hook process's own GIT_OBJECT_DIRECTORY. A separate process that opens // the bare repository — the daemon — cannot read them. git migrates the // quarantine into the real object store immediately after `pre-receive` // succeeds and before the first `update` runs, so `update` is the earliest // hook at which the daemon can read what is being pushed. // // Neither hook can do the whole job, so the work is split along that seam: // // - pre-receive — the only place push options exist. It forwards them, with // the full list of proposed ref updates, to the daemon, which records them // for the duration of this push. It validates nothing (it cannot: the // objects are invisible to the daemon) but it is still a rejecting hook, // because a daemon that cannot be reached must stop the push here rather // than one hook later. // - update — per ref, before the ref moves, and the only hook whose refusal // is scoped to a single ref. This is where the refs rule and frontmatter / // document-id validation run, via [service.Service.ValidatePush]. // - post-receive — after the fact, cannot reject. It tells the daemon the // push landed so the space can be reindexed and its index rev stamp moved. // // The two phases of one push are correlated by (repository, receive-pack pid): // every hook of a single push is a direct child of one receive-pack process, // so os.Getppid() is stable across them, and the daemon additionally requires // the ref update `update` presents to appear in the list `pre-receive` sent. // An `update` call with no recorded pre-receive phase is refused rather than // assumed unskippable — that combination means either a broken install or a // daemon restart mid-push, and both deserve a message instead of a guess. // // # Push options // // The one recognised option is `skip-validation`. It waives frontmatter and // document-id validation and nothing else; the refs rule is never skippable. // An unrecognised push option is a rejection, not a no-op: with exactly one // option in the vocabulary, a silently ignored `--push-option=skip-validaton` // would present as an inexplicable rejection of a push the human believed they // had waived. // // Push options only reach a hook when the repository advertises them, so // [Install] sets receive.advertisePushOptions on every repository it installs // into. Without it `git push --push-option=...` fails client-side with // "the receiving end does not support push options". // // # Identity // // The hook does not decide who is pushing; it forwards a credential and the // daemon resolves it: // // SPECSRHT_PRINCIPAL "owner" or "agent" (required) // SPECSRHT_AGENT_TOKEN agent credential (required when kind=agent) // SPECSRHT_AGENT agent identity string (provenance, optional) // SPECSRHT_AGENT_SESSION agent session id (provenance, optional) // // The agent credential is a tokens.sr.ht working token — the same one the REST // and MCP planes take — validated on every push through the same authn.Resolver // those surfaces authenticate with, so an agent credential asserts nothing by // itself. It must carry the spec:propose grant: a push by an agent is a proposal // by another transport. `owner` is different: it is an // assertion, trusted because sshd already authenticated the SSH key and the // forced-command wrapper — which owns this environment — is what sets it. // That wrapper is therefore part of the trust boundary: sshd must not // AcceptEnv any SPECSRHT_* name, or a client could name its own principal. // A missing or unrecognised SPECSRHT_PRINCIPAL is refused; there is no // anonymous write path and nothing to default to. // // # Addressing // // A hook knows which repository it is running in (git chdirs into it and sets // GIT_DIR) and nothing else, so both the socket and the space are derived from // that one fact: // // - the socket is /.specsrht/hook.sock, where is the parent of // the parent of the repository directory — the layout gitx.DiskPath // defines. It lives under `repos` rather than `cache` because `cache` is // documented as safe to delete at any time, and deleting the socket would // take the push path down until the daemon restarted. SPECSRHT_HOOK_SOCKET // overrides it for a non-standard deployment or a test. // - the space is not sent as a name at all. The hook sends the absolute // repository path and the daemon resolves it against its own configured // repos root, refusing anything that is not exactly // /~/. A hook can therefore only ever address a // repository the daemon already owns. // // # Installation // // [Install] writes each hook as a symlink to the specsrht binary, which // dispatches on the name it was invoked as ([ModeFromArgs]). There is no shell // stub, no generated script, and nothing to regenerate when the socket path or // the protocol changes; refreshing a repository's hooks after an upgrade is // running [Install] again, which the daemon does for every space at startup. package hooks