Home | History | Annotate | Download | only in git
      1 #!/usr/bin/env python
      2 # Copyright (c) 2013 The Chromium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 """Usage: mffr.py [-d] [-g *.h] [-g *.cc] REGEXP REPLACEMENT
      7 
      8 This tool performs a fast find-and-replace operation on files in
      9 the current git repository.
     10 
     11 The -d flag selects a default set of globs (C++ and Objective-C/C++
     12 source files). The -g flag adds a single glob to the list and may
     13 be used multiple times. If neither -d nor -g is specified, the tool
     14 searches all files (*.*).
     15 
     16 REGEXP uses full Python regexp syntax. REPLACEMENT can use
     17 back-references.
     18 """
     19 
     20 import optparse
     21 import re
     22 import subprocess
     23 import sys
     24 
     25 
     26 # We need to use shell=True with subprocess on Windows so that it
     27 # finds 'git' from the path, but can lead to undesired behavior on
     28 # Linux.
     29 _USE_SHELL = (sys.platform == 'win32')
     30 
     31 
     32 def MultiFileFindReplace(original, replacement, file_globs):
     33   """Implements fast multi-file find and replace.
     34 
     35   Given an |original| string and a |replacement| string, find matching
     36   files by running git grep on |original| in files matching any
     37   pattern in |file_globs|.
     38 
     39   Once files are found, |re.sub| is run to replace |original| with
     40   |replacement|.  |replacement| may use capture group back-references.
     41 
     42   Args:
     43     original: '(#(include|import)\s*["<])chrome/browser/ui/browser.h([>"])'
     44     replacement: '\1chrome/browser/ui/browser/browser.h\3'
     45     file_globs: ['*.cc', '*.h', '*.m', '*.mm']
     46 
     47   Returns the list of files modified.
     48 
     49   Raises an exception on error.
     50   """
     51   # Posix extended regular expressions do not reliably support the "\s"
     52   # shorthand.
     53   posix_ere_original = re.sub(r"\\s", "[[:space:]]", original)
     54   if sys.platform == 'win32':
     55     posix_ere_original = posix_ere_original.replace('"', '""')
     56   out, err = subprocess.Popen(
     57       ['git', 'grep', '-E', '--name-only', posix_ere_original,
     58        '--'] + file_globs,
     59       stdout=subprocess.PIPE,
     60       shell=_USE_SHELL).communicate()
     61   referees = out.splitlines()
     62 
     63   for referee in referees:
     64     with open(referee) as f:
     65       original_contents = f.read()
     66     contents = re.sub(original, replacement, original_contents)
     67     if contents == original_contents:
     68       raise Exception('No change in file %s although matched in grep' %
     69                       referee)
     70     with open(referee, 'wb') as f:
     71       f.write(contents)
     72 
     73   return referees
     74 
     75 
     76 def main():
     77   parser = optparse.OptionParser(usage='''
     78 (1) %prog <options> REGEXP REPLACEMENT
     79 REGEXP uses full Python regexp syntax. REPLACEMENT can use back-references.
     80 
     81 (2) %prog <options> -i <file>
     82 <file> should contain a list (in Python syntax) of
     83 [REGEXP, REPLACEMENT, [GLOBS]] lists, e.g.:
     84 [
     85   [r"(foo|bar)", r"\1baz", ["*.cc", "*.h"]],
     86   ["54", "42"],
     87 ]
     88 As shown above, [GLOBS] can be omitted for a given search-replace list, in which
     89 case the corresponding search-replace will use the globs specified on the
     90 command line.''')
     91   parser.add_option('-d', action='store_true',
     92                     dest='use_default_glob',
     93                     help='Perform the change on C++ and Objective-C(++) source '
     94                     'and header files.')
     95   parser.add_option('-f', action='store_true',
     96                     dest='force_unsafe_run',
     97                     help='Perform the run even if there are uncommitted local '
     98                     'changes.')
     99   parser.add_option('-g', action='append',
    100                     type='string',
    101                     default=[],
    102                     metavar="<glob>",
    103                     dest='user_supplied_globs',
    104                     help='Perform the change on the specified glob. Can be '
    105                     'specified multiple times, in which case the globs are '
    106                     'unioned.')
    107   parser.add_option('-i', "--input_file",
    108                     type='string',
    109                     action='store',
    110                     default='',
    111                     metavar="<file>",
    112                     dest='input_filename',
    113                     help='Read arguments from <file> rather than the command '
    114                     'line. NOTE: To be sure of regular expressions being '
    115                     'interpreted correctly, use raw strings.')
    116   opts, args = parser.parse_args()
    117   if opts.use_default_glob and opts.user_supplied_globs:
    118     print '"-d" and "-g" cannot be used together'
    119     parser.print_help()
    120     return 1
    121 
    122   from_file = opts.input_filename != ""
    123   if (from_file and len(args) != 0) or (not from_file and len(args) != 2):
    124     parser.print_help()
    125     return 1
    126 
    127   if not opts.force_unsafe_run:
    128     out, err = subprocess.Popen(['git', 'status', '--porcelain'],
    129                                 stdout=subprocess.PIPE,
    130                                 shell=_USE_SHELL).communicate()
    131     if out:
    132       print 'ERROR: This tool does not print any confirmation prompts,'
    133       print 'so you should only run it with a clean staging area and cache'
    134       print 'so that reverting a bad find/replace is as easy as running'
    135       print '  git checkout -- .'
    136       print ''
    137       print 'To override this safeguard, pass the -f flag.'
    138       return 1
    139 
    140   global_file_globs = ['*.*']
    141   if opts.use_default_glob:
    142     global_file_globs = ['*.cc', '*.h', '*.m', '*.mm']
    143   elif opts.user_supplied_globs:
    144     global_file_globs = opts.user_supplied_globs
    145 
    146   # Construct list of search-replace tasks.
    147   search_replace_tasks = []
    148   if opts.input_filename == '':
    149     original = args[0]
    150     replacement = args[1]
    151     search_replace_tasks.append([original, replacement, global_file_globs])
    152   else:
    153     f = open(opts.input_filename)
    154     search_replace_tasks = eval("".join(f.readlines()))
    155     for task in search_replace_tasks:
    156       if len(task) == 2:
    157         task.append(global_file_globs)
    158     f.close()
    159 
    160   for (original, replacement, file_globs) in search_replace_tasks:
    161     print 'File globs:  %s' % file_globs
    162     print 'Original:    %s' % original
    163     print 'Replacement: %s' % replacement
    164     MultiFileFindReplace(original, replacement, file_globs)
    165   return 0
    166 
    167 
    168 if __name__ == '__main__':
    169   sys.exit(main())
    170