Skip to content

Commit bdf3e06

Browse files
committed
feat: support GPG signature check
1 parent 2491296 commit bdf3e06

File tree

5 files changed

+269
-17
lines changed

5 files changed

+269
-17
lines changed

commit_check/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
'error': 'Signed-off-by not found in latest commit',
4949
'suggest': 'run command `git commit -m "conventional commit message" --signoff`',
5050
},
51+
{
52+
'check': 'gpg_signature',
53+
'regex': r'', # Not used for GPG signature check
54+
'error': 'Commit does not have a valid GPG signature',
55+
'suggest': 'run command `git commit -S` to sign your commits with GPG',
56+
},
5157
{
5258
'check': 'merge_base',
5359
'regex': r'main', # it can be master, develop, devel etc based on your project.

commit_check/commit.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,69 @@ def check_commit_signoff(checks: list, commit_msg_file: str = "") -> int:
9292
return PASS
9393

9494

95+
def check_commit_gpg_signature(checks: list) -> int:
96+
"""Check if commit has a valid GPG signature."""
97+
if has_commits() is False:
98+
return PASS # pragma: no cover
99+
100+
for check in checks:
101+
if check['check'] == 'gpg_signature':
102+
# Get GPG signature status using git log --pretty=format:"%G?"
103+
try:
104+
gpg_status = cmd_output(['git', 'log', '--pretty=format:%G?', '-1']).strip()
105+
commit_hash = get_commit_info("H")
106+
107+
# GPG status codes:
108+
# G = good (valid) signature
109+
# B = bad signature
110+
# U = good signature with unknown validity
111+
# X = good signature that has expired
112+
# Y = good signature made by an expired key
113+
# R = good signature made by a revoked key
114+
# E = signature cannot be checked (e.g., missing public key)
115+
# N = no signature
116+
117+
if gpg_status not in ['G', 'U']: # Only accept good signatures
118+
if not print_error_header.has_been_called:
119+
print_error_header() # pragma: no cover
120+
121+
error_msg = check['error']
122+
if gpg_status == 'N':
123+
error_msg = 'Commit is not signed with GPG'
124+
elif gpg_status == 'B':
125+
error_msg = 'Commit has a bad GPG signature'
126+
elif gpg_status == 'E':
127+
error_msg = 'GPG signature cannot be verified (missing public key?)'
128+
elif gpg_status == 'X':
129+
error_msg = 'GPG signature has expired'
130+
elif gpg_status == 'Y':
131+
error_msg = 'GPG signature made by an expired key'
132+
elif gpg_status == 'R':
133+
error_msg = 'GPG signature made by a revoked key'
134+
135+
print_error_message(
136+
check['check'], f'GPG Status: {gpg_status}',
137+
error_msg, commit_hash,
138+
)
139+
if check['suggest']:
140+
print_suggestion(check['suggest'])
141+
return FAIL
142+
143+
except Exception:
144+
# If we can't check GPG status, treat as failure
145+
if not print_error_header.has_been_called:
146+
print_error_header() # pragma: no cover
147+
print_error_message(
148+
check['check'], 'Unknown',
149+
'Unable to check GPG signature status', get_commit_info("H"),
150+
)
151+
if check['suggest']:
152+
print_suggestion(check['suggest'])
153+
return FAIL
154+
155+
return PASS
156+
157+
95158
def check_imperative(checks: list, commit_msg_file: str = "") -> int:
96159
"""Check if commit message uses imperative mood."""
97160
if has_commits() is False:

commit_check/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ def get_parser() -> argparse.ArgumentParser:
7676
required=False,
7777
)
7878

