pre-upload: Read .presubmitignore files.

Make pre-upload.py look for .presubmitignore files
containing lists of relative-path wildcards, one per line,
matching files that should be excluded from common presubmit
checks.

BUG=chromium:217286
TEST=manual: created a .presubmitignore file and uploaded a
     change to a powerd pref without getting nagged about a
     missing license

Change-Id: I3648e04ed840abd39d8a7fcc53fc7c65d27128ee
Reviewed-on: https://chromium-review.googlesource.com/249882
Reviewed-by: Dan Erat <derat@chromium.org>
Commit-Queue: Dan Erat <derat@chromium.org>
Trybot-Ready: Dan Erat <derat@chromium.org>
Tested-by: Dan Erat <derat@chromium.org>
diff --git a/pre-upload.py b/pre-upload.py
index 6ee09dd..4a077cd 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -11,6 +11,7 @@
 from __future__ import print_function
 
 import ConfigParser
+import fnmatch
 import functools
 import json
 import os
@@ -51,7 +52,7 @@
 
 
 COMMON_EXCLUDED_PATHS = [
-    # avoid doing source file checks for kernel
+    # Avoid doing source file checks for kernel.
     r"/src/third_party/kernel/",
     r"/src/third_party/kernel-next/",
     r"/src/third_party/ktop/",
@@ -59,26 +60,32 @@
     r".*\bexperimental[\\\/].*",
     r".*\b[A-Z0-9_]{2,}$",
     r".*[\\\/]debian[\\\/]rules$",
-    # for ebuild trees, ignore any caches and manifest data
+
+    # For ebuild trees, ignore any caches and manifest data.
     r".*/Manifest$",
     r".*/metadata/[^/]*cache[^/]*/[^/]+/[^/]+$",
 
-    # ignore profiles data (like overlay-tegra2/profiles)
+    # Ignore profiles data (like overlay-tegra2/profiles).
     r"(^|.*/)overlay-.*/profiles/.*",
     r"^profiles/.*$",
 
-    # ignore minified js and jquery
+    # Ignore minified js and jquery.
     r".*\.min\.js",
     r".*jquery.*\.js",
 
     # Ignore license files as the content is often taken verbatim.
-    r'.*/licenses/.*',
+    r".*/licenses/.*",
 ]
 
 
 _CONFIG_FILE = 'PRESUBMIT.cfg'
 
 
+# File containing wildcards, one per line, matching files that should be
+# excluded from presubmit checks. Lines beginning with '#' are ignored.
+_IGNORE_FILE = '.presubmitignore'
+
+
 # Exceptions
 
 
@@ -240,9 +247,71 @@
   return new_lines
 
 
