From e31319b3e31987f6990c300c342b6efcebdad330 Mon Sep 17 00:00:00 2001 From: Ikalus1988 <136884451+Ikalus1988@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:11:45 +0800 Subject: [PATCH] Add check-dco hook: verify commit message Signed-off-by --- .pre-commit-hooks.yaml | 11 ++ pre_commit_hooks/check_dco.py | 85 +++++++++++++ tests/check_dco_test.py | 223 ++++++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 pre_commit_hooks/check_dco.py create mode 100644 tests/check_dco_test.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 275605e..e5c161c 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/pre_commit_hooks/check_dco.py b/pre_commit_hooks/check_dco.py new file mode 100644 index 0000000..e3f86c8 --- /dev/null +++ b/pre_commit_hooks/check_dco.py @@ -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 `` 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[^<]+)' + 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, '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 "', + ) + + 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 " 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/tests/check_dco_test.py b/tests/check_dco_test.py new file mode 100644 index 0000000..d749345 --- /dev/null +++ b/tests/check_dco_test.py @@ -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 \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.""" + 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: 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'])