diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 275605eb..e5c161ca 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -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 `` line per the + `Developer Certificate of Origin `__. + entry: check-dco + language: python + stages: [commit-msg] + minimum_pre_commit_version: 2.9.2 diff --git a/README.md b/README.md index 8432455f..5e87cbf5 100644 --- a/README.md +++ b/README.md @@ -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 `` +line per the `Developer Certificate of Origin `__. + - 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. diff --git a/pre_commit_hooks/check_dco.py b/pre_commit_hooks/check_dco.py new file mode 100644 index 00000000..e7e888ac --- /dev/null +++ b/pre_commit_hooks/check_dco.py @@ -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 `` 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[^<]+)' + r'\s+' + r'<(?P[^>]+)>' + 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 "', + ) + + 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 " 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()) diff --git a/setup.cfg b/setup.cfg index d91f4399..2f852276 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/tests/check_dco_test.py b/tests/check_dco_test.py new file mode 100644 index 00000000..8b07ddd3 --- /dev/null +++ b/tests/check_dco_test.py @@ -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 \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 \n' + 'Signed-off-by: Alice \n' + 'Signed-off-by: Bob \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 \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 \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 \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 \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: without name should fail.""" + msg = _write_commit_msg( + 'fix: typo\n' + '\n' + 'Signed-off-by: \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 \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 \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 \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'])