3232# pushing anything. Information about why the push is rejected may be sent to
3333# the user by writing to standard error.
3434#
35+ # The policy this hook applies is defined in the edgpolicy library, which the
36+ # policy CI workflow also uses. This hook exists to give fast local feedback;
37+ # CI is what actually enforces the policy.
38+ #
3539
36- import json
37- import os
3840import platform
39- import re
40- import shutil
4141import subprocess
4242import sys
4343import tempfile
4444
45+ import edgpolicy
4546import edgutil
4647
4748from pathlib import Path
7172 # On Unix-like systems we can regain access to terminal input using /dev/tty.
7273 sys .stdin = open ('/dev/tty' )
7374
74-
7575#
76- # File paths that begin with the prefixes listed here are excluded from file
77- # level checking.
76+ # How each kind of finding reported by edgpolicy is introduced to the user.
7877#
79- EXCLUDES_PATH_PREFIXES = [
80- # Don't check tests as they're not necessarily "EDG-style".
81- 'tests/' ,
82- # Don't check the generated builtin files as they're very large and there's
83- # really nothing the user can do but accept them.
84- 'dev_tools/builtins/builtins_' ,
85- # Don't check GitHub workflow yml files; long lines are all but required.
86- '.github/workflows/' ,
87- # Don't check the error_msg.txt file.
88- 'src/error_msg.txt' ,
89- # Don't check AGENT files.
90- 'AGENTS.md' ,
91- '.agents/'
92- ]
93-
94- def _is_excluded_file_path (path_str : str ) -> bool :
95- return any (
96- path_str .startswith (prefix ) for prefix in EXCLUDES_PATH_PREFIXES
97- )
98-
99- COMMIT_SUBJECT_POLICY_REGEX = re .compile (
100- r'^.*\[(GH #[0-9]+|(EDG[cfjp]+fe/([0-9]+))|,)*\].*$'
101- )
102-
103- TOOL_PATH_GIT = shutil .which ('git' )
104-
105- def run_git (args , * , check = True , text = True ):
106- '''Run a git command and return the completed process.'''
107- return subprocess .run (
108- [TOOL_PATH_GIT , * args ],
109- check = check ,
110- capture_output = True ,
111- text = text
112- )
113-
114- def git_output (args ):
115- '''Run a git command and return stripped stdout.'''
116- return run_git (args ).stdout .strip ()
78+ FINDING_KIND_HEADINGS = {
79+ 'misspelling' : 'New misspelled words:' ,
80+ 'typo' : 'New typos:' ,
81+ 'coding-error' : 'Coding errors:' ,
82+ 'overlong-line' : 'Lines too long:'
83+ }
11784
11885def print_indented (text , prefix ):
11986 '''Print each line of text with the given prefix.'''
@@ -123,20 +90,12 @@ def print_indented(text, prefix):
12390 for line in text .splitlines ():
12491 print (f"{ prefix } { line } " )
12592
126- def git_show_blob (revision_path : str ) -> bytes :
127- '''Return the blob contents for revision:path, or b'' if missing.'''
128- completed_process = run_git (
129- ['show' , revision_path ], check = False , text = False
130- )
131- if completed_process .returncode != 0 :
132- return b''
133- return completed_process .stdout
134-
13593class PrePushChecker :
13694 '''Runs EDG pre-push policy checks against new commits and changed files.'''
13795
13896 def __init__ (self ):
13997 self .errors = 0
98+ self .repository = edgpolicy .GitRepository (edgutil .find_mono_repo_or_exit ())
14099
141100 def confirm_okay (self ):
142101 '''Ask the user whether a policy warning is acceptable.
@@ -176,165 +135,92 @@ def check_commits(self):
176135 As commits are walked, check to make sure they match formatting guidance.
177136
178137 Returns the oldest new commit hash, or None if there are no new commits.
179- Exits immediately if a commit subject fails the PR-number policy.
138+ A commit subject that fails the policy aborts the push outright when
139+ edgpolicy says the rule is binding, and is otherwise offered for
140+ confirmation like any other finding.
180141 '''
181142 first_new_commit = None
182143
183- commits = git_output (['log' , '--pretty=%H' ]). splitlines ( )
144+ commits = self . repository . lines (['log' , '--pretty=%H' ])
184145 for commit in commits :
185146 # Break if the remote already has this commit.
186- contains = run_git (
147+ contains = self . repository . run (
187148 ['branch' , '-r' , '--contains' , commit ], check = False
188149 ).stdout .strip ()
189150 if contains :
190151 break
191152
192153 print ('New commit:' )
193154 print ('' )
194- show_output = git_output (['show' , '--no-patch' , commit ])
155+ show_output = self . repository . output (['show' , '--no-patch' , commit ])
195156 print_indented (show_output , ' ' )
196157 print ('' )
197158
198- subject = git_output (['show' , '--no-patch' , '--format=%s' , commit ])
199- if COMMIT_SUBJECT_POLICY_REGEX .match (subject ) is None :
200- print (
201- " [POLICY] Commit message doesn't contain valid PR number (or [])."
202- )
203- sys .exit (1 )
159+ subject_error = edgpolicy .commit_subject_error (
160+ self .repository .subject_of (commit )
161+ )
162+ if subject_error is not None :
163+ print (f" [POLICY] { subject_error } " )
164+ print_indented (edgpolicy .SUBJECT_POLICY_DESCRIPTION , ' ' )
165+ if edgpolicy .ENFORCE_COMMIT_SUBJECT_POLICY :
166+ sys .exit (1 )
167+ self .confirm_okay ()
204168
205169 first_new_commit = commit
206170
207171 return first_new_commit
208172
173+ def report_findings (self , findings ):
174+ '''Print findings grouped by kind, confirming once per group.'''
175+ for kind , heading in FINDING_KIND_HEADINGS .items ():
176+ messages = [
177+ message for finding_kind , message in findings
178+ if finding_kind == kind
179+ ]
180+ if not messages :
181+ continue
182+
183+ print (f" { heading } " )
184+ for message in messages :
185+ print (f" - { message } " )
186+ self .confirm_okay ()
187+
209188 def check_changed_files (self , first_new_commit ):
210189 '''Run per-file policy checks for files changed since
211190 first_new_commit~1.'''
212- remote_head = f"{ first_new_commit } ~1"
191+ base_rev = f"{ first_new_commit } ~1"
213192 print ('Checking changed files:' )
214193
215- changed_files = git_output (
216- ['diff' , '--no-commit-id' , '--name-only' , '-r' , f"{ remote_head } ..HEAD" ]
217- ).splitlines ()
218-
219- mono_repo_dir = edgutil .find_mono_repo_or_exit ()
220- containing_tmp_dir = mono_repo_dir / '.tmp'
221-
222- with tempfile .TemporaryDirectory (dir = containing_tmp_dir ) as tmp_dir_str :
223- input_spec = {}
224- for file_path_str in changed_files :
225- if _is_excluded_file_path (file_path_str ):
226- continue
227-
228- file_path = Path (file_path_str )
229- named_tmp_args = {
230- 'prefix' : file_path .stem ,
231- 'suffix' : file_path .suffix ,
232- 'dir' : tmp_dir_str ,
233- 'delete' : False
234- }
235- with tempfile .NamedTemporaryFile (** named_tmp_args ) as file_handle :
236- file_handle .write (git_show_blob (f"HEAD:{ file_path_str } " ))
237- new_file = (
238- Path (file_handle .name ).relative_to (mono_repo_dir ).as_posix ()
239- )
240- with tempfile .NamedTemporaryFile (** named_tmp_args ) as file_handle :
241- file_handle .write (git_show_blob (f"{ remote_head } :{ file_path_str } " ))
242- old_file = (
243- Path (file_handle .name ).relative_to (mono_repo_dir ).as_posix ()
244- )
245-
246- input_spec [file_path_str ] = [old_file , new_file ]
194+ containing_tmp_dir = self .repository .repo_dir / '.tmp'
195+ containing_tmp_dir .mkdir (exist_ok = True )
247196
197+ with tempfile .TemporaryDirectory (
198+ dir = str (containing_tmp_dir )
199+ ) as tmp_dir_str :
248200 tmp_dir = Path (tmp_dir_str )
249- input_file = tmp_dir / 'input.json'
250- output_file = tmp_dir / 'output.json'
251- with open (input_file , 'w' ) as file_handle :
252- json .dump (input_spec , file_handle )
253-
254- use_docker = (
255- shutil .which ('docker' ) is not None and
256- edgutil .is_docker_preferred (mono_repo_dir )
201+
202+ change_spec = edgpolicy .collect_change_spec (
203+ self .repository , base_rev , 'HEAD' , tmp_dir
257204 )
258- if use_docker :
259- file_change_check_proc = subprocess .run (
260- [
261- shutil .which ('edg-exec' ),
262- '--' ,
263- 'edg-check-file-changes' ,
264- str (input_file .relative_to (mono_repo_dir ).as_posix ()),
265- str (output_file .relative_to (mono_repo_dir ).as_posix ())
266- ],
267- cwd = mono_repo_dir ,
268- stdin = subprocess .DEVNULL
269- )
270- else :
271- file_change_check_proc = subprocess .run (
272- [
273- shutil .which ('edg-check-file-changes' ),
274- str (input_file ),
275- str (output_file )
276- ],
277- cwd = mono_repo_dir ,
278- stdin = subprocess .DEVNULL
279- )
280-
281- if file_change_check_proc .returncode != 0 :
282- sys .exit (1 )
283205
284- with open (output_file , 'r' ) as file_handle :
285- results = json .load (file_handle )
206+ use_docker = edgpolicy .should_use_docker (self .repository .repo_dir )
207+ results = edgpolicy .run_file_checks (
208+ self .repository , change_spec , tmp_dir , use_docker = use_docker
209+ )
210+ if results is None :
211+ sys .exit (1 )
286212
287- for file_path_str , file_results in results . items ( ):
213+ for file_path_str in sorted ( results ):
288214 print (f" Checking file: { file_path_str } " )
289215
290- misspellings = file_results [ 'misspellings' ]
291- if misspellings is None :
216+ findings = edgpolicy . summarize_file_result ( results [ file_path_str ])
217+ if any ( kind == 'spelling-unavailable' for kind , _ in findings ) :
292218 print (' Spell check failed:' )
293219 if not use_docker :
294220 print (' docker not used.' )
295221 print (' aspell not found or missing dictionary.' )
296- elif len (misspellings ) != 0 :
297- print (' New misspelled words:' )
298- for misspelling in misspellings :
299- print (f" - { misspelling } " )
300- self .confirm_okay ()
301-
302- typos = file_results ['typos' ]
303- if len (typos ) != 0 :
304- print (' New typos:' )
305- for typo in typos :
306- print (f" - { typo } " )
307- self .confirm_okay ()
308-
309- coding_errors = file_results ['coding_errors' ]
310- if len (coding_errors ) != 0 :
311- endif_hits = coding_errors ['missing_endifs' ]
312- if len (endif_hits ) != 0 :
313- print (f" Missing comment on #endif:" )
314- print_indented ('\n ' .join (endif_hits ), ' ' )
315- self .confirm_okay ()
316-
317- else_hits = coding_errors ['missing_elses' ]
318- if len (else_hits ) != 0 :
319- print (f" Missing comment on #else:" )
320- print_indented ('\n ' .join (else_hits ), ' ' )
321- self .confirm_okay ()
322-
323- closing_comments = coding_errors ['closing_comments' ]
324- if len (closing_comments ) != 0 :
325- print_indented ('\n ' .join (closing_comments ), ' ' )
326- self .confirm_okay ()
327-
328- overlong_lines = file_results ['overlong_lines' ]
329- if len (overlong_lines ) != 0 :
330- for line_no , (line_start , lines ) in overlong_lines .items ():
331- print (f" Line { line_no } too long:" )
332- line_end = line_start + (len (lines ) - 1 )
333- print (f" { line_start } -> { line_end } " .center (79 , '=' ))
334- for line in lines :
335- print (line , end = '' )
336- print ('=' * 79 )
337- self .confirm_okay ()
222+
223+ self .report_findings (findings )
338224
339225 def run (self ) -> bool :
340226 '''Execute the full pre-push check sequence.'''
0 commit comments