Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,14 @@
types: [text]
stages: [pre-commit, pre-push, manual]
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ Require literal syntax when initializing empty or zero Python builtin types.
#### `check-case-conflict`
Check for files with names that would conflict on a case-insensitive filesystem like MacOS HFS+ or Windows FAT.

#### `check-dco`
Verifies that commit messages contain a valid ``Signed-off-by: Name <email>``
line per the `Developer Certificate of Origin <https://developercertificate.org/>`__.
- This hook runs in the ``commit-msg`` stage to catch missing sign-offs
before they're pushed.

#### `check-executables-have-shebangs`
Checks that non-binary executables have a proper shebang.

Expand Down
83 changes: 83 additions & 0 deletions pre_commit_hooks/check_dco.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""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

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, 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: Name <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())
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ console_scripts =
requirements-txt-fixer = pre_commit_hooks.requirements_txt_fixer:main
sort-simple-yaml = pre_commit_hooks.sort_simple_yaml:main
trailing-whitespace-fixer = pre_commit_hooks.trailing_whitespace_fixer:main
check-dco = pre_commit_hooks.check_dco:main

[bdist_wheel]
universal = True
Expand Down
226 changes: 226 additions & 0 deletions tests/check_dco_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
from __future__ import annotations

import os
import tempfile

import pytest

from pre_commit_hooks.check_dco import check_dco
from pre_commit_hooks.check_dco import 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."""
body = 'fix: resolve timeout issue\n\nJust a quick fix.\n'
msg = _write_commit_msg(body)
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