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 1/6] 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 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/pre_commit_hooks/check_dco.py b/pre_commit_hooks/check_dco.py new file mode 100644 index 00000000..e3f86c8f --- /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 00000000..d7493458 --- /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']) From e384faeea58ba3b28134fa563343897acd53cc8b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:12:05 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pre_commit_hooks/check_dco.py | 3 +-- tests/check_dco_test.py | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pre_commit_hooks/check_dco.py b/pre_commit_hooks/check_dco.py index e3f86c8f..1e3a5b26 100644 --- a/pre_commit_hooks/check_dco.py +++ b/pre_commit_hooks/check_dco.py @@ -7,7 +7,6 @@ This is a pure Python implementation with no external dependencies — only the standard library is used (``re``, ``sys``). """ - from __future__ import annotations import argparse @@ -32,7 +31,7 @@ def check_dco(commit_msg_path: str) -> tuple[int, list[str]]: Returns (exit_code, diagnostic_messages). """ - with open(commit_msg_path, 'r', encoding='utf-8') as f: + with open(commit_msg_path, encoding='utf-8') as f: lines = f.read().splitlines() errors: list[str] = [] diff --git a/tests/check_dco_test.py b/tests/check_dco_test.py index d7493458..73d856f8 100644 --- a/tests/check_dco_test.py +++ b/tests/check_dco_test.py @@ -1,10 +1,12 @@ from __future__ import annotations import os -import pytest import tempfile -from pre_commit_hooks.check_dco import check_dco, main +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: From 4a843640195f36f3f6c3fa5e0992d706810f0b71 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 13 Jun 2026 15:28:26 +0800 Subject: [PATCH 3/6] fix: register check-dco console_scripts entry point --- setup.cfg | 1 + 1 file changed, 1 insertion(+) 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 From 0b79656568d3051747425331200030a02b5b997a Mon Sep 17 00:00:00 2001 From: Ikalus1988 <136884451+Ikalus1988@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:56:26 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20satisfy=20flake8=20=E2=80=94=20drop?= =?UTF-8?q?=20unused=20import,=20shorten=20long=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pre_commit_hooks/check_dco.py | 3 +-- tests/check_dco_test.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pre_commit_hooks/check_dco.py b/pre_commit_hooks/check_dco.py index 1e3a5b26..e7e888ac 100644 --- a/pre_commit_hooks/check_dco.py +++ b/pre_commit_hooks/check_dco.py @@ -12,7 +12,6 @@ import argparse import re from collections.abc import Sequence -from typing import Optional DCO_PATTERN = re.compile( r'^Signed-off-by:\s+' @@ -54,7 +53,7 @@ def check_dco(commit_msg_path: str) -> tuple[int, list[str]]: # 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.', + f'Add "Signed-off-by: Name " to the commit message.', ) for err in errors: diff --git a/tests/check_dco_test.py b/tests/check_dco_test.py index 73d856f8..8b07ddd3 100644 --- a/tests/check_dco_test.py +++ b/tests/check_dco_test.py @@ -118,7 +118,8 @@ def test_multiline_commit_with_signoff(): 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') + 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 From c1dde8ef3f20fe96bda898c5bd7ac516b5117008 Mon Sep 17 00:00:00 2001 From: Ikalus1988 <136884451+Ikalus1988@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:04:11 +0800 Subject: [PATCH 5/6] docs: add check-dco to README hooks list --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 8432455f..2ee0cdc8 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. @@ -212,3 +218,4 @@ Trims trailing whitespace. If you'd like to use these hooks, they're also available as a standalone package. Simply `pip install pre-commit-hooks` + From e63f77eb193d990b79e881f8b1b12b654044d1ab Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:04:29 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 2ee0cdc8..5e87cbf5 100644 --- a/README.md +++ b/README.md @@ -218,4 +218,3 @@ Trims trailing whitespace. If you'd like to use these hooks, they're also available as a standalone package. Simply `pip install pre-commit-hooks` -