gitorules v0.3.1

Manage branch protection rules across all your repositories from a single YAML config

gitorules

CI License Crystal

Declarative GitHub Ruleset Manager.

Manage branch protection rules across all your repositories from a single YAML config.

Quick start

# Check current ruleset state
gitorules status

# Preview pending changes
gitorules diff

# Apply configuration
gitorules apply

# Generate .gitorules.yml from existing rulesets
gitorules init --org myorg

# Validate config without calling the API
gitorules lint

# Convert legacy org/repos/rules to defaults/scopes
gitorules migrate

Installation

GitHub Releases

Download the latest binary from releases.

Build from source

git clone https://github.com/unurgunite/gitorules.git
cd gitorules
shards install
crystal build src/gitorules.cr --release -o gitorules

Requires Crystal 1.21+.

CLI

gitorules <status|apply|diff|init|lint|migrate|verify> [options]

Commands

Command Description
status Show branch status for repositories
apply Apply branch configuration from .gitorules.yml
diff Show pending changes without applying
init Generate .gitorules.yml from existing branch rules
lint Validate config schema and values (offline)
verify Verify required checks are produced by workflows (offline)
migrate Convert legacy config to defaults/scopes shape

Options

Flag Description
--dry-run Preview apply changes without making them
--diff Show pending changes (same as diff command)
--repo REPO Target a single repository (owner/name)
--scope NAME Only process repositories in this scope
--only LIST Only process subsystems (branch,labels,workflows,files)
--exclude REPO Exclude repository (repeatable)
--org ORG GitHub organization name (for init)
--template NAME Standard CI template for init (ruby,node,crystal,gradle)
--in-place Overwrite config file in place (migrate only)
--json Machine-readable JSON output
--quiet Suppress all output except errors
--verbose Show unchanged workflows and detailed output
--yes Skip confirmation prompt and apply immediately
--token TOKEN GitHub personal access token
--config PATH Path to config file (default: .gitorules.yml)
--version Show version
-h, --help Show help

Apply confirmation:

Before applying changes, gitorules apply shows a diff and asks for confirmation. Use --yes to skip the prompt in scripts/automation.

Exit codes

  • 0 — all branch rules are up to date (no changes needed). For lint: config is valid (warnings allowed). For verify: all required checks are produced (warnings allowed). For migrate: migration succeeded. For status/diff/apply, see below
  • 1 — changes detected (in diff mode) or changes were applied (in apply mode). Also returned when the confirmation prompt is declined (changes exist but were skipped)
  • 2 — execution error (config error, API error, etc.). For lint: schema or value errors found. For verify: required checks with no producing workflow found. For migrate: read, parse, or schema error

gitorules lint

Validates .gitorules.yml offline (no API calls, no token required). Every error states what is wrong, where (file and key), and how to fix it.

gitorules lint --config .gitorules.yml

