mirror of
https://github.com/pre-commit/pre-commit-hooks.git
synced 2026-07-02 15:39:35 +00:00
Add check-dco hook: verify commit message Signed-off-by
This commit is contained in:
parent
fa6b006f0e
commit
e31319b3e3
3 changed files with 319 additions and 0 deletions
|
|
@ -210,3 +210,14 @@
|
||||||
types: [text]
|
types: [text]
|
||||||
stages: [pre-commit, pre-push, manual]
|
stages: [pre-commit, pre-push, manual]
|
||||||
minimum_pre_commit_version: 3.2.0
|
minimum_pre_commit_version: 3.2.0
|
||||||
|
|
||||||
|
- id: check-dco
|
||||||
|
name: Check Developer Certificate of Origin (DCO)
|
||||||
|
description: >
|
||||||
|
Verifies that the commit message contains a valid
|
||||||
|
``Signed-off-by: Name <email>`` line per the
|
||||||
|
`Developer Certificate of Origin <https://developercertificate.org/>`__.
|
||||||
|
entry: check-dco
|
||||||
|
language: python
|
||||||
|
stages: [commit-msg]
|
||||||
|
minimum_pre_commit_version: 2.9.2
|
||||||
|
|
|
||||||
85
pre_commit_hooks/check_dco.py
Normal file
85
pre_commit_hooks/check_dco.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""check-dco: verify commit messages contain a Signed-off-by line.
|
||||||
|
|
||||||
|
This hook reads the commit message file (passed as argv[1] by pre-commit
|
||||||
|
in commit-msg stage) and checks that it contains a valid
|
||||||
|
``Signed-off-by: Name <email>`` line per the Developer Certificate of Origin.
|
||||||
|
|
||||||
|
This is a pure Python implementation with no external dependencies —
|
||||||
|
only the standard library is used (``re``, ``sys``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DCO_PATTERN = re.compile(
|
||||||
|
r'^Signed-off-by:\s+'
|
||||||
|
r'(?P<name>[^<]+)'
|
||||||
|
r'\s+'
|
||||||
|
r'<(?P<email>[^>]+)>'
|
||||||
|
r'\s*$',
|
||||||
|
)
|
||||||
|
|
||||||
|
EXIT_PASS = 0
|
||||||
|
EXIT_FAIL = 1
|
||||||
|
|
||||||
|
|
||||||
|
def check_dco(commit_msg_path: str) -> tuple[int, list[str]]:
|
||||||
|
"""Check a commit message file for a valid DCO sign-off.
|
||||||
|
|
||||||
|
Returns (exit_code, diagnostic_messages).
|
||||||
|
"""
|
||||||
|
with open(commit_msg_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.read().splitlines()
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
found_valid = False
|
||||||
|
|
||||||
|
for i, line in enumerate(lines, start=1):
|
||||||
|
# Check if any line matches the DCO pattern
|
||||||
|
if DCO_PATTERN.match(line):
|
||||||
|
found_valid = True
|
||||||
|
# Detect malformed sign-off attempts
|
||||||
|
lower = line.lower().strip()
|
||||||
|
if lower.startswith('signed-off-by'):
|
||||||
|
if not DCO_PATTERN.match(line):
|
||||||
|
errors.append(
|
||||||
|
f'{commit_msg_path}:{i}: malformed Signed-off-by line — '
|
||||||
|
f'expected "Signed-off-by: Name <email>"',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not found_valid and not errors:
|
||||||
|
# No sign-off found at all
|
||||||
|
errors.append(
|
||||||
|
f'{commit_msg_path}: missing Signed-off-by line. '
|
||||||
|
f'Add "Signed-off-by: Your Name <your@email>" to the commit message.',
|
||||||
|
)
|
||||||
|
|
||||||
|
for err in errors:
|
||||||
|
print(err)
|
||||||
|
|
||||||
|
if not found_valid:
|
||||||
|
return EXIT_FAIL, errors
|
||||||
|
|
||||||
|
return EXIT_PASS, []
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Check that the commit message has a DCO sign-off.',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'commit_msg_file',
|
||||||
|
help='Path to the commit message file (provided by pre-commit).',
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
exit_code, _ = check_dco(args.commit_msg_file)
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
223
tests/check_dco_test.py
Normal file
223
tests/check_dco_test.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from pre_commit_hooks.check_dco import check_dco, main
|
||||||
|
|
||||||
|
|
||||||
|
def _write_commit_msg(content: str) -> str:
|
||||||
|
"""Write a temporary commit message file and return its path."""
|
||||||
|
tmp = tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8')
|
||||||
|
tmp.write(content)
|
||||||
|
tmp.close()
|
||||||
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
|
# ── Success cases ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_standard_signoff():
|
||||||
|
"""A standard Signed-off-by line should pass."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'feat: add new feature\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by: Alice Smith <alice@example.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, _ = check_dco(msg)
|
||||||
|
assert code == 0
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_signoffs():
|
||||||
|
"""Multiple co-author sign-offs should pass."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'fix: resolve encoding issue\n'
|
||||||
|
'\n'
|
||||||
|
'Co-authored-by: Bob <bob@example.com>\n'
|
||||||
|
'Signed-off-by: Alice <alice@example.com>\n'
|
||||||
|
'Signed-off-by: Bob <bob@example.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, _ = check_dco(msg)
|
||||||
|
assert code == 0
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signoff_in_body_not_trailer():
|
||||||
|
"""Sign-off in the middle of the body should still pass."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'docs: update readme\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by: Charlie <charlie@example.com>\n'
|
||||||
|
'\n'
|
||||||
|
'More content after sign-off.\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, _ = check_dco(msg)
|
||||||
|
assert code == 0
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signoff_with_full_name():
|
||||||
|
"""A sign-off with full name and email should pass."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'chore: bump version\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by: John Michael Doe <john.m.doe@company.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, _ = check_dco(msg)
|
||||||
|
assert code == 0
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signoff_with_plus_email():
|
||||||
|
"""Email with + addressing should pass."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'refactor: extract method\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by: Dev <dev+feature@example.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, _ = check_dco(msg)
|
||||||
|
assert code == 0
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_commit_with_signoff():
|
||||||
|
"""A multi-paragraph commit message with a trailing sign-off."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'feat: implement BM25 search\n'
|
||||||
|
'\n'
|
||||||
|
'This adds BM25 scoring to the search module for\n'
|
||||||
|
'better relevance ranking in RAG pipelines.\n'
|
||||||
|
'\n'
|
||||||
|
'Includes unit tests and benchmark script.\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by: Ikalus <ikalus1988@example.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, _ = check_dco(msg)
|
||||||
|
assert code == 0
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Failure cases ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_signoff():
|
||||||
|
"""A commit message without any Signed-off-by line should fail."""
|
||||||
|
msg = _write_commit_msg('fix: resolve timeout issue\n\nJust a quick fix.\n')
|
||||||
|
try:
|
||||||
|
code, errors = check_dco(msg)
|
||||||
|
assert code == 1
|
||||||
|
assert any('missing Signed-off-by' in e for e in errors)
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_message():
|
||||||
|
"""An empty commit message should fail."""
|
||||||
|
msg = _write_commit_msg('')
|
||||||
|
try:
|
||||||
|
code, errors = check_dco(msg)
|
||||||
|
assert code == 1
|
||||||
|
assert any('missing Signed-off-by' in e for e in errors)
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signoff_without_email():
|
||||||
|
"""Signed-off-by: without email should fail."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'fix: typo\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by: JustName\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, errors = check_dco(msg)
|
||||||
|
assert code == 1
|
||||||
|
assert any('malformed' in e for e in errors)
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signoff_without_name():
|
||||||
|
"""Signed-off-by: <email> without name should fail."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'fix: typo\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by: <anon@example.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, errors = check_dco(msg)
|
||||||
|
assert code == 1
|
||||||
|
assert any('malformed' in e for e in errors)
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lowercase_signoff_only():
|
||||||
|
"""lowercase 'signed-off-by:' without proper format should fail."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'fix: typo\n'
|
||||||
|
'\n'
|
||||||
|
'signed-off-by: alice <alice@example.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, _ = check_dco(msg)
|
||||||
|
assert code == 1
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signoff_only_no_colon():
|
||||||
|
"""Signed-off-by without colon should fail."""
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'fix: typo\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by alice <alice@example.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
code, errors = check_dco(msg)
|
||||||
|
assert code == 1
|
||||||
|
assert any('malformed' in e for e in errors)
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ── main() integration ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_passes_with_valid_signoff():
|
||||||
|
msg = _write_commit_msg(
|
||||||
|
'feat: add search\n'
|
||||||
|
'\n'
|
||||||
|
'Signed-off-by: Test <test@example.com>\n',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert main([msg]) == 0
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_fails_without_signoff():
|
||||||
|
msg = _write_commit_msg('fix: quick patch\n')
|
||||||
|
try:
|
||||||
|
assert main([msg]) == 1
|
||||||
|
finally:
|
||||||
|
os.unlink(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_help():
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
main(['--help'])
|
||||||
Loading…
Add table
Add a link
Reference in a new issue