On this page

Gate every push to main

A commit pushed straight to main skips the submit queue, and with it the checks the queue runs: a commit whose locks do not resolve or whose crate declares a module it does not hold turns main red for every session that builds from it, and every change queued behind it is refused until someone repairs it. Every repository has one checkout on a machine, and every session working there pushes from it, so one Git pre-push hook in that checkout gates every push made from it. stado quality hooks puts that hook in every checkout of a product.

stado quality hooks install --product PRODUCT [--json]
stado quality hooks install --all [--json]
stado quality hooks status --product PRODUCT [--json]
stado quality hooks remove --product PRODUCT [--json]

Exactly one of --product and --all is given; neither, or both, is a usage error (exit 2). The checkouts of a product are the canonical checkouts in the workspace (WISENT_WORKSPACE, by default ~/Documents/CodingProjects/Wisent) of every repository its catalog record names: its owner_repository and the repository of each surface and installation. --all takes every catalogued product's, each repository once. A product the catalog does not name is refused with every product it does name: the catalog names no product <id>; it names <ids>.

The gate follows a product onto a machine without anyone running install: once stado product install or update of a product succeeds, and once stado product sync succeeds (every catalogued product's checkouts when it names no product), Stado writes the hook into each of the product's checkouts whose state is absent or outdated, and says each one on stderr as stado product: <repository> (<checkout>) pre-push gate <state>: <what it did>. A checkout whose hook is not Stado's (foreign) or that core.hooksPath governs (governed) is left as it is, for stado quality hooks install to decide; a checkout it could not read, and an installed Stado or catalog it could not find, are said the same way and do not fail the installation.

What the hook runs

The hook is a short shell script whose first two lines are #!/bin/sh and # stado quality hooks: this checkout's pre-push gate.; that header is how Stado tells its own hook from any other. It reads Git's ref updates from stdin once and hands them, with the two arguments Git gives every pre-push hook (the remote's name, or its URL when the push names none, and the URL it pushes to), to the installed Stado:

"$HOME"/.stado/bin/stado quality check --pre-push --if-declared --remote "$1" --remote-url "$2"

The installed Stado is named from $HOME when the hook runs, so the hook carries no machine's home path and keeps working when Stado is updated. install refuses when no Stado is installed there, since a hook naming a missing program refuses every push: no Stado is installed at <path>, the program the pre-push hook runs, so no hook was written; install it with `stado product install --surface cli stado` and run this again. A hook an older Stado wrote, without the two arguments, reads as outdated, and install rewrites it.

stado quality check --pre-push checks each pushed commit's own committed source, never the working tree, so another session's uncommitted edits in the shared checkout neither refuse nor admit a push: its lock resolution, module declarations and reach. No gate formats, lints or compiles at push, and no admission is asked.

Before those, each pushed commit is compared with its parents, and one that deletes a file its message gives no account of is refused. A commit written from an index that held only the files it changed (a temporary index read at the parent, with only the touched files written back into a work tree of their own) records every other file of its parent as deleted, and its message, written for the change, names none of them. A file counts as deleted when the commit lacks it and every parent holds it, so a merge that brings in a deletion one side already made is not refused, and a rename is no deletion. A deletion is accounted for when the message names the file's path, its file name, or a directory above it of two or more components (src/content, not src). The refusal lists each file:

stado quality check: <commit> deletes <n> file(s) its parent <parent> holds, and its message names none of them:
  <path>
  …
A commit written from an index that held only the changed files records every other file as deleted. Write it again from an index read at its parent with only the intended paths changed; a deletion that is meant is named in the message by its path, file name or directory, then pushed again.

Only a push that can reach the repository the checkout's origin names is checked. A push Git names as origin always is, whatever its URL reads: Git hands the hook the URL after its own rewrites (an insteadOf alias, a forge's second SSH host and port), which can spell origin's repository otherwise. Any other remote is compared by URL with each of origin's push URLs (git remote get-url --push --all origin) as the repository it names, its host and path with the scheme, a user, a port, one .git, a trailing / and case set aside, so https://github.com/owner/repo, https://github.com/owner/repo.git and git@github.com:owner/repo.git are one repository, and a push to that repository under another remote name is checked too. A push to another repository, a mirror or a fork, carries nothing to the shared main; it is not checked, passes, and says where it went:

stado quality check: this push goes to <remote> (<url>), the repository <host/path>, and origin, whose main the submit queue lands on, is <host/path>[, …]; nothing it pushes reaches that repository, so it is not checked

