Skip to content

Commit 9c6d7dd

Browse files
committed
Enforce commit and file policy in CI rather than only at push time
1 parent e3e8268 commit 9c6d7dd

11 files changed

Lines changed: 964 additions & 177 deletions

File tree

‎.git-hooks/pre-push.py‎

Lines changed: 63 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,17 @@
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
3840
import platform
39-
import re
40-
import shutil
4141
import subprocess
4242
import sys
4343
import tempfile
4444

45+
import edgpolicy
4546
import edgutil
4647

4748
from pathlib import Path
@@ -71,49 +72,15 @@
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

11885
def 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-
13593
class 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.'''

‎.github/CODEOWNERS‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM
2+
# Exceptions.
3+
# See https://edgcpp.org/LICENSE.txt for license information.
4+
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
5+
6+
# Code owners are requested for review automatically. They are only
7+
# *required* to approve when the branch ruleset enables "Require review from
8+
# Code Owners"; see .github/rulesets/README.md.
9+
#
10+
# Teams must have write access to the repository to be assignable here.
11+
# Until the owning teams exist, every entry below is commented out so that
12+
# GitHub does not report an invalid CODEOWNERS file.
13+
14+
# Fallback owner for anything not matched below.
15+
# * @edgcpp/maintainers
16+
17+
# Front end sources and the headers they publish.
18+
# /src/ @edgcpp/front-end-maintainers
19+
# /include_c++/ @edgcpp/front-end-maintainers
20+
# /include_c99/ @edgcpp/front-end-maintainers
21+
# /lib_src/ @edgcpp/front-end-maintainers
22+
23+
# Developer tooling, the test harness, and the build system.
24+
# /dev_tools/ @edgcpp/tooling-maintainers
25+
# /cmake/ @edgcpp/tooling-maintainers
26+
# /CMakeLists.txt @edgcpp/tooling-maintainers
27+
# /CMakePresets.json @edgcpp/tooling-maintainers
28+
29+
# Project policy and automation. Changes here alter what CI enforces, so
30+
# these paths warrant the closest review.
31+
# /.github/ @edgcpp/maintainers
32+
# /.git-hooks/ @edgcpp/maintainers

0 commit comments

Comments
 (0)