79+
parser.add_argument(
80+
'-g',
81+
'--gpg-signature',
82+
help='check commit GPG signature',
83+
action="store_true",
84+
required=False,
85+
)
86+
7987
parser.add_argument(
8088
'-mb',
8189
'--merge-base',
@@ -128,6 +136,8 @@ def main() -> int:
128136
check_results.append(branch.check_branch(checks))
129137
if args.commit_signoff:
130138
check_results.append(commit.check_commit_signoff(checks))
139+
if args.gpg_signature:
140+
check_results.append(commit.check_commit_gpg_signature(checks))
131141
if args.merge_base:
132142
check_results.append(branch.check_merge_base(checks))
133143
if args.imperative:

tests/commit_test.py

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22
from commit_check import PASS, FAIL
3-
from commit_check.commit import check_commit_msg, get_default_commit_msg_file, read_commit_msg, check_commit_signoff, check_imperative
3+
from commit_check.commit import check_commit_msg, get_default_commit_msg_file, read_commit_msg, check_commit_signoff, check_commit_gpg_signature, check_imperative
44

55
# used by get_commit_info mock
66
FAKE_BRANCH_NAME = "fake_commits_info"
@@ -398,3 +398,168 @@ def test_is_imperative_invalid_cases():
398398

399399
for case in invalid_cases:
400400
assert not _is_imperative(case), f"'{case}' should not be imperative mood"
401+
402+
403+
# GPG Signature tests
404+
@pytest.mark.benchmark
405+
def test_check_commit_gpg_signature_valid(mocker):
406+
"""Test GPG signature check passes for good signature."""
407+
checks = [{
408+
"check": "gpg_signature",
409+
"regex": "",
410+
"error": "Commit does not have a valid GPG signature",
411+
"suggest": "Use git commit -S to sign commits"
412+
}]
413+
414+
mocker.patch("commit_check.util.cmd_output", return_value="G")
415+
mocker.patch("commit_check.util.get_commit_info", return_value="abc123")
416+
417+
retval = check_commit_gpg_signature(checks)
418+
assert retval == PASS
419+
420+
421+
@pytest.mark.benchmark
422+
def test_check_commit_gpg_signature_valid_unknown(mocker):
423+
"""Test GPG signature check passes for good signature with unknown validity."""
424+
checks = [{
425+
"check": "gpg_signature",
426+
"regex": "",
427+
"error": "Commit does not have a valid GPG signature",
428+
"suggest": "Use git commit -S to sign commits"
429+
}]
430+
431+
mocker.patch("commit_check.util.cmd_output", return_value="U")
432+
mocker.patch("commit_check.util.get_commit_info", return_value="abc123")
433+
434+
retval = check_commit_gpg_signature(checks)
435+
assert retval == PASS
436+
437+
438+
@pytest.mark.benchmark
439+
def test_check_commit_gpg_signature_no_signature(mocker):
440+
"""Test GPG signature check fails for unsigned commit."""
441+
checks = [{
442+
"check": "gpg_signature",
443+
"regex": "",
444+
"error": "Commit does not have a valid GPG signature",
445+
"suggest": "Use git commit -S to sign commits"
446+
}]
447+
448+
mocker.patch("commit_check.util.cmd_output", return_value="N")
449+
mocker.patch("commit_check.util.get_commit_info", return_value="abc123")
450+
m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message")
451+
m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion")
452+
453+
retval = check_commit_gpg_signature(checks)
454+
assert retval == FAIL
455+
assert m_print_error_message.call_count == 1
456+
assert m_print_suggestion.call_count == 1
457+
458+
459+
@pytest.mark.benchmark
460+
def test_check_commit_gpg_signature_bad_signature(mocker):
461+
"""Test GPG signature check fails for bad signature."""
462+
checks = [{
463+
"check": "gpg_signature",
464+
"regex": "",
465+
"error": "Commit does not have a valid GPG signature",
466+
"suggest": "Use git commit -S to sign commits"
467+
}]
468+
469+
mocker.patch("commit_check.util.cmd_output", return_value="B")
470+
mocker.patch("commit_check.util.get_commit_info", return_value="abc123")
471+
m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message")
472+
m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion")
473+
474+
retval = check_commit_gpg_signature(checks)
475+
assert retval == FAIL
476+
assert m_print_error_message.call_count == 1
477+
assert m_print_suggestion.call_count == 1
478+
479+
480+
@pytest.mark.benchmark
481+
def test_check_commit_gpg_signature_expired(mocker):
482+
"""Test GPG signature check fails for expired signature."""
483+
checks = [{
484+
"check": "gpg_signature",
485+
"regex": "",
486+
"error": "Commit does not have a valid GPG signature",
487+
"suggest": "Use git commit -S to sign commits"
488+
}]
489+
490+
mocker.patch("commit_check.util.cmd_output", return_value="X")
491+
mocker.patch("commit_check.util.get_commit_info", return_value="abc123")
492+
m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message")
493+
m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion")
494+
495+
retval = check_commit_gpg_signature(checks)
496+
assert retval == FAIL
497+
assert m_print_error_message.call_count == 1
498+
assert m_print_suggestion.call_count == 1
499+
500+
501+
@pytest.mark.benchmark
502+
def test_check_commit_gpg_signature_cannot_check(mocker):
503+
"""Test GPG signature check fails when signature cannot be verified."""
504+
checks = [{
505+
"check": "gpg_signature",
506+
"regex": "",
507+
"error": "Commit does not have a valid GPG signature",
508+
"suggest": "Use git commit -S to sign commits"
509+
}]
510+
511+
mocker.patch("commit_check.util.cmd_output", return_value="E")
512+
mocker.patch("commit_check.util.get_commit_info", return_value="abc123")
513+
m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message")
514+
m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion")
515+
516+
retval = check_commit_gpg_signature(checks)
517+
assert retval == FAIL
518+
assert m_print_error_message.call_count == 1
519+
assert m_print_suggestion.call_count == 1
520+
521+
522+
@pytest.mark.benchmark
523+
def test_check_commit_gpg_signature_exception(mocker):
524+
"""Test GPG signature check fails when git command throws exception."""
525+
checks = [{
526+
"check": "gpg_signature",
527+
"regex": "",
528+
"error": "Commit does not have a valid GPG signature",
529+
"suggest": "Use git commit -S to sign commits"
530+
}]
531+
532+
mocker.patch("commit_check.util.cmd_output", side_effect=Exception("Git command failed"))
533+
mocker.patch("commit_check.util.get_commit_info", return_value="abc123")
534+
m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message")
535+
m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion")
536+
537+
retval = check_commit_gpg_signature(checks)
538+
assert retval == FAIL
539+
assert m_print_error_message.call_count == 1
540+
assert m_print_suggestion.call_count == 1
541+
542+
543+
@pytest.mark.benchmark
544+
def test_check_commit_gpg_signature_no_commits(mocker):
545+
"""Test GPG signature check passes when no commits exist."""
546+
checks = [{
547+
"check": "gpg_signature",
548+
"regex": "",
549+
"error": "Commit does not have a valid GPG signature",
550+
"suggest": "Use git commit -S to sign commits"
551+
}]
552+
553+
mocker.patch("commit_check.commit.has_commits", return_value=False)
554+
555+
retval = check_commit_gpg_signature(checks)
556+
assert retval == PASS
557+
558+
559+
@pytest.mark.benchmark
560+
def test_check_commit_gpg_signature_empty_checks(mocker):
561+
"""Test GPG signature check with empty checks list."""
562+
checks = []
563+
564+
retval = check_commit_gpg_signature(checks)
565+
assert retval == PASS

tests/main_test.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,25 @@
88

99
class TestMain:
1010
@pytest.mark.benchmark
11-
@pytest.mark.parametrize("argv, check_commit_call_count, check_branch_call_count, check_author_call_count, check_commit_signoff_call_count, check_merge_base_call_count, check_imperative_call_count", [
12-
([CMD, "--message"], 1, 0, 0, 0, 0, 0),
13-
([CMD, "--branch"], 0, 1, 0, 0, 0, 0),
14-
([CMD, "--author-name"], 0, 0, 1, 0, 0, 0),
15-
([CMD, "--author-email"], 0, 0, 1, 0, 0, 0),
16-
([CMD, "--commit-signoff"], 0, 0, 0, 1, 0, 0),
17-
([CMD, "--merge-base"], 0, 0, 0, 0, 1, 0),
18-
([CMD, "--imperative"], 0, 0, 0, 0, 0, 1),
19-
([CMD, "--message", "--author-email"], 1, 0, 1, 0, 0, 0),
20-
([CMD, "--branch", "--message"], 1, 1, 0, 0, 0, 0),
21-
([CMD, "--author-name", "--author-email"], 0, 0, 2, 0, 0, 0),
22-
([CMD, "--message", "--branch", "--author-email"], 1, 1, 1, 0, 0, 0),
23-
([CMD, "--branch", "--message", "--author-name", "--author-email"], 1, 1, 2, 0, 0, 0),
24-
([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base"], 1, 1, 2, 1, 1, 0),
25-
([CMD, "--message", "--imperative"], 1, 0, 0, 0, 0, 1),
26-
([CMD, "--dry-run"], 0, 0, 0, 0, 0, 0),
11+
@pytest.mark.parametrize("argv, check_commit_call_count, check_branch_call_count, check_author_call_count, check_commit_signoff_call_count, check_gpg_signature_call_count, check_merge_base_call_count, check_imperative_call_count", [
12+
([CMD, "--message"], 1, 0, 0, 0, 0, 0, 0),
13+
([CMD, "--branch"], 0, 1, 0, 0, 0, 0, 0),
14+
([CMD, "--author-name"], 0, 0, 1, 0, 0, 0, 0),
15+
([CMD, "--author-email"], 0, 0, 1, 0, 0, 0, 0),
16+
([CMD, "--commit-signoff"], 0, 0, 0, 1, 0, 0, 0),
17+
([CMD, "--gpg-signature"], 0, 0, 0, 0, 1, 0, 0),
18+
([CMD, "--merge-base"], 0, 0, 0, 0, 0, 1, 0),
19+
([CMD, "--imperative"], 0, 0, 0, 0, 0, 0, 1),
20+
([CMD, "--message", "--author-email"], 1, 0, 1, 0, 0, 0, 0),
21+
([CMD, "--branch", "--message"], 1, 1, 0, 0, 0, 0, 0),
22+
([CMD, "--author-name", "--author-email"], 0, 0, 2, 0, 0, 0, 0),
23+
([CMD, "--message", "--branch", "--author-email"], 1, 1, 1, 0, 0, 0, 0),
24+
([CMD, "--branch", "--message", "--author-name", "--author-email"], 1, 1, 2, 0, 0, 0, 0),
25+
([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base"], 1, 1, 2, 1, 0, 1, 0),
26+
([CMD, "--message", "--gpg-signature"], 1, 0, 0, 0, 1, 0, 0),
27+
([CMD, "--commit-signoff", "--gpg-signature"], 0, 0, 0, 1, 1, 0, 0),
28+
([CMD, "--message", "--imperative"], 1, 0, 0, 0, 0, 0, 1),
29+
([CMD, "--dry-run"], 0, 0, 0, 0, 0, 0, 0),
2730
])
2831
def test_main(
2932
self,
@@ -33,6 +36,7 @@ def test_main(
3336
check_branch_call_count,
3437
check_author_call_count,
3538
check_commit_signoff_call_count,
39+
check_gpg_signature_call_count,
3640
check_merge_base_call_count,
3741
check_imperative_call_count,
3842
):
@@ -48,6 +52,7 @@ def test_main(
4852
m_check_branch = mocker.patch("commit_check.branch.check_branch")
4953
m_check_author = mocker.patch("commit_check.author.check_author")
5054
m_check_commit_signoff = mocker.patch("commit_check.commit.check_commit_signoff")
55+
m_check_gpg_signature = mocker.patch("commit_check.commit.check_commit_gpg_signature")
5156
m_check_merge_base = mocker.patch("commit_check.branch.check_merge_base")
5257
m_check_imperative = mocker.patch("commit_check.commit.check_imperative")
5358
sys.argv = argv
@@ -56,6 +61,7 @@ def test_main(
5661
assert m_check_branch.call_count == check_branch_call_count
5762
assert m_check_author.call_count == check_author_call_count
5863
assert m_check_commit_signoff.call_count == check_commit_signoff_call_count
64+
assert m_check_gpg_signature.call_count == check_gpg_signature_call_count
5965
assert m_check_merge_base.call_count == check_merge_base_call_count
6066
assert m_check_imperative.call_count == check_imperative_call_count
6167

@@ -73,6 +79,7 @@ def test_main_help(self, mocker, capfd):
7379
m_check_branch = mocker.patch("commit_check.branch.check_branch")
7480
m_check_author = mocker.patch("commit_check.author.check_author")
7581
m_check_commit_signoff = mocker.patch("commit_check.commit.check_commit_signoff")
82+
m_check_gpg_signature = mocker.patch("commit_check.commit.check_commit_gpg_signature")
7683
m_check_merge_base = mocker.patch("commit_check.branch.check_merge_base")
7784
sys.argv = ["commit-check", "--h"]
7885
with pytest.raises(SystemExit):
@@ -81,6 +88,7 @@ def test_main_help(self, mocker, capfd):
8188
assert m_check_branch.call_count == 0
8289
assert m_check_author.call_count == 0
8390
assert m_check_commit_signoff.call_count == 0
91+
assert m_check_gpg_signature.call_count == 0
8492
assert m_check_merge_base.call_count == 0
8593
stdout, _ = capfd.readouterr()
8694
assert "usage: " in stdout

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy