@@ -858,7 +858,19 @@ def check_commit_msg(message, files, repo):
858858 if re .match (r'^ccdc-opensource/' , repo ):
859859 # Do not check for JIRA in opensource repo as we don't want to require external contributors to do this
860860 return 0
861-
861+
862+
863+ # Check for Conventional Commits compliance.
864+ # Opt-in per repo: commit an empty marker file named
865+ # `.conventional-commits` at the repo root.
866+ if _conventional_commits_enabled ():
867+ if not conventional_commit_present (message ):
868+ _fail ('Commit message does not follow the Angular Conventional '
869+ 'Commits standard.\n '
870+ 'Expected: <type>(<scope>)?: <subject>\n '
871+ 'See https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md' )
872+ return 1
873+
862874 if (
863875 NO_JIRA_MARKER not in message
864876 and copilot_autofix_coauthor_pattern .search (message ) is None
@@ -884,6 +896,32 @@ def check_commit_msg(message, files, repo):
884896jira_id_pattern = re .compile (r'\b[A-Z]{2,8}-[0-9]{1,5}\b' )
885897
886898
899+
900+ def _conventional_commits_enabled ():
901+ '''Return True if the repo opts in to Conventional Commits enforcement.
902+
903+ Opt-in is signalled by a `.conventional-commits` file at the repo root.
904+ '''
905+ repo_root = _get_output (['git' , 'rev-parse' , '--show-toplevel' ]).strip ()
906+ return (Path (repo_root ) / '.conventional-commits' ).is_file ()
907+
908+
909+ def conventional_commit_present (message ):
910+ '''Return True if the commit message follows the Angular Conventional Commits standard.'''
911+ # Angular Conventional Commits header: type(scope?): subject
912+ # Allowed types from @commitlint/config-angular:
913+ # build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test
914+ # Note: Angular does not use the `!` breaking-change marker in the header;
915+ pattern = re .compile (
916+ r'^(BREAKING CHANGE|feat|fix|refactor|build|chore|ci|docs|perf|revert|style|test)' # type
917+ r'(\([\w\-\.\/]+\))?' # optional scope
918+ r': ' # required ": "
919+ r'.+' # subject
920+ )
921+ first_line = message .split ('\n ' , 1 )[0 ]
922+ return pattern .match (first_line ) is not None
923+
924+
887925class TestJiraIDPattern (unittest .TestCase ):
888926 def test_various_strings (self ):
889927 def _test (input , is_jira = True ):
0 commit comments