deploy_production_local: Add better change reporting.

This will now report all of the changes added to a repo after an update.

BUG=chromium:425636
TEST=Unittests.

Change-Id: I999d03cdbd63f22d97fda47c8fb7eba31c5a62a1
Reviewed-on: https://chromium-review.googlesource.com/234828
Tested-by: Don Garrett <dgarrett@chromium.org>
Reviewed-by: Richard Barnette <jrbarnette@chromium.org>
Commit-Queue: Don Garrett <dgarrett@chromium.org>
diff --git a/contrib/deploy_production_local.py b/contrib/deploy_production_local.py
index 0d58ea8..a723fd3 100755
--- a/contrib/deploy_production_local.py
+++ b/contrib/deploy_production_local.py
@@ -65,11 +65,40 @@
 def repo_versions():
     """This function collects the versions of all git repos in the general repo.
 
-    @returns A string the describes HEAD of all git repos.
+    @returns A dictionary mapping project names to git hashes for HEAD.
     @raises subprocess.CalledProcessError on a repo command failure.
     """
-    cmd = ['repo', 'forall', '-p', '-c', 'git', 'log', '-1', '--oneline']
-    return subprocess.check_output(cmd)
+    cmd = ['repo', 'forall', '-p', '-c', 'pwd && git log -1 --format=%h']
+    output = subprocess.check_output(cmd)
+
+    # The expected output format is:
+
+    # project chrome_build/
+    # /dir/holding/chrome_build
+    # 73dee9d
+    #
+    # project chrome_release/
+    # /dir/holding/chrome_release
+    # 9f3a5d8
+
+    lines = output.splitlines()
+
+    PROJECT_PREFIX = 'project '
+
+    project_heads = {}
+    for n in range(0, len(lines), 4):
+        project_line = lines[n]
+        project_dir = lines[n+1]
+        project_hash = lines[n+2]
+        # lines[n+3] is a blank line, but doesn't exist for the final block.
+
+        # Convert 'project chrome_build/' -> 'chrome_build'
+        assert project_line.startswith(PROJECT_PREFIX)
+        name = project_line[len(PROJECT_PREFIX):].rstrip('/')
+
+        project_heads[name] = (project_dir, project_hash)
+
+    return project_heads
 
 
 def repo_sync():
@@ -213,7 +242,7 @@
 
 
 def run_deploy_actions(dryrun=False):
-    """
+    """Run arbitrary update commands specified in global.ini.
 
     @param dryrun: Don't really restart the service, just print out the command.
 
@@ -232,6 +261,43 @@
         restart_services(services, dryrun=dryrun)
 
 
+def report_changes(versions_before, versions_after):
+    """Produce a report describing what changed in all repos.
+
+    @param versions_before: Results of repo_versions() from before the update.
+    @param versions_after: Results of repo_versions() from after the update.
+
+    @returns string containing a human friendly changes report.
+    """
+    result = []
+
+    for project in sorted(set(versions_before.keys() + versions_after.keys())):
+        result.append('%s:' % project)
+
+        _, before_hash = versions_before.get(project, (None, None))
+        after_dir, after_hash = versions_after.get(project, (None, None))
+
+        if project not in versions_before:
+            result.append('Added.')
+
+        elif project not in versions_after:
+            result.append('Removed.')
+
+        elif before_hash == after_hash:
+            result.append('No Change.')
+
+        else:
+            hashes = '%s..%s' % (before_hash, after_hash)
+            cmd = ['git', 'log', hashes, '--oneline']
+            out = subprocess.check_output(cmd, cwd=after_dir,
+                                          stderr=subprocess.STDOUT)
+            result.append(out.strip())
+
+        result.append('')
+
+    return '\n'.join(result)
+
+
 def parse_arguments(args):
     """Parse command line arguments.
 
@@ -292,6 +358,8 @@
             print(e.args[0])
             return 1
 
+    versions_before = versions_after = {}
+
     if behaviors.update:
         print('Checking repository versions.')
         versions_before = repo_versions()
@@ -300,7 +368,8 @@
         repo_sync()
 
         print('Checking repository versions after update.')
-        if versions_before == repo_versions():
+        versions_after = repo_versions()
+        if versions_before == versions_after:
             print('No change found.')
             return
 
@@ -313,9 +382,9 @@
             print(e.args[0])
             return 1
 
-    if behaviors.report:
-        print('Current production versions:')
-        print(repo_versions())
+    if behaviors.report and versions_before and versions_after:
+        print('Changes:')
+        print(report_changes(versions_before, versions_after))
 
 
 if __name__ == '__main__':
diff --git a/contrib/deploy_production_local_unittest.py b/contrib/deploy_production_local_unittest.py
index 67f3f39..017af2d 100755
--- a/contrib/deploy_production_local_unittest.py
+++ b/contrib/deploy_production_local_unittest.py
@@ -8,6 +8,7 @@
 from __future__ import print_function
 
 import mock
+import subprocess
 import unittest
 
 import deploy_production_local as dpl
@@ -60,14 +61,35 @@
 
         @param run_cmd: Mock of subprocess call used.
         """
