Skip to content

Gitaly Server Hook for Commit Validation

Overview

A global Gitaly pre-receive server hook that validates commit messages on push to the main branch across all projects on the GitLab instance.

What it does:

  • On git push to main (only) — validates every commit against the Conventional Commits format
  • Invalid commit → push is rejected with a descriptive error
  • Pushes to other branches — allowed without validation

Info

GitLab Push Rules (UI-based message validation) require GitLab Premium. Since we run GitLab CE, server hooks are the only mechanism for server-side commit validation.

Components

File Purpose
TBD-GitalyCommitHook Pre-receive hook script — reads stdin, iterates commits, calls validation functions
TBD-CommitTypes Single source of truth (SSOT) for commit types, regex patterns, and validation functions. Sourced by the hook at runtime.

Both files originate from the ICVR/General/BUILD_SCRIPTS repository (Version/ directory) and must be manually deployed to the Gitaly server.

Installation

Configure the Custom Hooks Directory

Verify the configuration in /etc/gitlab/gitlab.rb:

gitaly['configuration'] = {
  hooks: {
    custom_hooks_dir: '/var/opt/gitlab/gitaly/custom_hooks'
  }
}

If the directive is missing, add it and apply:

sudo gitlab-ctl reconfigure

Create the Directory Structure

sudo mkdir -p /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d
sudo mkdir -p /var/opt/gitlab/gitaly/custom_hooks/lib

GitLab executes all files in pre-receive.d/ in alphabetical order. If any file returns a non-zero exit code, the push is rejected. The lib/ directory is not executed — it stores shared libraries sourced by hooks.

Deploy Files

Copy from the buildscripts repository:

sudo cp TBD-GitalyCommitHook /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/TBD-GitalyCommitHook
sudo cp TBD-CommitTypes      /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes
sudo chmod +x /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/TBD-GitalyCommitHook
sudo chmod +x /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes
sudo chown git:git /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/TBD-GitalyCommitHook
sudo chown git:git /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes

Resulting Directory Structure

/var/opt/gitlab/gitaly/custom_hooks/
├── pre-receive.d/
│   └── TBD-GitalyCommitHook   # Pre-receive hook (executed by GitLab)
└── lib/
    └── TBD-CommitTypes         # SSOT for types (sourced by hook via hardcoded path)

The hook sources TBD-CommitTypes from the lib/ directory:

source "/var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes"

Note

TBD-CommitTypes has a source guard (return 0 on double-load). It defines no executable logic when called directly — only variables and functions that become available after sourcing.

How It Works

Execution Flow

git push
GitLab/Gitaly invokes all scripts in pre-receive.d/ (alphabetical order)
TBD-GitalyCommitHook receives stdin: <old_sha> <new_sha> <ref_name>
  ├─ ref_name != refs/heads/main → skip (exit 0)
  ├─ Branch deletion (new_sha = 000...0) → skip
  ├─ New branch creation (old_sha = 000...0) → skip
  ├─ For each commit in old_sha..new_sha:
  │   ├─ Merge commit (Merge branch ...) → pass (valid)
  │   ├─ Valid format (type: description) → pass
  │   └─ Invalid format → print GL-HOOK-ERR → increment error count
  └─ errors > 0 → exit 1 (push rejected)
     errors = 0 → exit 0 (push accepted)

Validation Rules

The hook calls tbd_validate_message() from TBD-CommitTypes. A commit message is valid if it matches either:

  • Conventional Commits format: <type>[(<scope>)]: <description>
  • Merge commit pattern: Merge branch ... or Merge remote-tracking branch ...

Valid commit types:

Type Example
feat feat: add user dashboard
fix fix(auth): resolve token expiry
chore chore: update dependencies
remove remove: drop legacy endpoint
BREAKING CHANGE BREAKING CHANGE: remove v1 API
BREAKING-CHANGE BREAKING-CHANGE: remove v1 API

Error Output

Invalid commits produce GL-HOOK-ERR: prefixed messages, which GitLab displays to the user:

