Update pre-upload hook to look for aliases in parent dirs.

Walk up the directory tree when looking for a .project_alias
file while verifying commit message prefixes. Otherwise,
changes within e.g. platform2/power_manager/powerd/policy
don't permit a "power: " prefix (as allowed by
platform2/power_manager/.project_alias).

BUG=none
TEST=manual; also wrote tests for _check_project_prefix

Change-Id: I3dab8144e2422fb4e594cc2fb0f1cb328b9fbc36
Reviewed-on: https://chromium-review.googlesource.com/220282
Reviewed-by: Mike Frysinger <vapier@chromium.org>
Commit-Queue: Daniel Erat <derat@chromium.org>
Tested-by: Daniel Erat <derat@chromium.org>
diff --git a/pre-upload.py b/pre-upload.py
index 88f8c03..1c0cd6f 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -29,6 +29,7 @@
 if __name__ in ('__builtin__', '__main__'):
   sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), '..', '..'))
 
+from chromite.lib import osutils
 from chromite.lib import patch
 from chromite.licensing import licenses_lib
 
@@ -978,15 +979,23 @@
   prefix = os.path.dirname(prefix)
 
   # If there is no common prefix, the CL span multiple projects.
-  if prefix == '':
+  if not prefix:
     return
 
   project_name = prefix.split('/')[0]
-  alias_file = os.path.join(prefix, '.project_alias')
-  # If an alias exists, use it.
-  if os.path.isfile(alias_file):
-    with open(alias_file, 'r') as f:
-      project_name = f.read().strip()
+
+  # The common files may all be within a subdirectory of the main project
+  # directory, so walk up the tree until we find an alias file.
+  # _get_affected_files() should return relative paths, but check against '/' to
+  # ensure that this loop terminates even if it receives an absolute path.
+  while prefix and prefix != '/':
+    alias_file = os.path.join(prefix, '.project_alias')
+
+    # If an alias exists, use it.
+    if os.path.isfile(alias_file):
+      project_name = osutils.ReadFile(alias_file).strip()
+
+    prefix = os.path.dirname(prefix)
 
   if not _get_commit_desc(commit).startswith(project_name + ': '):
     return HookFailure('The commit title for changes affecting only %s'
diff --git a/pre-upload_unittest.py b/pre-upload_unittest.py
index 6a5d968..0a9959e 100755
--- a/pre-upload_unittest.py
+++ b/pre-upload_unittest.py
@@ -24,6 +24,7 @@
   sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), '..', '..'))
 
 from chromite.lib import cros_test_lib
+from chromite.lib import osutils
 
 
 pre_upload = __import__('pre-upload')
@@ -72,6 +73,57 @@
                       failure.items)
 
 
+class CheckProjectPrefix(cros_test_lib.MockTempDirTestCase):
+  """Tests for _check_project_prefix."""
+
+  def setUp(self):
+    self.orig_cwd = os.getcwd()
+    os.chdir(self.tempdir)
+    self.file_mock = self.PatchObject(pre_upload, '_get_affected_files')
+    self.desc_mock = self.PatchObject(pre_upload, '_get_commit_desc')
+
+  def tearDown(self):
+    os.chdir(self.orig_cwd)
+
+  def _WriteAliasFile(self, filename, project):
+    """Writes a project name to a file, creating directories if needed."""
+    os.makedirs(os.path.dirname(filename))
+    osutils.WriteFile(filename, project)
+
+  def testInvalidPrefix(self):
+    """Report an error when the prefix doesn't match the base directory."""
+    self.file_mock.return_value = ['foo/foo.cc', 'foo/subdir/baz.cc']
+    self.desc_mock.return_value = 'bar: Some commit'
+    failure = pre_upload._check_project_prefix('PROJECT', 'COMMIT')
+    self.assertTrue(failure)
+    self.assertEquals(('The commit title for changes affecting only foo' +
+                       ' should start with "foo: "'), failure.msg)
+
+  def testValidPrefix(self):
+    """Use a prefix that matches the base directory."""
+    self.file_mock.return_value = ['foo/foo.cc', 'foo/subdir/baz.cc']
+    self.desc_mock.return_value = 'foo: Change some files.'
+    self.assertFalse(pre_upload._check_project_prefix('PROJECT', 'COMMIT'))
+
+  def testAliasFile(self):
+    """Use .project_alias to override the project name."""
+    self._WriteAliasFile('foo/.project_alias', 'project')
+    self.file_mock.return_value = ['foo/foo.cc', 'foo/subdir/bar.cc']
+    self.desc_mock.return_value = 'project: Use an alias.'
+    self.assertFalse(pre_upload._check_project_prefix('PROJECT', 'COMMIT'))
+
+  def testAliasFileWithSubdirs(self):
+    """Check that .project_alias is used when only modifying subdirectories."""
+    self._WriteAliasFile('foo/.project_alias', 'project')
+    self.file_mock.return_value = [
+        'foo/subdir/foo.cc',
+        'foo/subdir/bar.cc'
+        'foo/subdir/blah/baz.cc'
+    ]
+    self.desc_mock.return_value = 'project: Alias with subdirs.'
+    self.assertFalse(pre_upload._check_project_prefix('PROJECT', 'COMMIT'))
+
+
 class CheckKernelConfig(cros_test_lib.MoxTestCase):
   """Tests for _kernel_configcheck."""