-        expected = 'expected return'
+        output = """project autotest/
+/usr/local/autotest
+5897108
 
-        run_cmd.return_value = expected
+project autotest/site_utils/autotest_private/
+/usr/local/autotest/site_utils/autotest_private
+78b9626
+
+project autotest/site_utils/autotest_tools/
+/usr/local/autotest/site_utils/autotest_tools
+a1598f7
+"""
+
+        expected = {
+            'autotest':
+            ('/usr/local/autotest', '5897108'),
+            'autotest/site_utils/autotest_private':
+            ('/usr/local/autotest/site_utils/autotest_private', '78b9626'),
+            'autotest/site_utils/autotest_tools':
+            ('/usr/local/autotest/site_utils/autotest_tools', 'a1598f7'),
+        }
+
+        run_cmd.return_value = output
         result = dpl.repo_versions()
         self.assertEquals(result, expected)
 
         run_cmd.assert_called_with(
-                ['repo', 'forall', '-p', '-c', 'git', 'log', '-1', '--oneline'])
+                ['repo', 'forall', '-p', '-c',
+                 'pwd && git log -1 --format=%h'])
 
     @mock.patch('subprocess.check_output', autospec=True)
     def test_repo_sync(self, run_cmd):
@@ -165,6 +187,47 @@
             self._test_restart_services(triple_unstable)
         self.assertEqual(unstable.exception.args[0], ['bar', 'joe'])
 
+    @mock.patch('subprocess.check_output', autospec=True)
+    def test_report_changes(self, run_cmd):
+        """Test deploy_production_local.report_changes.
+
+        @param run_cmd: Mock of subprocess call used.
+        """
+
+        before = {
+            'autotest': ('/usr/local/autotest', 'auto_before'),
+            'autotest_private': ('/dir/autotest_private', '78b9626'),
+            'other': ('/fake/unchanged', 'constant_hash'),
+        }
+
+        after = {
+            'autotest': ('/usr/local/autotest', 'auto_after'),
+            'autotest_tools': ('/dir/autotest_tools', 'a1598f7'),
+            'other': ('/fake/unchanged', 'constant_hash'),
+        }
+
+        run_cmd.return_value = 'hash1 Fix change.\nhash2 Bad change.\n'
+
+        result = dpl.report_changes(before, after)
+
+        self.assertEqual(result, """autotest:
+hash1 Fix change.
+hash2 Bad change.
+
+autotest_private:
+Removed.
+
+autotest_tools:
+Added.
+
+other:
+No Change.
+""")
+
+        run_cmd.assert_called_with(
+                ['git', 'log', 'auto_before..auto_after', '--oneline'],
+                cwd='/usr/local/autotest', stderr=subprocess.STDOUT)
+
     def test_parse_arguments(self):
         """Test deploy_production_local.parse_arguments."""
         # No arguments.