pre-upload: fix uprev checks with new packages & symlink renames

Handle a few more edge cases:
 - If we're creating a package from scratch, an uprev is not needed.
 - If the user has renamed the existing symlink and modified files/,
   then they don't need to do anything else.
 - If files/ are modified, but the ebuilds haven't been updated, throw
   an error so the uprev is forced.

BUG=chromium:397714
TEST=created a CL that added an ebuild -> was not rejected
TEST=created a CL that modified an ebuild -> was rejected

Change-Id: I38ea5051d14fd2b68ea04ee97300c40cf604558f
Reviewed-on: https://chromium-review.googlesource.com/231614
Reviewed-by: Doug Anderson <dianders@chromium.org>
Tested-by: Mike Frysinger <vapier@chromium.org>
diff --git a/pre-upload.py b/pre-upload.py
index a619cac..ffa6eae 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -240,17 +240,25 @@
   return new_lines
 
 
-def _get_affected_files(commit, include_deletes=False, relative=False):
+def _get_affected_files(commit, include_deletes=False, relative=False,
+                        include_symlinks=False, include_adds=True,
+                        full_details=False):
   """Returns list of file paths that were modified/added, excluding symlinks.
 
   Args:
     commit: The commit
     include_deletes: If true, we'll include deleted files in the result
     relative: Whether to return relative or full paths to files
+    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.
 
   Returns:
     A list of modified/added (and perhaps deleted) files
   """
+  if not relative and full_details:
+    raise ValueError('full_details only supports relative paths currently')
+
   if commit == PRE_SUBMIT:
     return _run_command(['git', 'diff-index', '--cached',
                          '--name-only', 'HEAD']).split()
@@ -259,17 +267,25 @@
   files = git.RawDiff(path, '%s^!' % commit)
 
   # Filter out symlinks.
-  files = [x for x in files if not stat.S_ISLNK(int(x.dst_mode, 8))]
+  if not include_symlinks:
+    files = [x for x in files if not stat.S_ISLNK(int(x.dst_mode, 8))]
 
   if not include_deletes:
     files = [x for x in files if x.status != 'D']
 
-  # Caller only cares about filenames.
-  files = [x.dst_file if x.dst_file else x.src_file for x in files]
-  if relative:
+  if not include_adds:
+    files = [x for x in files if x.status != 'A']
+
+  if full_details:
+    # Caller wants the raw objects to parse status/etc... themselves.
     return files
   else:
-    return [os.path.join(path, x) for x in files]
+    # Caller only cares about filenames.
+    files = [x.dst_file if x.dst_file else x.src_file for x in files]
+    if relative:
+      return files
+    else:
+      return [os.path.join(path, x) for x in files]
 
 
 def _get_commits():
@@ -390,7 +406,7 @@
     return HookFailure(msg)
 
 
