◆ docs

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/gitops covers deploy/gitops/prod/kustomization.yaml but not a sibling like deploy/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.

Note

"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 .sql file exists in BOTH directories.
Heads up

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.

Was this page helpful?