Checks include:

  • merge methods must be the string onlymerge: true is an error, not silently ignored
  • at most one of merge/squash/rebase may be only
  • checks must be a non-empty list of strings
  • check names without a Workflow / job separator produce a warning; verify real names with gh api repos/<org>/<repo>/commits/HEAD/check-runs
  • check patterns with glob characters (*, ?, [) produce a warning: they match locally and are skipped when creating rulesets
  • every exact required check must be produced by a workflow in the same scope (see gitorules verify): a stale check such as check / check is an error shaped as what is wrong (the check name), where (rules.<scope>.<type>.checks), how to fix (rename the check or update the template), plus the list of checks the scope actually produces. A produced job with no matching requirement is a warning, not an error

Exit codes: 0 when the file is valid (warnings allowed), 2 on any error.

gitorules verify

Dry-run report of required-vs-produced checks per scope (offline, no API calls, no token required). Reuses the same cross-check core as lint without failing the schema validation. Accepts both gitorules verify and gitorules scope verify spellings.

gitorules verify --config .gitorules.yml
gitorules scope verify --scope backend --config .gitorules.yml
gitorules verify --json | jq '.[] | {scope, missing, extra, ok}'

Matrix axes in templates expand to concrete GitHub check names (CI / test (20)), so comparison is exact, not prefix-based. Both matrix: {key: [values]} maps and include: lists are supported; unknown shapes fall back to the plain job name with a warning.

Exit codes: 0 when every required check is produced (warnings allowed), 2 on missing checks or read/parse errors.

gitorules migrate

Converts a legacy org/repos/rules (or orgs) config to the defaults/scopes shape:

# Print migrated YAML to stdout (source file untouched)
gitorules migrate --config .gitorules.yml

# Rewrite the source file
gitorules migrate --config .gitorules.yml --in-place

Single-org input moves shared rules to defaults and repositories to scopes.main (short names expand to full org/name entries; bare org without repos becomes org/*). Multi-org input becomes one scope per organization. Input already using scopes is returned unchanged.

Exit codes: 0 on success, 2 on read, parse, or schema errors.

Authentication

Use a Personal Access Token with repo and read:org scopes:

gitorules --token ghp_xxx status

Or set GITHUB_TOKEN environment variable.

If no token is configured, gitorules prints an error and exits with code 2.

Configuration: .gitorules.yml

gitorules reads a YAML config file (default: .gitorules.yml). This file defines which repositories to manage and what branch protection rules to enforce.

File structure

.gitorules.yml
├── orgs (multi-org mode)
│   └── <organization>
│       ├── repos      — list of repos
│       └── rules      — branch type definitions (see below)
│
└── org + repos + rules (single-org mode, shorthand)

Multi-org mode

orgs:
  unurgunite:
    repos:
      - docscribe
      - irb-autosuggestions
    rules:
      default_branch:
        merge: only
        checks:
          - "CI / build"

      release:
        pattern: v*
        squash: only

      system:
        pattern: system/*
        merge: only

Rule types

Key Description Values
pattern Branch pattern (default: master) Glob pattern or branch name
merge Merge method restriction only, false (omit for all methods)
rebase Rebase method restriction only, false
squash Squash method restriction only, false
checks Required status checks List of check context strings
required_approvals Required approving reviews Integer (default: 0)
dismiss_stale Dismiss approvals on new push true, false (default: true)
require_code_owner Require code owner review true, false (default: false)
enforce_admins Enforce rules for admins true, false (default: true)
deletion Allow deletion (default: true) true, false — set to false to protect branch
non_fast_forward Allow non-fast-forward pushes true, false (default: false)

Single-org mode

For one organization, you can omit orgs: and use flat keys:

org: unurgunite
repos:
  - docscribe
rules:
  default_branch:
    merge: only

Branch types

Each key under rules is a branch type — a named group of branches that share the same protection rules. You can define any name; the value becomes a column in gitorules status output.

Conventional types:

Type Default pattern Matches Typical use
default_branch master refs/heads/master Main branch
release v* refs/heads/v1.0.0, v2.3.4, ... Release branches
system system/* refs/heads/system/* CI/automation branches

Any other key (e.g. feature, dev, staging) resolves to refs/heads/{key}/*.


Branch type options

Each branch type supports these fields:

Field Type Description Default
pattern string Override the branch glob pattern See table above
name string Custom ruleset display name Auto-generated (e.g. "master")
merge "only" Restrict to merge commits only Any method allowed
squash "only" Restrict to squash merges only Any method allowed
rebase "only" Restrict to rebase merges only Any method allowed
checks [string] Required status check contexts None required
linear_history bool Require linear history (planned) false
delete_branch bool Auto-delete branch after merge (planned) false

pattern

Overrides the default branch glob for this type. The value is appended to refs/heads/, so v* becomes refs/heads/v*.

release:
  pattern: v*        # matches v1.0.0, v2.3.4, v2026.07, ...

feature:
  pattern: feat/*    # matches feat/settings, feat/export/abc

Merge methods: merge, squash, rebase

Exactly one method must be set to "only". The GitHub API allows only one merge method per ruleset. If none is set, all three methods are allowed.

default_branch:
  merge: only        # ✓ merge commit  (no squash, no rebase)

release:
  squash: only       # ✓ squash commit (no merge, no rebase)

[!NOTE] "only" is a string, not a boolean. merge: true does nothing.

checks

List of status check context names — these come directly from your GitHub Actions workflows. When a workflow runs, GitHub posts checks named after the workflow and job.

How to find check names:

# View check names for the latest commit on a branch
gh api repos/<org>/<repo>/commits/HEAD/check-runs --jq '.check_runs[].name'

The naming convention is "<workflow name> / <job name>". For a workflow like:

# .github/workflows/ci.yml
name: CI
jobs:
  build:
    runs-on: ubuntu-latest

The check context will be "CI / build".

Example:

default_branch:
  merge: only
  checks:
    - "CI / build"
    - "CI / lint"
    - "CI / test (1.20.0)"

[!Warning] If the config requires checks that don't exist in the repository's CI, the ruleset will still be created, but the status checks will never pass (pending indefinitely).

name

By default, gitorules generates a display name from the branch type:

  • default_branch -> "master"
  • release -> "Release branches — squash only"
  • custom -> "{Type} branches"

Overriding with name is useful when you want a specific name in the GitHub UI or when renaming an existing ruleset:

default_branch:
  name: "Main branch protection"
  merge: only

What gitorules creates

For each branch type, gitorules creates a GitHub ruleset with these rules:

Rule type Purpose Always present?
deletion Prevent branch deletion ✅ Always
non_fast_forward Require up-to-date branch ✅ Always
pull_request Require PR with merge method ✅ Always
required_status_checks Enforce CI checks Only if checks: set

The ruleset targets branches matching refs/heads/{pattern} and uses enforcement: active (fully enforced, not "evaluate" or "disabled").

For pull_request, these parameters are always set:

Parameter Value
required_approving_review_count 0
dismiss_stale_reviews_on_push false
require_code_owner_review false
require_last_push_approval false
required_review_thread_resolution false
allowed_merge_methods [method] (only if merge/squash/rebase: only)

Config lookup logic

gitorules resolves which rules apply to a repository:

  1. Multi-org mode (orgs:): finds the org that owns the repo by splitting "org/repo", looks up rules in orgs.<org>.rules
  2. Single-org mode (org: + rules:): uses top-level rules directly

If the owning org has no rules, the repo shows ✗ MISSING for every branch type.


gitorules status output

The table columns correspond to branch type keys from your config. Each cell shows:

Status Meaning
✓ merge +checks Merge method OK, checks match (or extra checks found)
✓ merge ~checks Merge method OK, but checks differ from config
✓ merge -checks Merge method OK, but no required_status_checks rule
✗ merge Merge method doesn't match config
✗ MISSING No ruleset found for this branch type

Checks suffix:

  • +checks — required checks present (possibly more)
  • ~checks — checks exist but don't match config exactly
  • -checks — no checks rule at all

Workflows sync

gitorules syncs GitHub Actions workflow files from local templates via the Contents API, keeping .github/workflows/ identical across repositories.

Template layout

Declare workflows in .gitorules.yml (single-org mode shown; multi-org mode supports orgs.<org>.workflows with the same shape):

workflows:
  ci.yml:
    source: templates/ci.yml

Each key is the workflow file name; source is the local template path (relative to the current directory). The key ci.yml syncs to .github/workflows/ci.yml in every managed repository. Keys that already carry the .github/workflows/ prefix are used as-is.

Remote sources and pinning

A source can also reference a file from another repository with a pin:

workflows:
  ci.yml:
    source: FlorexLabs/templates@v1:ruby/ci.yml

The shape is owner/repo@ref:path, where path is the file inside the template repository and ref is a tag (e.g. v1) or a full 40-hex commit SHA (e.g. 9a3b...). Local paths without @ keep the previous behavior.

Tag pins track a moving tag; SHA pins are fully reproducible. Prefer SHA pins for production fleets and tags for tracking upstream.

Resolution fetches GET /repos/{repo}/contents/{path}?ref={ref} with the same authentication as other API calls. Downloads cache in memory per run, so one pin used by many repos or workflows performs a single fetch. Set GITORULES_CACHE_DIR to a directory to also cache downloads on disk for offline-friendly repeated runs.

Reproducibility is reported, not locked:

  • gitorules diff --verbose prints the resolved template sha per workflow, e.g. source 'FlorexLabs/templates@v1:ruby/ci.yml' resolved sha 91acc6....
  • JSON output (diff --json, apply --json) adds source, resolved_sha (template blob sha) and ref fields to each workflow entry.

Design note: resolved-sha reporting was chosen over a .gitorules.lock lockfile as the smaller fit — it reuses the existing unified JSON contract, adds no new file lifecycle or merge conflicts, and the blob sha already verifies content equality for the sha-match skip.

Allowlist

Only .github/workflows/*.yml (or *.yaml) targets are allowed — no subdirectories, no path traversal. A disallowed target aborts the run with exit code 2 before any API write.

Behavior

  • Matching blob shas are skipped silently (use --verbose to show them).
  • Missing remote files are created; differing files are updated with the remote blob sha.
  • --dry-run performs zero PUT requests and prints intentions instead.

Token scopes

Workflow sync needs contents:write (covered by the classic repo scope). For fine-grained tokens, grant Contents read and write on the managed repositories.

Gradle pack

templates/gradle/ci.yml is an IntelliJ plugin CI template. It defines a single build job on ubuntu-latest, so the required check context stays stable as CI / build.

Job steps in order:

  • checkout (actions/checkout@v4)
  • setup Java 21 on Temurin (actions/setup-java@v4, cache: gradle)
  • install Crystal (pinned version via crystal-lang/install-crystal@v1)
  • setup Gradle (gradle/actions/setup-gradle@v4)
  • version-consistency check: pluginVersion from gradle.properties must match the commit message tag [x.y.z], and CHANGELOG.md must contain a matching ## [x.y.z] section
  • test (./gradlew test)
  • verify (./gradlew verifyPlugin)
  • build (./gradlew buildPlugin)
  • upload (actions/upload-artifact@v4, build/libs/*.zip)

Usage:

workflows:
  ci.yml:
    source: templates/gradle/ci.yml

Require the stable context in branch protection:

rules:
  default_branch:
    merge: only
    checks:
      - "CI / build"

Extension points

Some repositories need project-specific steps (for example a change-notes check that compares CHANGELOG.md against plugin.xml). The base template stays intact; overrides are explicit files appended at a marked anchor.

Declare an extra-steps file per workflow entry:

workflows:
  ci.yml:
    source: templates/gradle/ci.yml
    extra_steps: templates/gradle/extra-steps.example.yml

Rules:

  • extra_steps is a local YAML file with a list of steps. It is appended verbatim after the # gitorules:extra-steps marker in the base template.
  • Keep the 6-space indent in the extra file so the result stays valid YAML under jobs.build.steps.
  • extra_steps_anchor optionally renames the marker (default: gitorules:extra-steps). A blank anchor is an error.
  • An unknown anchor is an error: the marker must exist in the base file. (For remote repo@ref:path bases the marker cannot be checked offline, so the anchor check runs at sync time.)
  • There are no silent full-file overrides. Unknown workflow fields are rejected by gitorules lint, and missing or invalid extra files fail the sync for that repository.

Example extra steps (see templates/gradle/extra-steps.example.yml):

- name: Check change notes
  run: ./gradlew checkChangeNotes

Minimal packs

Small starting points for non-Gradle repositories. Both use a single build job (CI / build) and expose the same # gitorules:extra-steps anchor.

  • templates/python/ci.yml: setup-python matrix (3.11, 3.12) with pip cache, dependency install, and a pytest skeleton.
  • templates/shell/ci.yml: shellcheck over **/*.sh plus a test skeleton that runs bats test/ when available.
workflows:
  ci.yml:
    source: templates/python/ci.yml
workflows:
  ci.yml:
    source: templates/shell/ci.yml

Stack presets

Stack presets map one template pack to one scope. Each scope declares its own workflows entry pointing at the stack template. A complete working example ships as .gitorules.yml.example — copy it to .gitorules.yml and adapt the repo lists to your fleet:

defaults:
  rules:
    default_branch:
      merge: only
  labels:
    - name: bug
      color: d73a4a
      description: Something is broken
    - name: enhancement
      color: a2eeef
      description: New feature or request

scopes:
  ruby-gems:
    repos:
      - unurgunite/docscribe
      - unurgunite/genius-api
    workflows:
      ci.yml:
        source: templates/ruby/ci.yml
  crystal-shards:
    repos:
      - unurgunite/gitorules
      - unurgunite/catalyst

The Ruby pack (templates/ruby/ci.yml) provides bundler cache, RuboCop style check, and RSpec across Ruby 3.1–3.4 in a single test job, so check contexts stay stable (CI / test). Run a preset with --scope:

gitorules diff --scope ruby-gems
gitorules apply --scope ruby-gems --yes

Node and VSCode stacks

Two templates cover Node.js projects. Both define a single test job — the job name is part of the GitHub check context, so renaming it changes required checks and must stay in sync with branch rules.

  • templates/node/ci.yml — standard Node CI. Single test job on ubuntu-latest with a node-version: [20, 22, 24] matrix. Installs with npm ci (npm cache), then runs eslint, typecheck, and tests. Matrix checks look like "CI / test (20)" — verify real names with gh api repos/<org>/<repo>/commits/HEAD/check-runs.
  • templates/node/vscode-ci.yml — VSCode extension pipeline. Single test job on ubuntu-latest with a node-version: [18, 20, 22, 24] by vscode-version: [stable, insiders] matrix. Runs format check (npm run format:check), lint, typecheck, compile, then extension tests under xvfb with retry (nick-fields/retry, 10 minute timeout, 2 attempts).
workflows:
  ci.yml:
    source: templates/node/ci.yml
  vscode-ci.yml:
    source: templates/node/vscode-ci.yml

Example branch rules for the standard Node template (matrix jobs produce one check per combination):

rules:
  default_branch:
    merge: only
    checks:
      - "CI / test (20)"
      - "CI / test (22)"
      - "CI / test (24)"

### Labels
Labels apply to every managed repository selected for the run.

```yaml
org: unurgunite
repos:
  - docscribe
rules:
  default_branch:
    merge: only
labels:
  - name: bug
    color: d73a4a
    description: Something is broken
  - name: help wanted
    color: "008672"
    description: Extra attention is needed
labels_sync: warn
Field Type Description
name string Label name (unique per repository)
color string Hex color without # (e.g. d73a4a)
description string Short description (optional)

Color comparison is case-insensitive and ignores a leading #; a missing description and an empty description are treated as equal.

Sync modes (labels_sync)

Mode Missing labels Differing labels Orphan labels (not in config)
warn (default) Created Updated Reported only, never deleted
prune Created Updated Deleted
ignore Created Updated Skipped silently

[!WARNING] labels_sync: prune deletes every label that is not listed in labels:, including labels created manually or by other tools. Run gitorules diff first and review the - Delete label lines before applying with prune.

Token scopes

Label sync uses the same authentication as branch rules: a Personal Access Token with repo and read:org scopes (or GITHUB_TOKEN with those scopes). No additional scopes are required.

Limiting a run to labels

gitorules diff --only labels
gitorules apply --only labels --yes
gitorules status --only labels

Use --only branch to skip labels. gitorules apply --dry-run performs zero writes for labels: creations, updates, and prune deletions are only reported.

In JSON output (--json), each label change is an entry shaped {repo, resource, action, changes[]} with resource: "labels" and action one of create, update, orphan, unchanged.

Files sync

gitorules syncs generic config files from local sources via the Contents API, keeping linter configs, version pins, dependabot config and issue templates identical across repositories.

files:
  .rubocop.yml:
    source: templates/.rubocop.yml
  .ruby-version:
    source: templates/.ruby-version
  .github/dependabot.yml:
    source: templates/dependabot.yml
  .github/ISSUE_TEMPLATE/bug_report.md:
    source: templates/bug_report.md

Keys are repository-relative target paths used as-is (unlike workflows, there is no bare-name prefix resolution). Multi-org mode supports orgs.<org>.files with the same shape.

Allowlist

Only these paths may be synced (per file class):

Class Paths
linter .rubocop.yml, .ameba.yml
version .ruby-version, .nvmrc
dependabot .github/dependabot.yml
issue_template .github/ISSUE_TEMPLATE/*.md (no subdirectories)
workflow .github/workflows/*.yml, .github/workflows/*.yaml

A disallowed target aborts the run with exit code 2 before any API write. Use gitorules diff --only files to preview file changes and gitorules apply --only files --yes to apply them. --dry-run performs zero PUT requests.

Scaffold a fresh repository

gitorules init --repo myorg/new-repo --template ruby
gitorules init --repo myorg/new-repo --template node
gitorules init --repo myorg/new-repo --template crystal
gitorules init --repo myorg/new-repo --template gradle

Each template writes only allowlisted paths (CI workflow plus the matching linter and version files). Matching shas are skipped; --dry-run prints intentions with zero writes.

JSON output

--json emits machine-readable JSON with a unified entry shape across status, diff and apply. Every per-resource entry carries:

Field Type Description
repo string Full repository name (owner/name)
resource string Ruleset display name (empty for repo-level errors)
action string One of create, update, unchanged, orphan, skip, error
changes [string] Human-readable differences (empty when none)

Legacy fields (types, changes, results, name, exists, merge_method_ok, checks_ok, error) are kept for compatibility.

Actions

  • create — ruleset is missing and would be created.
  • update — ruleset exists but differs from config.
  • unchanged — ruleset matches config.
  • orphan — ruleset exists on GitHub but has no matching branch type in config (diff only).
  • skip — repo has no configured rules (unknown org in multi-org mode).
  • error — API request failed; the entry also carries an error message field.

Examples

gitorules status --json | jq '.[0].types.default_branch | {resource, action, changes}'
# {"resource":"master","action":"unchanged","changes":[]}

gitorules diff --json | jq '.[0].changes[] | {resource, action, changes}'
# {"resource":"master","action":"create","changes":[]}

gitorules apply --json --dry-run | jq '.[0].results[] | {resource, action, changes}'

CI usage

- name: Check rulesets
  run: |
    gitorules diff --json > diff.json
    if jq -e '[.[].changes[]? | select(.action == "create" or .action == "update")] | length > 0' diff.json > /dev/null; then
      echo "Ruleset drift detected"
      jq -r '.[] | select(.action == "error") | "\(.repo): \(.error)"' diff.json
      exit 1
    fi

Drift gate

Use gitorules diff as a CI drift gate. Exit codes:

  • 0 — no drift (everything up to date).
  • 1 — drift detected (pending creates or updates). The confirmation prompt is bypassed in CI; use --yes only with apply.
  • 2 — execution error (config, auth, or API failure, including allowlist violations).

Text mode (gitorules diff) exits 1 when the output contains +, - or ~ change lines. JSON mode (gitorules diff --json) always exits 0 on success; gate on the payload with jq:

gitorules diff --json > diff.json

# Fail when any resource wants create or update.
if jq -e '[.[].changes[]? | select(.action == "create" or .action == "update")] | length > 0' diff.json > /dev/null; then
  echo "Drift detected"
  jq -r '.[].changes[]? | select(.action == "create" or .action == "update") | "\(.resource): \(.action)"' diff.json
  exit 1
fi

# Surface repo-level errors separately.
jq -r '.[] | select(.action == "error") | "\(.repo): \(.error)"' diff.json

The same entry shape ({repo, resource, action, changes[]}) covers branch rulesets, labels, workflows, and generic files, so one jq filter gates all subsystems. Combine with --only to gate a single subsystem (for example gitorules diff --only files --json).

Performance notes: repository listing follows GitHub Link pagination, per-repo work runs in a bounded fiber pool (size 10, ordered output), and API requests retry with exponential backoff on 429 and 5xx.

Development

shards install
crystal spec
./bin/ameba
crystal tool format --check

Contributing

See CONTRIBUTING.md.

License

MIT. See LICENSE.

Repository

gitorules

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 2
  • 2 days ago
  • July 5, 2026
License

MIT License

Links
Synced at

Sun, 13 Sep 2026 22:54:13 GMT

Languages