-def _check_for_uprev(project, commit):
+def _check_for_uprev(project, commit, project_top=None):
   """Check that we're not missing a revbump of an ebuild in the given commit.
 
   If the given commit touches files in a directory that has ebuilds somewhere
@@ -415,6 +431,7 @@
   Args:
     project: The project to look at
     commit: The commit to look at
+    project_top: Top dir to process commits in
 
   Returns:
     A HookFailure or None.
@@ -428,38 +445,59 @@
   if project in whitelist:
     return None
 
-  affected_paths = _get_affected_files(commit, include_deletes=True)
+  def FinalName(obj):
+    # If the file is being deleted, then the dst_file is not set.
+    if obj.dst_file is None:
+      return obj.src_file
+    else:
+      return obj.dst_file
+
+  affected_path_objs = _get_affected_files(
+      commit, include_deletes=True, include_symlinks=True, relative=True,
+      full_details=True)
 
   # Don't yell about changes to whitelisted files...
   whitelist = ('ChangeLog', 'Manifest', 'metadata.xml')
-  affected_paths = [path for path in affected_paths
-                    if os.path.basename(path) not in whitelist]
-  if not affected_paths:
+  affected_path_objs = [x for x in affected_path_objs
+                        if os.path.basename(FinalName(x)) not in whitelist]
+  if not affected_path_objs:
     return None
 
   # If we've touched any file named with a -rN.ebuild then we'll say we're
   # OK right away.  See TODO above about enhancing this.
-  touched_revved_ebuild = any(re.search(r'-r\d*\.ebuild$', path)
-                              for path in affected_paths)
+  touched_revved_ebuild = any(re.search(r'-r\d*\.ebuild$', FinalName(x))
+                              for x in affected_path_objs)
   if touched_revved_ebuild:
     return None
 
+  # If we're creating new ebuilds from scratch, then we don't need an uprev.
+  # Find all the dirs that new ebuilds and ignore their files/.
+  ebuild_dirs = [os.path.dirname(FinalName(x)) + '/' for x in affected_path_objs
+                 if FinalName(x).endswith('.ebuild') and x.status == 'A']
+  affected_path_objs = [obj for obj in affected_path_objs
+                        if not any(FinalName(obj).startswith(x)
+                                   for x in ebuild_dirs)]
+  if not affected_path_objs:
+    return
+
   # We want to examine the current contents of all directories that are parents
   # of files that were touched (up to the top of the project).
   #
   # ...note: we use the current directory contents even though it may have
   # changed since the commit we're looking at.  This is just a heuristic after
   # all.  Worst case we don't flag a missing revbump.
-  project_top = os.getcwd()
+  if project_top is None:
+    project_top = os.getcwd()
   dirs_to_check = set([project_top])
-  for path in affected_paths:
-    path = os.path.dirname(path)
+  for obj in affected_path_objs:
+    path = os.path.join(project_top, os.path.dirname(FinalName(obj)))
     while os.path.exists(path) and not os.path.samefile(path, project_top):
       dirs_to_check.add(path)
       path = os.path.dirname(path)
 
   # Look through each directory.  If it's got an ebuild in it then we'll
   # consider this as a case when we need a revbump.
+  affected_paths = set([FinalName(x) for x in affected_path_objs])
   for dir_path in dirs_to_check:
     contents = os.listdir(dir_path)
     ebuilds = [os.path.join(dir_path, path)
@@ -468,12 +506,13 @@
 
     # If the -9999.ebuild file was touched the bot will uprev for us.
     # ...we'll use a simple intersection here as a heuristic...
-    if set(ebuilds_9999) & set(affected_paths):
+    if set(ebuilds_9999) & affected_paths:
       continue
 
     if ebuilds:
-      return HookFailure('Changelist probably needs a revbump of an ebuild\n'
-                         'or a -r1.ebuild symlink if this is a new ebuild')
+      return HookFailure('Changelist probably needs a revbump of an ebuild, '
+                         'or a -r1.ebuild symlink if this is a new ebuild:\n'
+                         '%s' % dir_path)
 
   return None
 
diff --git a/pre-upload_unittest.py b/pre-upload_unittest.py
index 30c2349..395126d 100755
--- a/pre-upload_unittest.py
+++ b/pre-upload_unittest.py
@@ -640,23 +640,41 @@
     self.assertMessageRejected('o' * 200)
 
 
+def DiffEntry(src_file=None, dst_file=None, src_mode=None, dst_mode='100644',
+              status='M'):
+  """Helper to create a stub RawDiffEntry object"""
+  if src_mode is None:
+    if status == 'A':
+      src_mode = '000000'
+    elif status == 'M':
+      src_mode = dst_mode
+    elif status == 'D':
+      src_mode = dst_mode
+      dst_mode = '000000'
+
+  src_sha = dst_sha = 'abc'
+  if status == 'D':
+    dst_sha = '000000'
+  elif status == 'A':
+    src_sha = '000000'
+
+  return git.RawDiffEntry(src_mode=src_mode, dst_mode=dst_mode, src_sha=src_sha,
+                          dst_sha=dst_sha, status=status, score=None,
+                          src_file=src_file, dst_file=dst_file)
+
+
 class HelpersTest(cros_test_lib.MockTestCase):
   """Various tests for utility functions."""
 
   def _SetupGetAffectedFiles(self):
     self.PatchObject(git, 'RawDiff', return_value=[
         # A modified normal file.
-        git.RawDiffEntry(src_mode='100644', dst_mode='100644', src_sha='abc',
-                         dst_sha='abc', status='M', score=None,
-                         src_file='buildbot/constants.py', dst_file=None),
+        DiffEntry(src_file='buildbot/constants.py', status='M'),
         # A new symlink file.
-        git.RawDiffEntry(src_mode='000000', dst_mode='120000', src_sha='abc',
-                         dst_sha='abc', status='A', score=None,
-                         src_file='scripts/cros_env_whitelist', dst_file=None),
+        DiffEntry(dst_file='scripts/cros_env_whitelist', dst_mode='120000',
+                  status='A'),
         # A deleted file.
-        git.RawDiffEntry(src_mode='100644', dst_mode='000000', src_sha='abc',
-                         dst_sha='000000', status='D', score=None,
-                         src_file='scripts/sync_sonic.py', dst_file=None),
+        DiffEntry(src_file='scripts/sync_sonic.py', status='D'),
     ])
 
   def testGetAffectedFilesNoDeletesNoRelative(self):
@@ -689,12 +707,91 @@
   def testGetAffectedFilesDeletesRelative(self):
     """Verify _get_affected_files() works w/delete & relative."""
     self._SetupGetAffectedFiles()
-    path = os.getcwd()
     files = pre_upload._get_affected_files('HEAD', include_deletes=True,
                                            relative=True)
     exp_files = ['buildbot/constants.py', 'scripts/sync_sonic.py']
     self.assertEquals(files, exp_files)
 
+  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')
+
+
+class CheckForUprev(cros_test_lib.MockTempDirTestCase):
+  """Tests for _check_for_uprev."""
+
+  def setUp(self):
+    self.file_mock = self.PatchObject(git, 'RawDiff')
+
+  def _Files(self, files):
+    """Create |files| in the tempdir and return full paths to them."""
+    for obj in files:
+      if obj.status == 'D':
+        continue
+      if obj.dst_file is None:
+        f = obj.src_file
+      else:
+        f = obj.dst_file
+      osutils.Touch(os.path.join(self.tempdir, f), makedirs=True)
+    return files
+
+  def assertAccepted(self, files, project='project', commit='fake sha1'):
+    """Assert _check_for_uprev accepts |files|."""
+    self.file_mock.return_value = self._Files(files)
+    ret = pre_upload._check_for_uprev(project, commit, project_top=self.tempdir)
+    self.assertEqual(ret, None)
+
+  def assertRejected(self, files, project='project', commit='fake sha1'):
+    """Assert _check_for_uprev rejects |files|."""
+    self.file_mock.return_value = self._Files(files)
+    ret = pre_upload._check_for_uprev(project, commit, project_top=self.tempdir)
+    self.assertTrue(isinstance(ret, errors.HookFailure))
+
+  def testWhitelistOverlay(self):
+    """Skip checks on whitelisted overlays."""
+    self.assertAccepted([DiffEntry(src_file='cat/pkg/pkg-0.ebuild')],
+                        project='chromiumos/overlays/portage-stable')
+
+  def testWhitelistFiles(self):
+    """Skip checks on whitelisted files."""
+    files = ['ChangeLog', 'Manifest', 'metadata.xml']
+    self.assertAccepted([DiffEntry(src_file=os.path.join('c', 'p', x),
+                                   status='M')
+                         for x in files])
+
+  def testRejectBasic(self):
+    """Reject ebuilds missing uprevs."""
+    self.assertRejected([DiffEntry(src_file='c/p/p-0.ebuild', status='M')])
+
+  def testNewPackage(self):
+    """Accept new ebuilds w/out uprevs."""
+    self.assertAccepted([DiffEntry(src_file='c/p/p-0.ebuild', status='A')])
+    self.assertAccepted([DiffEntry(src_file='c/p/p-0-r12.ebuild', status='A')])
+
+  def testModifiedFilesOnly(self):
+    """Reject ebuilds w/out uprevs and changes in files/."""
+    osutils.Touch(os.path.join(self.tempdir, 'cat/pkg/pkg-0.ebuild'),
+                  makedirs=True)
+    self.assertRejected([DiffEntry(src_file='cat/pkg/files/f', status='A')])
+    self.assertRejected([DiffEntry(src_file='cat/pkg/files/g', status='M')])
+
+  def testFilesNoEbuilds(self):
+    """Ignore changes to paths w/out ebuilds."""
+    self.assertAccepted([DiffEntry(src_file='cat/pkg/files/f', status='A')])
+    self.assertAccepted([DiffEntry(src_file='cat/pkg/files/g', status='M')])
+
+  def testModifiedFilesWithUprev(self):
+    """Accept ebuilds w/uprevs and changes in files/."""
+    self.assertAccepted([DiffEntry(src_file='c/p/files/f', status='A'),
+                         DiffEntry(src_file='c/p/p-0.ebuild', status='A')])
+    self.assertAccepted([
+        DiffEntry(src_file='c/p/files/f', status='M'),
+        DiffEntry(src_file='c/p/p-0-r1.ebuild', src_mode='120000',
+                  dst_file='c/p/p-0-r2.ebuild', dst_mode='120000', status='R')])
+
 
 if __name__ == '__main__':
   cros_test_lib.main()