Merge guards
Declare paths or migration rules a merge must never break, and the merge button refuses with the reason instead of landing the change.
Merge guards are per-repository merge-time checks configured by committed
.rickub/ marker files. Both are off by default: a repository that
declares nothing merges exactly as before. Both read their marker from the
base branch of the merge — never from the merge request's head — so a
branch cannot switch its own guard off on the way in, and a deliberate change
is always a separate, individually reviewable merge request.
When a guard refuses, the merge answers 409 and the reason is shown on the
merge request page (and returned by the API and CLI). The reason names what
moved, what declared it, and the command that fixes it.
Pipeline-owned paths: .rickub/protected-paths
Some files have exactly one writer, and it is not a person: a deploy pipeline that bumps an image pointer, a generated lockfile, a published version file. A merge request that changes one of them is almost always an accident — a branch rebased onto a mirror that carries the pipeline's own commits — and it merges silently, with nothing in the request to review.
Commit a .rickub/protected-paths file at the repository root to declare
those paths. The engine refuses any merge (merge, squash, or fast-forward)
whose merge result would change one of them:
# .rickub/protected-paths — one repo-relative path per line
deploy/gitops/prod/kustomization.yaml
dist/version.txt
File format:
- One repo-relative path per line.
#comments and blank lines are ignored; surrounding whitespace is trimmed, and a leading or trailing/(directory form) is tolerated and stripped. - A declared directory covers everything under it:
deploy/gitopscoversdeploy/gitops/prod/kustomization.yamlbut not a sibling likedeploy/gitops-staging/x. - Globs are not supported, and that is deliberate: an entry the parser
cannot interpret exactly —
*,?,[], backslashes,./..segments, empty segments — is DROPPED rather than matched approximately. This file gates merges, so a typo must never silently become a loose match. A directory prefix covers the real cases without a pattern engine. - The file is capped at 64 KiB; anything larger is treated as empty (a big blob at that path is somebody else's file, not a declaration).
The declaration is read from the base branch at merge time. To change a
protected file on purpose, first merge a merge request that removes (or
narrows) its entry from .rickub/protected-paths — that request touches only
the declaration, so it merges — then change the file in a second request.
Repositories with direct-push access to the branch can also write the file the
way the pipeline does.
"My merge request doesn't even contain that file — why is it refused?" The
refusal names the path and usually means your branch was rebased onto a
remote whose tip carries the pipeline's own commits, so your branch now
carries a newer (or older) copy of the protected file. The fix the refusal
prints rebases you onto the line of development instead:
git fetch <upstream> && git rebase <upstream>/<base> && git push --force-with-lease.
Migration numbering: .rickub/migration-guard
If your repository manages database schema with numbered SQL migrations, two merge requests opened at the same time can both take the next free number. Each one's CI passes — the collision only exists once both merge — and the second one lands a broken migration set on your default branch.
Commit an empty .rickub/migration-guard marker at the repository root to
have the merge button refuse any merge whose merge result breaks the
migration-numbering rules:
- Every migration file name starts with a 4-digit numeric prefix
(
0133_add_index.sql), and no two files share a prefix — except exact historical sets grandfathered by the rule owner. - The migration directories are in mirror parity: every
.sqlfile exists in BOTH directories.
The rules enforce rickub's own two-directory layout: a canonical
migrations/ directory plus a web/migrations/ mirror of the same files
(the mirror exists because rickub's binary embeds and applies it at boot).
That dual-directory layout is the only one supported today. A repository
with a single migrations/ directory and no mirror should NOT opt in yet:
the parity rule would flag every migration as missing from the mirror. Lay
your repository out exactly as rickub does — or wait for per-repository
configurable directories, a known follow-up.
The guard checks the merge RESULT, not either branch: a sibling merge request
that merged while yours was open and took your number is caught at your merge
click, which is exactly the race a CI check on your own branch cannot see. A
merge that leaves both migration directories byte-for-byte the same name sets
as the base branch exits before any check runs, so most merges pay nothing.
The refusal carries the violation text — the number, both files, and the
renumber fix (make sync-migrations on a rickub-layout repo, or renumber your
new file to the next genuinely free number — never fill a gap, never rename an
already-applied migration).
The check fail-open, loudly: if the guard's control-plane service is unreachable or unconfigured, the merge proceeds and the skip is logged, so an unenforcing deployment is visible rather than a frozen merge button.