1+ #!/usr/bin/env python3
2+ """Git commit-msg hook.
3+
4+ Reads the activity (MarkAPI-NNNN) from the branch name -- anywhere in the
5+ name -- and:
6+
7+ 1. validates the message format: <type>(<optional scope>): <message>
8+ allowed types: feature, fix, chore, unittest
9+ 2. injects the activity prefix into the first line: [MarkAPI-NNNN] ...
10+
11+ Automatic Git commits (merge/revert/squash) are skipped.
12+ Uses the standard library only for portability across Linux and macOS.
13+ """
14+
15+ import re
16+ import subprocess
17+ import sys
18+
19+ ALLOWED_TYPES = ("feature" , "fix" , "chore" , "unittest" )
20+
21+ BRANCH_ACTIVITY_RE = re .compile (r"MarkupAPI-(\d+)" , re .IGNORECASE )
22+ EXISTING_PREFIX_RE = re .compile (r"^\s*\[MarkupAPI-\d+\]\s*" , re .IGNORECASE )
23+ MESSAGE_FORMAT_RE = re .compile (
24+ r"^(?:%s)(?:\([a-z0-9-]+\))?: .+" % "|" .join (ALLOWED_TYPES )
25+ )
26+
27+ # Automatic commits generated by Git that should pass without validation.
28+ MarkAPI_COMMIT_PREFIXES = (
29+ "Merge branch " ,
30+ "Merge pull request " ,
31+ "Merge remote-tracking " ,
32+ 'Revert "' ,
33+ )
34+ SQUASH_MERGE_RE = re .compile (r"^Merge .+ into " )
35+
36+
37+ def fail (message ):
38+ print (message , file = sys .stderr )
39+ sys .exit (1 )
40+
41+
42+ def is_auto_commit (first_line ):
43+ if first_line .startswith (MarkAPI_COMMIT_PREFIXES ):
44+ return True
45+ return bool (SQUASH_MERGE_RE .match (first_line ))
46+
47+
48+ def current_branch ():
49+ # symbolic-ref works even on the first commit (branch with no commits),
50+ # where rev-parse --abbrev-ref HEAD fails. On a detached HEAD,
51+ # symbolic-ref returns a non-zero code and we fall back to rev-parse
52+ # (which returns "HEAD").
53+ for cmd in (
54+ ["git" , "symbolic-ref" , "--short" , "HEAD" ],
55+ ["git" , "rev-parse" , "--abbrev-ref" , "HEAD" ],
56+ ):
57+ try :
58+ result = subprocess .run (cmd , capture_output = True , text = True )
59+ except OSError :
60+ return None
61+ if result .returncode == 0 :
62+ branch = result .stdout .strip ()
63+ if branch :
64+ return branch
65+ return None
66+
67+
68+ def main ():
69+ if len (sys .argv ) < 2 :
70+ fail ("⌠commit-msg hook: message file path not provided." )
71+
72+ msg_path = sys .argv [1 ]
73+ with open (msg_path , "r" , encoding = "utf-8" ) as handle :
74+ lines = handle .read ().splitlines ()
75+
76+ if not lines :
77+ fail ("⌠Empty commit message." )
78+
79+ first_line = lines [0 ].rstrip ("\r " )
80+
81+ # Let automatic Git commits pass through.
82+ if is_auto_commit (first_line ):
83+ sys .exit (0 )
84+
85+ branch = current_branch ()
86+ if not branch or branch == "HEAD" :
87+ fail (
88+ "⌠Could not determine the current branch (detached HEAD?).\n "
89+ "The branch must contain the activity MarkAPI-NNNN."
90+ )
91+
92+ match = BRANCH_ACTIVITY_RE .search (branch )
93+ if not match :
94+ fail (
95+ "⌠Branch without an activity.\n "
96+ "The branch name must contain MarkAPI-NNNN "
97+ "(e.g. feature/MarkAPI-1234/my-feature).\n "
98+ "Current branch: %s" % branch
99+ )
100+
101+ activity = "MarkAPI-%s" % match .group (1 )
102+
103+ # Strip an existing prefix to avoid duplication (amend/rebase/reword).
104+ body = EXISTING_PREFIX_RE .sub ("" , first_line ).strip ()
105+
106+ if not MESSAGE_FORMAT_RE .match (body ):
107+ fail (
108+ "⌠Invalid commit message.\n "
109+ "Expected format: <type>(<optional scope>): <message>\n "
110+ "Allowed types: %s\n "
111+ "Examples:\n "
112+ " chore: format readme\n "
113+ " fix(auth): fix authentication when token is invalid"
114+ % ", " .join (ALLOWED_TYPES )
115+ )
116+
117+ lines [0 ] = "[%s] %s" % (activity , body )
118+
119+ with open (msg_path , "w" , encoding = "utf-8" ) as handle :
120+ handle .write ("\n " .join (lines ) + "\n " )
121+
122+ sys .exit (0 )
123+
124+
125+ if __name__ == "__main__" :
126+ main ()
0 commit comments