Add a clang-format pre-upload hook

This change converts adds the `clang_format_check` pre-upload hook,
modelled after the one in AOSP's repohooks. This is disabled by default,
and all projects must opt in to enable it.

  [Hook Overrides]
  clang_format_check: true

  # Optional if you need to limit clang-format to just a few projects.
  # e.g. for platform2/
  [Hook Overrides Options]
  clang_format_check: project_a/ project_b/

CQ-DEPEND=CL:688744,CL:*464767
BUG=b:65376611
TEST=../repohooks/pre-upload.py  # On platform2 with a badly-formatted
                                 # change.

Change-Id: I088a9f8585e74d3d0db3b647febdecdbf79f3d03
Reviewed-on: https://chromium-review.googlesource.com/685283
Commit-Ready: Luis Hector Chavez <lhchavez@chromium.org>
Tested-by: Luis Hector Chavez <lhchavez@chromium.org>
Reviewed-by: Dan Erat <derat@chromium.org>
diff --git a/clang-format.py b/clang-format.py
new file mode 100755
index 0000000..8e884d4
--- /dev/null
+++ b/clang-format.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python2
+# -*- coding:utf-8 -*-
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Wrapper to run git-clang-format and parse its output."""
+
+from __future__ import print_function
+
+import hashlib
+import io
+import os
+import sys
+
+_path = os.path.realpath(__file__ + '/../../..')
+if sys.path[0] != _path:
+  sys.path.insert(0, _path)
+del _path
+
+from chromite.lib import commandline
+from chromite.lib import constants
+from chromite.lib import cros_build_lib
+
+
+# Since we're asking git-clang-format to print a diff, all modified filenames
+# that have formatting errors are printed with this prefix.
+DIFF_MARKER_PREFIX = '+++ b/'
+
+BUILDTOOLS_PATH = os.path.join(constants.SOURCE_ROOT, 'chromium', 'src',
+                               'buildtools')
+
+
+def _GetSha1Hash(path):
+  """Gets the SHA-1 hash of |path|, or None if the file does not exist."""
+  if not os.path.exists(path):
+    return None
+  with open(path, 'rb') as f:
+    m = hashlib.sha1()
+    while True:
+      buf = f.read(io.DEFAULT_BUFFER_SIZE)
+      if not buf:
+        break
+      m.update(buf)
+    return m.hexdigest()
+
+
+def _GetDefaultClangFormatPath():
+  """Gets the default clang-format binary path.
+
+  This also ensures that the binary itself is up-to-date.
+  """
+
+  clang_format_path = os.path.join(BUILDTOOLS_PATH, 'linux64/clang-format')
+  hash_file_path = os.path.join(BUILDTOOLS_PATH, 'linux64/clang-format.sha1')
+  with open(hash_file_path, 'r') as f:
+    expected_hash = f.read().strip()
+  if expected_hash != _GetSha1Hash(clang_format_path):
+    # See chromium/src/buildtools/clang_format/README.txt for more details.
+    cmd = [os.path.join(constants.DEPOT_TOOLS_DIR,
+                        'download_from_google_storage.py'), '-b',
+           'chromium-clang-format', '-s', hash_file_path]
+    cros_build_lib.RunCommand(cmd=cmd, print_cmd=False)
+  return clang_format_path
+
+
+def main(argv):
+  """Checks if a project is correctly formatted with clang-format.
+
+  Returns 1 if there are any clang-format-worthy changes in the project (or
+  on a provided list of files/directories in the project), 0 otherwise.
+  """
+
+  parser = commandline.ArgumentParser(description=__doc__)
+  parser.add_argument('--clang-format', default=_GetDefaultClangFormatPath(),
+                      help='The path of the clang-format executable.')
+  parser.add_argument('--git-clang-format',
+                      default=os.path.join(BUILDTOOLS_PATH, 'clang_format',
+                                           'script', 'git-clang-format'),
+                      help='The path of the git-clang-format executable.')
+  parser.add_argument('--style', metavar='STYLE', type=str, default='file',
+                      help='The style that clang-format will use.')
+  parser.add_argument('--extensions', metavar='EXTENSIONS', type=str,
+                      help='Comma-separated list of file extensions to '
+                           'format.')
+  parser.add_argument('--fix', action='store_true',
+                      help='Fix any formatting errors automatically.')
+
+  scope = parser.add_mutually_exclusive_group(required=True)
+  scope.add_argument('--commit', type=str, default='HEAD',
+                     help='Specify the commit to validate.')
+  scope.add_argument('--working-tree', action='store_true',
+                     help='Validates the files that have changed from '
+                          'HEAD in the working directory.')
+
+  parser.add_argument('files', type=str, nargs='*',
+                      help='If specified, only consider differences in '
+                           'these files/directories.')
+
+  opts = parser.parse_args(argv)
+
+  cmd = [opts.git_clang_format, '--binary', opts.clang_format, '--diff']
+  if opts.style:
+    cmd.extend(['--style', opts.style])
+  if opts.extensions:
+    cmd.extend(['--extensions', opts.extensions])
+  if not opts.working_tree:
+    cmd.extend(['%s^' % opts.commit, opts.commit])
+  cmd.extend(['--'] + opts.files)
+
+  # Fail gracefully if clang-format itself aborts/fails.
+  try:
+    result = cros_build_lib.RunCommand(cmd=cmd,
+                                       print_cmd=False,
+                                       stdout_to_pipe=True)
+  except cros_build_lib.RunCommandError as e:
+    print('clang-format failed:\n' + str(e), file=sys.stderr)
+    print('\nPlease report this to the clang team.', file=sys.stderr)
+    return 1
+
+  stdout = result.output
+  if stdout.rstrip('\n') == 'no modified files to format':
+    # This is always printed when only files that clang-format does not
+    # understand were modified.
+    return 0
+
+  diff_filenames = []
+  for line in stdout.splitlines():
+    if line.startswith(DIFF_MARKER_PREFIX):
+      diff_filenames.append(line[len(DIFF_MARKER_PREFIX):].rstrip())
+
+  if diff_filenames:
+    if opts.fix:
+      cros_build_lib.RunCommand(cmd=['git', 'apply'],
+                                print_cmd=False,
+                                input=stdout)
+    else:
+      print('The following files have formatting errors:')
+      for filename in diff_filenames:
+        print('\t%s' % filename)
+      print('You can run `%s --fix %s` to fix this' %
+            (sys.argv[0],
+             ' '.join(cros_build_lib.ShellQuote(arg) for arg in argv)))
+      return 1
+
+if __name__ == '__main__':
+  commandline.ScriptWrapperMain(lambda _: main)
diff --git a/pre-upload.py b/pre-upload.py
index 3bd6f66..59b47ff 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -1189,6 +1189,26 @@
 # Project-specific hooks
 
 
+def _check_clang_format(_project, commit, options=()):
+  """Runs clang-format on the given project"""
+  hooks_dir = _get_hooks_dir()
+  options = list(options)
+  if commit == PRE_SUBMIT:
+    options.append('--commit=HEAD')
+  else:
+    options.extend(['--commit', commit])
+  cmd = ['%s/clang-format.py' % hooks_dir] + options
+  cmd_result = cros_build_lib.RunCommand(cmd=cmd,
+                                         print_cmd=False,
+                                         input=_get_patch(commit),
+                                         stdout_to_pipe=True,
+                                         combine_stdout_stderr=True,
+                                         error_code_ok=True)
+  if cmd_result.returncode:
+    return HookFailure('clang-format.py errors/warnings\n\n' +
+                       cmd_result.output)
+
+
 def _run_checkpatch(_project, commit, options=()):
   """Runs checkpatch.pl on the given project"""
   hooks_dir = _get_hooks_dir()
@@ -1487,6 +1507,7 @@
 # A dictionary of flags (keys) that can appear in the config file, and the hook
 # that the flag controls (value).
 _HOOK_FLAGS = {
+    'clang_format_check': _check_clang_format,
     'checkpatch_check': _run_checkpatch,
     'stray_whitespace_check': _check_no_stray_whitespace,
     'json_check': _run_json_check,