+def _get_ignore_wildcards(directory, cache):
+  """Get wildcards listed in a directory's _IGNORE_FILE.
+
+  Args:
+    directory: A string containing a directory path.
+    cache: A dictionary (opaque to caller) caching previously-read wildcards.
+
+  Returns:
+    A list of wildcards from _IGNORE_FILE or an empty list if _IGNORE_FILE
+    wasn't present.
+  """
+  # In the cache, keys are directories and values are lists of wildcards from
+  # _IGNORE_FILE within those directories (and empty if no file was present).
+  if directory not in cache:
+    wildcards = []
+    dotfile_path = os.path.join(directory, _IGNORE_FILE)
+    if os.path.exists(dotfile_path):
+      # TODO(derat): Consider using _get_file_content() to get the file as of
+      # this commit instead of the on-disk version. This may have a noticeable
+      # performance impact, as each call to _get_file_content() runs git.
+      with open(dotfile_path, 'r') as dotfile:
+        for line in dotfile.readlines():
+          line = line.strip()
+          if line.startswith('#'):
+            continue
+          if line.endswith('/'):
+            line += '*'
+          wildcards.append(line)
+    cache[directory] = wildcards
+
+  return cache[directory]
+
+
+def _path_is_ignored(path, cache):
+  """Check whether a path is ignored by _IGNORE_FILE.
+
+  Args:
+    path: A string containing a path.
+    cache: A dictionary (opaque to caller) caching previously-read wildcards.
+
+  Returns:
+    True if a file named _IGNORE_FILE in one of the passed-in path's parent
+    directories contains a wildcard matching the path.
+  """
+  # Skip ignore files.
+  if os.path.basename(path) == _IGNORE_FILE:
+    return True
+
+  path = os.path.abspath(path)
+  base = os.getcwd()
+
+  prefix = os.path.dirname(path)
+  while prefix.startswith(base):
+    rel_path = path[len(prefix) + 1:]
+    for wildcard in _get_ignore_wildcards(prefix, cache):
+      if fnmatch.fnmatch(rel_path, wildcard):
+        return True
+    prefix = os.path.dirname(prefix)
+
+  return False
+
+
 def _get_affected_files(commit, include_deletes=False, relative=False,
                         include_symlinks=False, include_adds=True,
-                        full_details=False):
+                        full_details=False, use_ignore_files=True):
   """Returns list of file paths that were modified/added, excluding symlinks.
 
   Args:
@@ -252,6 +321,7 @@
     include_symlinks: If true, we'll include symlinks in the result
     include_adds: If true, we'll include new files in the result
     full_details: If False, return filenames, else return structured results.
+    use_ignore_files: Whether we ignore files matched by _IGNORE_FILE files.
 
   Returns:
     A list of modified/added (and perhaps deleted) files
@@ -276,6 +346,11 @@
   if not include_adds:
     files = [x for x in files if x.status != 'A']
 
+  if use_ignore_files:
+    cache = {}
+    is_ignored = lambda x: _path_is_ignored(x.dst_file or x.src_file, cache)
+    files = [x for x in files if not is_ignored(x)]
+
   if full_details:
     # Caller wants the raw objects to parse status/etc... themselves.
     return files
diff --git a/pre-upload_unittest.py b/pre-upload_unittest.py
index 686ea08..c073a01 100755
--- a/pre-upload_unittest.py
+++ b/pre-upload_unittest.py
@@ -804,10 +804,13 @@
                           src_file=src_file, dst_file=dst_file)
 
 
-class HelpersTest(cros_test_lib.MockTestCase):
+class HelpersTest(cros_test_lib.MockTempDirTestCase):
   """Various tests for utility functions."""
 
-  def _SetupGetAffectedFiles(self):
+  def setUp(self):
+    self.orig_cwd = os.getcwd()
+    os.chdir(self.tempdir)
+
     self.PatchObject(git, 'RawDiff', return_value=[
         # A modified normal file.
         DiffEntry(src_file='buildbot/constants.py', status='M'),
@@ -818,9 +821,18 @@
         DiffEntry(src_file='scripts/sync_sonic.py', status='D'),
     ])
 
+  def tearDown(self):
+    os.chdir(self.orig_cwd)
+
+  def _WritePresubmitIgnoreFile(self, subdir, data):
+    """Writes to a .presubmitignore file in the passed-in subdirectory."""
+    directory = os.path.join(self.tempdir, subdir)
+    if not os.path.exists(directory):
+      os.makedirs(directory)
+    osutils.WriteFile(os.path.join(directory, pre_upload._IGNORE_FILE), data)
+
   def testGetAffectedFilesNoDeletesNoRelative(self):
     """Verify _get_affected_files() works w/no delete & not relative."""
-    self._SetupGetAffectedFiles()
     path = os.getcwd()
     files = pre_upload._get_affected_files('HEAD', include_deletes=False,
                                            relative=False)
@@ -829,7 +841,6 @@
 
   def testGetAffectedFilesDeletesNoRelative(self):
     """Verify _get_affected_files() works w/delete & not relative."""
-    self._SetupGetAffectedFiles()
     path = os.getcwd()
     files = pre_upload._get_affected_files('HEAD', include_deletes=True,
                                            relative=False)
@@ -839,7 +850,6 @@
 
   def testGetAffectedFilesNoDeletesRelative(self):
     """Verify _get_affected_files() works w/no delete & relative."""
-    self._SetupGetAffectedFiles()
     files = pre_upload._get_affected_files('HEAD', include_deletes=False,
                                            relative=True)
     exp_files = ['buildbot/constants.py']
@@ -847,7 +857,6 @@
 
   def testGetAffectedFilesDeletesRelative(self):
     """Verify _get_affected_files() works w/delete & relative."""
-    self._SetupGetAffectedFiles()
     files = pre_upload._get_affected_files('HEAD', include_deletes=True,
                                            relative=True)
     exp_files = ['buildbot/constants.py', 'scripts/sync_sonic.py']
@@ -855,11 +864,44 @@
 
   def testGetAffectedFilesDetails(self):
     """Verify _get_affected_files() works w/full_details."""
-    self._SetupGetAffectedFiles()
     files = pre_upload._get_affected_files('HEAD', full_details=True,
                                            relative=True)
     self.assertEquals(files[0].src_file, 'buildbot/constants.py')
 
+  def testGetAffectedFilesPresubmitIgnoreDirectory(self):
+    """Verify .presubmitignore can be used to exclude a directory."""
+    self._WritePresubmitIgnoreFile('.', 'buildbot/')
+    self.assertEquals(pre_upload._get_affected_files('HEAD', relative=True), [])
+
+  def testGetAffectedFilesPresubmitIgnoreDirectoryWildcard(self):
+    """Verify .presubmitignore can be used with a directory wildcard."""
+    self._WritePresubmitIgnoreFile('.', '*/constants.py')
+    self.assertEquals(pre_upload._get_affected_files('HEAD', relative=True), [])
+
+  def testGetAffectedFilesPresubmitIgnoreWithinDirectory(self):
+    """Verify .presubmitignore can be placed in a subdirectory."""
+    self._WritePresubmitIgnoreFile('buildbot', '*.py')
+    self.assertEquals(pre_upload._get_affected_files('HEAD', relative=True), [])
+
+  def testGetAffectedFilesPresubmitIgnoreDoesntMatch(self):
+    """Verify .presubmitignore has no effect when it doesn't match a file."""
+    self._WritePresubmitIgnoreFile('buildbot', '*.txt')
+    self.assertEquals(pre_upload._get_affected_files('HEAD', relative=True),
+                      ['buildbot/constants.py'])
+
+  def testGetAffectedFilesPresubmitIgnoreAddedFile(self):
+    """Verify .presubmitignore matches added files."""
+    self._WritePresubmitIgnoreFile('.', 'buildbot/\nscripts/')
+    self.assertEquals(pre_upload._get_affected_files('HEAD', relative=True,
+                                                     include_symlinks=True),
+                      [])
+
+  def testGetAffectedFilesPresubmitIgnoreSkipIgnoreFile(self):
+    """Verify .presubmitignore files are automatically skipped."""
+    self.PatchObject(git, 'RawDiff', return_value=[
+        DiffEntry(src_file='.presubmitignore', status='M')
+    ])
+    self.assertEquals(pre_upload._get_affected_files('HEAD', relative=True), [])
 
 class CheckForUprev(cros_test_lib.MockTempDirTestCase):
   """Tests for _check_for_uprev."""