When the remote cannot be told, the push is checked as one to origin, and the check says why: no --remote was given (a hook an older Stado wrote, or a dispatcher that does not hand Git's arguments on), the URL names no repository, the checkout has no origin remote, or origin's push URLs name none. --remote and --remote-url are given together or not at all (a usage error, exit 2), and only with --pre-push. The same check runs by hand by giving Git's update lines on stdin:

printf 'refs/heads/main COMMIT refs/heads/main REMOTE_TIP\n' | stado quality check --root /path/to/product --pre-push --if-declared --remote origin --remote-url URL

The submit queue's own push passes without being checked again. Before it pushes, stado change run records the rebased commit as the candidate's landing (the submit queue); the pre-push check finds that exact commit in the queue's record (a running candidate's landing, or a landed candidate's landed commit) and answers <commit> is the submit queue's landing of candidate <id>, whose pass checked it; nothing to check. The marker is the queue's record of the commit itself, never an environment variable a caller could set.

When the check passes, the hook the checkout had before (a Git LFS upload, say), kept as pre-push.chained, runs with the same ref updates and arguments, and its answer decides the push.

Tama's managed pre-push dispatcher runs the same check through its quality-check program and hands Git's two arguments on the same way. A Stado whose quality check --help names no --remote-url is run without them, and the dispatcher says the push is checked as one to origin.

The real journey tests/quality/push/mod.rs (kompilator test --manifest-path stado-rs/Cargo.toml --test quality-push) pushes through a hook with Git's arguments to an isolated bare origin, refuses a commit whose crate declares a missing module, and in mirror.rs pushes that same commit to a second bare remote, which is not checked and receives it, then to origin's repository named by its URL with a trailing /, which is still refused, and hands the check that commit's update named as origin with the mirror's URL, which is still checked and refused.

Pushes of one repository take turns

A push to main other than the queue's own landing is checked in its repository's turn on this machine: the lock ~/.stado/change-queue/<owner>/<name>.push.lock (under STADO_CHANGE_QUEUE when that is set), beside the queue pass's own lock. The check takes it before the first pushed commit is checked and holds it until the check ends, so two pushes of one repository never check at once and then lose to each other at the remote (cannot lock ref 'refs/heads/main': is at X but expected Y). The queue's own landing takes the same turn around its push alone (stado change run: another push of <owner/name> is being checked (…); this push waits for its turn at main), so a check that ran for minutes lands what it checked before a landing moves main under it; the landing's pre-push check finds the recorded landing and takes no turn, so the two never wait on each other, and a main that moved meanwhile has the candidate written again. A push that finds the turn held says so and waits:

stado quality check: another push of <owner/name> is being checked (pusher <pid>); this push waits for its turn at main

The holder writes into the lock the git process that pushes once its check has ended (pusher <pid>, or no git push runs above this check when the check was run by hand). Git sends the commits only after its hook has ended, so the next holder waits for that git process to end, saying the push ahead of this one (git, pid <pid>) is sending, and then reads origin's main again with git ls-remote. A main that moved from the one Git handed the push, while it waited or while its commits were checked, is refused before anything is pushed:

the origin main this push was built on moved while this push waited for its turn: Git handed the push <old> and origin's main is <new> now, so the remote would reject it (`cannot lock ref`) after its check; nothing was pushed. Fetch origin, put the pushed commits on <new> (`git rebase origin/main`) and push again; …

The kernel releases the turn when the check ends, however it ends, so a killed push holds nothing. The check also watches, from its start, the git process that pushes for it (the kernel reports its end: kqueue on macOS, pidfd on Linux). When that push is killed, while the check waits for its turn or while it checks, no push can follow, so the check stops with exit 1 instead of exporting and fetching on with no parent:

the git push of <owner/name> this check runs for (pid <pid>) ended before its check did, so no push follows this check and it stops here; nothing was pushed. Push again to check the commits

The run area it leaves is reclaimed by the next check of that revision. When its end cannot be watched, the check says the end of the git push of <owner/name> (pid <pid>) cannot be watched (<cause>); this check runs to its end even if that push is killed and goes on. tests/quality/push/killed.rs holds the turn, kills a real push waiting at it and reads the check's end.

A push from another machine is not held back by this machine's turn; the read when the check ends refuses a push it overtook with the same sentence (… moved while this push's commits were checked …). git ls-remote failing (no network, no credentials) refuses the push with git's own words, as the push itself would fail.

What each verb does

The checkout's pre-push status reports install remove
none absent writes Stado's hook nothing to remove
Stado's, as this build writes it installed leaves it unchanged removes it and puts pre-push.chained back as pre-push
Stado's, as another build wrote it outdated rewrites it removes it and puts pre-push.chained back
a hook that is not Stado's foreign moves it to pre-push.chained and writes Stado's leaves it
core.hooksPath sends Git to another directory governed leaves it leaves it
the workspace holds no checkout of the repository no-checkout nothing to gate nothing to remove

Running install twice changes nothing the second time. The hook is written through a file beside it renamed over it, so Git never runs a half-written script.

A checkout whose core.hooksPath (its own, or a global dispatcher's) names another directory is governed: Git runs that directory's pre-push, not the checkout's own, and Stado writes nothing into it. An empty core.hooksPath names no directory and is reported as core.hooksPath is set to the empty string, which names no directory, so Git runs no pre-push hook here. Such a checkout is gated only if the governing directory's pre-push runs stado quality check --pre-push.

install refuses a checkout that holds both a pre-push that is not Stado's and a pre-push.chained: <hook> is not Stado's and <chained> is already kept beside it, so neither can take the other's place; move one of them out of <hooks directory> and install again. A checkout that cannot be read (its Git refuses, it is not on main) is named with its cause. Every checkout is taken first; then the verb exits non-zero naming each one that failed: stado quality hooks <verb>: <n> of <m> checkout(s) could not be taken: <repository>: <cause>; ….

Plain output is one line per repository: <owner/name> (<checkout>) <state>: <what the verb did>. With --json, stdout carries {"verb", "checkouts"}, each checkout with its repository, products, checkout, hooks (the directory Git runs its hooks from), state, chained, said and failed; on a failure the same document is the error's details.

In Stado Desktop

Releases → Available operations… carries Read the pre-push gate of a product's checkouts, Install the pre-push gate in a product's checkouts and Remove the pre-push gate from a product's checkouts. Each takes a catalogued product, or every product, and runs the same command with --json on the selected Stado API host; install and remove ask for the host's mutation confirmation.

Source: this website