pre-upload.py: Add hook to run cargo clippy
Add a hook `check_cargo_clippy` to run cargo clippy in pre-upload.py.
This hook is opt-in and clippy runs only when files in specified Cargo
projects were modified.
BUG=chromium:1105466
TEST=repo upload a Rust CL
TEST=pre-upload_unittest.py
Change-Id: I6cd642457125d1f76c87b0f6db19a2c36ab28c95
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/repohooks/+/2297362
Reviewed-by: Mike Frysinger <vapier@chromium.org>
Commit-Queue: Keiichi Watanabe <keiichiw@chromium.org>
Tested-by: Keiichi Watanabe <keiichiw@chromium.org>
diff --git a/pre-upload.py b/pre-upload.py
index e22a032..ec1ce24 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -646,6 +646,108 @@
return None
+class CargoClippyArgumentParserError(Exception):
+ """An exception indicating an invalid check_cargo_clippy option."""
+
+
+class CargoClippyArgumentParser(argparse.ArgumentParser):
+ """A argument parser for check_cargo_clippy."""
+
+ def error(self, message):
+ raise CargoClippyArgumentParserError(message)
+
+
+# A cargo project in which clippy runs.
+ClippyProject = collections.namedtuple('ClippyProject', ('root', 'script'))
+
+
+class _AddClippyProjectAction(argparse.Action):
+ """A callback that adds a cargo clippy setting.
+
+ It accepts a value which is in the form of "ROOT[:SCRIPT]".
+ """
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ if getattr(namespace, self.dest, None) is None:
+ setattr(namespace, self.dest, [])
+ spec = values.split(':', 1)
+ if len(spec) == 1:
+ spec += [None]
+
+ if spec[0].startswith('/'):
+ raise CargoClippyArgumentParserError('root path must not start with "/"'
+ f' but "{spec[0]}"')
+
+ clippy = ClippyProject(root=spec[0], script=spec[1])
+ getattr(namespace, self.dest).append(clippy)
+
+
+def _get_cargo_clippy_parser():
+ """Creates a parser for check_cargo_clippy options."""
+
+ parser = CargoClippyArgumentParser()
+ parser.add_argument('--project', action=_AddClippyProjectAction, default=[])
+
+ return parser
+
+
+def _check_cargo_clippy(project, commit, options=()):
+ """Checks that a change doesn't produce cargo-clippy errors."""
+
+ options = list(options)
+ if not options:
+ return []
+ parser = _get_cargo_clippy_parser()
+
+ try:
+ opts = parser.parse_args(options)
+ except CargoClippyArgumentParserError as e:
+ return [HookFailure('invalid check_cargo_clippy option is given.'
+ f' Please check PRESUBMIT.cfg is correct: {e}')]
+ files = _get_affected_files(commit)
+
+ errors = []
+ for clippy in opts.project:
+ root = os.path.normpath(os.path.join(project.dir, clippy.root))
+
+ # Check if any file under `root` was modified.
+ modified = False
+ for f in files:
+ if f.startswith(root):
+ modified = True
+ break
+ if not modified:
+ continue
+
+ # Clean cargo's cache when we run clippy for this `root` for the first time.
+ # We don't want to clean the cache between commits to save time when
+ # multiple commits are checked.
+ if root not in _check_cargo_clippy.cleaned_root:
+ _run_command(['cargo', 'clean',
+ f'--manifest-path={root}/Cargo.toml'],
+ stderr=subprocess.STDOUT)
+ _check_cargo_clippy.cleaned_root.add(root)
+
+ cmd = ['cargo', 'clippy', '--all-features', '--all-targets',
+ f'--manifest-path={root}/Cargo.toml',
+ '--', '-D', 'warnings']
+ # Overwrite the clippy command if a project-specific script is specified.
+ if clippy.script:
+ cmd = [os.path.join(project.dir, clippy.script)]
+
+ output = _run_command(cmd, stderr=subprocess.STDOUT)
+ error = re.search(r'^error: ', output, flags=re.MULTILINE)
+ if error:
+ msg = output[error.start():]
+ errors.append(HookFailure(msg))
+
+ return errors
+
+
+# Stores cargo projects in which `cargo clean` ran.
+_check_cargo_clippy.cleaned_root = set()
+
+
def _check_change_has_test_field(_project, commit):
"""Check for a non-empty 'TEST=' field in the commit message."""
SEE_ALSO = 'Please review the documentation:\n%s' % (DOC_COMMIT_MSG_URL,)
@@ -1834,6 +1936,7 @@
# A list of hooks that are not project-specific
_COMMON_HOOKS = [
+ _check_cargo_clippy,
_check_cros_license,
_check_ebuild_eapi,
_check_ebuild_keywords,
@@ -1883,6 +1986,7 @@
'contribution_check': _check_change_is_contribution,
'project_prefix_check': _check_project_prefix,
'filepath_chartype_check': _check_filepath_chartype,
+ 'cargo_clippy_check': _check_cargo_clippy,
}
diff --git a/pre-upload_unittest.py b/pre-upload_unittest.py
index 71b4337..c3886ba 100755
--- a/pre-upload_unittest.py
+++ b/pre-upload_unittest.py
@@ -1703,5 +1703,90 @@
self.assertIsNone(failure)
+class GetCargoClippyParserTest(cros_test_lib.TestCase):
+ """Tests for _get_cargo_clippy_parser."""
+
+ def testSingleProject(self):
+ parser = pre_upload._get_cargo_clippy_parser()
+ args = parser.parse_args(['--project', 'foo'])
+ self.assertEqual(args.project,
+ [pre_upload.ClippyProject(root='foo', script=None)])
+
+ def testMultipleProjects(self):
+ parser = pre_upload._get_cargo_clippy_parser()
+ args = parser.parse_args(['--project', 'foo:bar',
+ '--project', 'baz'])
+ self.assertCountEqual(args.project,
+ [pre_upload.ClippyProject(root='foo', script='bar'),
+ pre_upload.ClippyProject(root='baz', script=None)])
+
+
+class CheckCargoClippyTest(PreUploadTestCase, cros_test_lib.TempDirTestCase):
+ """Tests for _check_cargo_clippy."""
+
+ def setUp(self):
+ self.project = pre_upload.Project(name='PROJECT', dir=self.tempdir,
+ remote=None)
+
+ def testClippy(self):
+ """Verify clippy is called when a monitored file was changed."""
+ rc_mock = self.PatchObject(pre_upload, '_run_command', return_value='')
+
+ self.PatchObject(pre_upload, '_get_affected_files',
+ return_value=[f'{self.project.dir}/repo_a/a.rs'])
+
+ ret = pre_upload._check_cargo_clippy(self.project, 'COMMIT',
+ options=['--project=repo_a',
+ '--project=repo_b:foo'])
+ self.assertFalse(ret)
+
+ # Check if `cargo clippy` ran.
+ called = False
+ for args, _ in rc_mock.call_args_list:
+ cmd = args[0]
+ if len(cmd) > 1 and cmd[0] == 'cargo' and cmd[1] == 'clippy':
+ called = True
+ break
+
+ self.assertTrue(called)
+
+ def testDontRun(self):
+ """Skip checks when no monitored files are modified."""
+ rc_mock = self.PatchObject(pre_upload, '_run_command', return_value='')
+
+ # A file under `repo_a` was monitored.
+ self.PatchObject(pre_upload, '_get_affected_files',
+ return_value=[f'{self.project.dir}/repo_a/a.rs'])
+ # But, we only care about files under `repo_b`.
+ errs = pre_upload._check_cargo_clippy(self.project, 'COMMIT',
+ options=['--project=repo_b:foo'])
+
+ self.assertFalse(errs)
+
+ rc_mock.assert_not_called()
+
+ def testCustomScript(self):
+ """Verify project-specific script is used."""
+ rc_mock = self.PatchObject(pre_upload, '_run_command', return_value='')
+
+ self.PatchObject(pre_upload, '_get_affected_files',
+ return_value=[f'{self.project.dir}/repo_b/b.rs'])
+
+ errs = pre_upload._check_cargo_clippy(self.project, 'COMMIT',
+ options=['--project=repo_a',
+ '--project=repo_b:foo'])
+ self.assertFalse(errs)
+
+ # Check if the script `foo` ran.
+ called = False
+ for args, _ in rc_mock.call_args_list:
+ cmd = args[0]
+ if len(cmd) > 0 and cmd[0] == os.path.join(self.project.dir, 'foo'):
+ called = True
+ break
+
+ self.assertTrue(called)
+
+
if __name__ == '__main__':
cros_test_lib.main(module=__name__)