chromium/tools/git/for-all-touched-files.py

#!/usr/bin/env python3
# Copyright 2011 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""
  Invokes the specified (quoted) command for all files modified
  between the current git branch and the specified branch or commit.

  The special token [[FILENAME]] (or whatever you choose using the -t
  flag) is replaced with each of the filenames of new or modified files.

  Deleted files are not included.  Neither are untracked files.

Synopsis:
  %prog [-b BRANCH] [-d] [-x EXTENSIONS|-c|-g] [-t TOKEN] QUOTED_COMMAND

Examples:
  %prog -x gyp,gypi "tools/format_xml.py [[FILENAME]]"
  %prog -c "tools/sort-headers.py [[FILENAME]]"
  %prog -g "tools/sort_sources.py [[FILENAME]]"
  %prog -t "~~BINGO~~" "echo I modified ~~BINGO~~"
"""

import optparse
import os
import subprocess
import sys


# List of C++-like source file extensions.
_CPP_EXTENSIONS = ('h', 'hh', 'hpp', 'c', 'cc', 'cpp', 'cxx', 'mm',)
# List of build file extensions.
_BUILD_EXTENSIONS = ('gyp', 'gypi', 'gn',)


def GitShell(args, ignore_return=False):
  """A shell invocation suitable for communicating with git. Returns
  output as list of lines, raises exception on error.
  """
  job = subprocess.Popen(args,
                         shell=True,
                         stdout=subprocess.PIPE,
                         stderr=subprocess.STDOUT,
                         text=True)
  (out, err) = job.communicate()
  if job.returncode != 0 and not ignore_return:
    print(out)
    raise Exception("Error %d running command %s" % (
        job.returncode, args))
  return out.split('\n')


def FilenamesFromGit(branch_name, extensions):
  """Provides a list of all new and modified files listed by [git diff
  branch_name] where branch_name can be blank to get a diff of the
  workspace.

  Excludes deleted files.

  If extensions is not an empty list, include only files with one of
  the extensions on the list.
  """
  lines = GitShell('git diff --stat=600,500 %s' % branch_name)
  filenames = []
  for line in lines:
    line = line.lstrip()
    # Avoid summary line, and files that have been deleted (no plus).
    if line.find('|') != -1 and line.find('+') != -1:
      filename = line.split()[0]
      if filename:
        filename = filename.rstrip()
        ext = filename.rsplit('.')[-1]
        if not extensions or ext in extensions:
          filenames.append(filename)
  return filenames


def ForAllTouchedFiles(branch_name, extensions, token, command):
  """For each new or modified file output by [git diff branch_name],
  run command with token replaced with the filename. If extensions is
  not empty, do this only for files with one of the extensions in that
  list.
  """
  filenames = FilenamesFromGit(branch_name, extensions)
  for filename in filenames:
    os.system(command.replace(token, filename))


def main():
  parser = optparse.OptionParser(usage=__doc__)
  parser.add_option('-x', '--extensions', default='', dest='extensions',
                    help='Limits to files with given extensions '
                    '(comma-separated).')
  parser.add_option('-c', '--cpp', default=False, action='store_true',
                    dest='cpp_only',
                    help='Runs your command only on C++-like source files.')
  # -g stands for GYP and GN.
  parser.add_option('-g', '--build', default=False, action='store_true',
                    dest='build_only',
                    help='Runs your command only on build files.')
  parser.add_option('-t', '--token', default='[[FILENAME]]', dest='token',
                    help='Sets the token to be replaced for each file '
                    'in your command (default [[FILENAME]]).')
  parser.add_option('-b', '--branch', default='origin/master', dest='branch',
                    help='Sets what to diff to (default origin/master). Set '
                    'to empty to diff workspace against HEAD.')
  opts, args = parser.parse_args()

  if not args:
    parser.print_help()
    sys.exit(1)

  if opts.cpp_only and opts.build_only:
    parser.error("--cpp and --build are mutually exclusive")

  extensions = opts.extensions
  if opts.cpp_only:
    extensions = _CPP_EXTENSIONS
  if opts.build_only:
    extensions = _BUILD_EXTENSIONS

  ForAllTouchedFiles(opts.branch, extensions, opts.token, args[0])


if __name__ == '__main__':
  main()