remote: GL-HOOK-ERR: Commit a1b2c3d4 has invalid message: 'bad commit'
remote: GL-HOOK-ERR: Expected: <type>(<scope>): <description>
remote: GL-HOOK-ERR: Valid types: feat, fix, chore, remove, BREAKING CHANGE, BREAKING-CHANGE
remote: GL-HOOK-ERR: Examples: 'feat: add user login', 'fix(auth): resolve token expiry'

Verification

Server-Side Test

# Test: non-main branch — should pass (exit 0)
echo "aaa000 bbb111 refs/heads/feature/test" | \
  sudo -u git /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/TBD-GitalyCommitHook
echo $?  # Expected: 0

Client-Side Test

# Invalid commit — push to main should be rejected
git commit --allow-empty -m "bad commit"
git push origin main
# → remote: GL-HOOK-ERR: Commit xxxxxxxx has invalid message: 'bad commit'
# → rejected

# Valid commit — push to main should succeed
git commit --allow-empty -m "feat: test hook"
git push origin main
# → OK

Updating Commit Types

When TBD-CommitTypes is updated in the buildscripts repository, redeploy it to the Gitaly server:

sudo cp TBD-CommitTypes /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes
sudo chown git:git /var/opt/gitlab/gitaly/custom_hooks/lib/TBD-CommitTypes

No service restart is required — Gitaly reads the scripts on each push.

Info

TBD-CommitTypes contains all type definitions, regex patterns, and validation functions (tbd_validate_message, tbd_print_format_hint). The hook script itself contains no validation logic — it only calls functions from the SSOT.

Architecture: CI vs Gitaly Validation

Commit message validation exists in two contexts, both using TBD-CommitTypes as the single source of truth:

┌──────────────────────────────────┬──────────────────────────────────┐
│        CI Runner                 │        Gitaly Server             │
├──────────────────────────────────┼──────────────────────────────────┤
│  TBD-ValidateCommitMessage       │  TBD-GitalyCommitHook            │
│    source Config (Logs, Errors)  │    (standalone, no dependencies) │
│    source TBD-CommitTypes        │    source TBD-CommitTypes        │
│    → validates MR title          │    → validates push commits      │
│    → runs in MR pipeline         │    → runs on every push to main  │
├──────────────────────────────────┴──────────────────────────────────┤
│              TBD-CommitTypes (single source of truth)               │
│                                                                     │
│  Types: feat, fix, chore, remove, BREAKING CHANGE, BREAKING-CHANGE  │
│  Patterns: TBD_VALID_PATTERN, TBD_MERGE_PATTERN, ...                │
│  Functions: tbd_validate_message(), tbd_print_format_hint()         │
│  Mappings: tbd_type_bump(), tbd_type_section()                      │
└─────────────────────────────────────────────────────────────────────┘
Aspect CI (MR Pipeline) Gitaly (Server Hook)
Script TBD-ValidateCommitMessage TBD-GitalyCommitHook
Validates MR title (CI_MERGE_REQUEST_TITLE) Individual commit messages on push
When MR pipeline (open/update MR) Every git push to main
Dependencies Config (Logs, ErrorCodes), TBD-CommitTypes TBD-CommitTypes only (standalone)
Failure Pipeline job fails (non-blocking merge) Push rejected (blocking)
Scope Single project (where CI is configured) All projects on the GitLab instance

Info

Both validation layers complement each other:

* **CI validation** catches issues early — developers see errors in the MR pipeline before merge
* **Gitaly validation** is the last line of defense — prevents non-compliant commits from entering `main` even if CI is bypassed or not configured

Scope and Limitations

  • The hook runs globally — it validates pushes to main in every project on the instance
  • Projects that do not follow Conventional Commits will have pushes to main rejected
  • To exempt a project, it must push to a differently named branch (not main)
  • The hook does not validate MR squash-merge messages — that is handled by the CI job (TBD-ValidateCommitMessage)
  • The hook only checks the commit subject line (first line), not the body