| #!/usr/bin/env python3 |
| # Copyright 2024 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Does presubmit checks for Rust-like changes. |
| |
| This is intended to be used by `repo`. For more information, please see |
| https://chromium.googlesource.com/chromiumos/repohooks/+/refs/heads/main/README.md. |
| |
| Enforces that all Rust toolchain packages, as well as the Rust virtual, are at |
| the same ${PVR}. This does not apply to rust-bootstrap. |
| """ |
| |
| import dataclasses |
| import os |
| import re |
| import subprocess |
| import sys |
| import textwrap |
| from typing import List, Optional |
| |
| |
| _ALL_RUST_DIRS = ("virtual/rust/", "dev-lang/rust/", "dev-lang/rust-host/") |
| _CROS_RUSTC_ECLASS = "eclass/cros-rustc.eclass" |
| |
| _Complaint = str |
| |
| |
| @dataclasses.dataclass(frozen=True) |
| class CommitChanges: |
| """Contains information about the modifications performed by a commit.""" |
| |
| # A list of files that were only modified in-place; not deleted, created, |
| # renamed, etc. |
| files_only_modified: List[str] |
| # All files changed by a commit that aren't in `files_only_modified`. Note |
| # that renames will have an entry for _both_ the rename-from and rename-to |
| # locations. |
| all_other_changed_files: List[str] |
| |
| @property |
| def all_changed_files(self) -> List[str]: |
| return self.files_only_modified + self.all_other_changed_files |
| |
| @classmethod |
| def from_commit(cls, sha: str) -> "CommitChanges": |
| # This command outputs a line-separated list of files changed by a |
| # commit, in the form: |
| # operation file_name (new_file_name)? |
| # |
| # e.g., |
| # M dev-lang/rust/presubmit_check.py |
| # R100 dev-lang/rust/foo dev-lang/rust/foo2 |
| output = subprocess.check_output( |
| [ |
| "git", |
| "show", |
| "--name-status", |
| sha, |
| "--format=", |
| # Only report Added, Deleted, Copied, Modified, Renamed, and |
| # Type-Changed files. Other types seem to have git-inter |
| "--diff-filter=ADMRT", |
| ], |
| encoding="utf-8", |
| ) |
| |
| output_re = re.compile(r"^([ADMRT])\d*\s+(\S+)(?:\s+(\S+))?$") |
| all_other_changed_files = [] |
| files_only_modified = [] |
| for line in output.splitlines(): |
| line = line.strip() |
| if not line: |
| continue |
| m = output_re.match(line) |
| assert m, f"Unexpectedly failed to match {line!r} with {output_re}" |
| mod_type, file1, maybe_file2 = m.groups() |
| if mod_type == "R": |
| assert maybe_file2, f"No second file for a rename? Line: {line}" |
| all_other_changed_files += (file1, maybe_file2) |
| continue |
| assert not maybe_file2, ( |
| f"Second file unexpectedly present for {mod_type} operation. " |
| f"Line: {line}" |
| ) |
| if mod_type == "M": |
| files_only_modified.append(file1) |
| else: |
| all_other_changed_files.append(file1) |
| return cls( |
| all_other_changed_files=all_other_changed_files, |
| files_only_modified=files_only_modified, |
| ) |
| |
| |
| def _will_change_to_file_cause_revbump( |
| file: str, package_directory: str |
| ) -> bool: |
| """Returns whether changes to `file` cause `package` to be revbumped.""" |
| file_in_package = os.path.relpath(file, package_directory) |
| if file_in_package.startswith("../"): |
| return False |
| return ( |
| file_in_package == "Manifest" |
| or file_in_package.startswith("files/") |
| or file_in_package.endswith("-9999.ebuild") |
| ) |
| |
| |
| def _ensure_cros_rustc_changes_have_revbumps( |
| changes: CommitChanges, |
| ) -> Optional[_Complaint]: |
| all_changed_files = changes.all_changed_files |
| if not any(x == _CROS_RUSTC_ECLASS for x in all_changed_files): |
| return |
| |
| for file in all_changed_files: |
| if _will_change_to_file_cause_revbump( |
| file, "dev-lang/rust/" |
| ) or _will_change_to_file_cause_revbump(file, "dev-lang/rust-host/"): |
| # Just `return None` here, since something, somewhere will |
| # cause a revbump to a Rust package. The 'lockstep' check later |
| # ensures that all other packages are revbumped properly. |
| return None |
| |
| return textwrap.dedent( |
| f"""\ |
| {_CROS_RUSTC_ECLASS} was changed, but no corresponding cros-rustc users |
| were changed in a way that will cause them to be uprevved by Annealing. |
| If this is intended, pass `--ignore-hooks` or `--no-verify` to `repo`. |
| Otherwise, please update `files/revision_bump` in dev-lang/rust and |
| dev-lang/rust-host. |
| """ |
| ) |
| |
| |
| def _ensure_rust_and_rust_host_are_in_lockstep( |
| changes: CommitChanges, |
| ) -> Optional[_Complaint]: |
| has_rust_change = False |
| has_rust_host_change = False |
| for file in changes.all_changed_files: |
| if _will_change_to_file_cause_revbump(file, "dev-lang/rust/"): |
| has_rust_change = True |
| if has_rust_host_change: |
| break |
| elif _will_change_to_file_cause_revbump(file, "dev-lang/rust-host/"): |
| has_rust_host_change = True |
| if has_rust_change: |
| break |
| |
| if has_rust_change == has_rust_host_change: |
| return None |
| |
| if has_rust_change: |
| package_with_changes = "rust" |
| package_missing_changes = "rust-host" |
| else: |
| package_missing_changes = "rust" |
| package_with_changes = "rust-host" |
| |
| return textwrap.dedent( |
| f"""\ |
| Change(s) in dev-lang/{package_with_changes} detected, but no change is |
| in dev-lang/{package_missing_changes}. This can lead to rust and |
| rust-host becoming desynced, which will break your CQ+1 run. If you'd |
| like to force an uprev of dev-lang/{package_missing_changes}, you can |
| update dev-lang/{package_missing_changes}/files/revision_bump. |
| """ |
| ) |
| |
| |
| def _ensure_no_virtual_rust_revbumps( |
| changes: CommitChanges, |
| ) -> Optional[_Complaint]: |
| """Complains if the commit modifies ebuild names in virtual/rust.""" |
| virtual_rust_ebuild = re.compile(r"virtual/rust/.*\.ebuild") |
| # Ignore simple modifications of virtual/rust. It's OK to change it in ways |
| # that don't involve bringing it out of sync with the other Rust packages. |
| has_potential_revbumps = any( |
| virtual_rust_ebuild.match(x) for x in changes.all_other_changed_files |
| ) |
| if not has_potential_revbumps: |
| return None |
| |
| return textwrap.dedent( |
| """\ |
| Creations/deletions/renames to ebuilds in virtual/rust detected. The |
| SDK builder maintains this file; it should not be uprevved by humans. |
| Bypass this check if you're certain you want to do this. |
| """ |
| ) |
| |
| |
| def _is_rusty_file(file_path: str) -> bool: |
| """Returns whether the param is in a rust dir we should check.""" |
| return file_path == _CROS_RUSTC_ECLASS or any( |
| file_path.startswith(x) for x in _ALL_RUST_DIRS |
| ) |
| |
| |
| def main(): |
| """Main function.""" |
| presubmit_files = os.environ.get("PRESUBMIT_FILES") |
| if presubmit_files is None: |
| sys.exit("Need a value for PRESUBMIT_FILES") |
| |
| # Skip this for CLs that don't touch Rust. It's faster, and if this is |
| # broken at ToT somehow, we don't want to halt all uploads on unrelated |
| # CLs. |
| presubmit_files = [x.strip() for x in presubmit_files.splitlines()] |
| if not any(_is_rusty_file(x) for x in presubmit_files): |
| return |
| |
| presubmit_commit = os.environ.get("PRESUBMIT_COMMIT") |
| if presubmit_commit is None: |
| sys.exit("Need a value for PRESUBMIT_COMMIT") |
| |
| changes = CommitChanges.from_commit(presubmit_commit) |
| checks = ( |
| _ensure_no_virtual_rust_revbumps, |
| _ensure_rust_and_rust_host_are_in_lockstep, |
| _ensure_cros_rustc_changes_have_revbumps, |
| ) |
| |
| had_complaint = False |
| for check in checks: |
| if c := check(changes): |
| had_complaint = True |
| print(f"error: {c}", file=sys.stderr) |
| sys.exit(1 if had_complaint else 0) |
| |
| |
| if __name__ == "__main__": |
| main() |