xref: /openbsd-src/gnu/llvm/clang/tools/clang-format/git-clang-format (revision a9ac8606c53d55cee9c3a39778b249c51df111ef)
1e5dd7070Spatrick#!/usr/bin/env python
2e5dd7070Spatrick#
3e5dd7070Spatrick#===- git-clang-format - ClangFormat Git Integration ---------*- python -*--===#
4e5dd7070Spatrick#
5e5dd7070Spatrick# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
6e5dd7070Spatrick# See https://llvm.org/LICENSE.txt for license information.
7e5dd7070Spatrick# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
8e5dd7070Spatrick#
9e5dd7070Spatrick#===------------------------------------------------------------------------===#
10e5dd7070Spatrick
11e5dd7070Spatrickr"""
12e5dd7070Spatrickclang-format git integration
13e5dd7070Spatrick============================
14e5dd7070Spatrick
15e5dd7070SpatrickThis file provides a clang-format integration for git. Put it somewhere in your
16e5dd7070Spatrickpath and ensure that it is executable. Then, "git clang-format" will invoke
17e5dd7070Spatrickclang-format on the changes in current files or a specific commit.
18e5dd7070Spatrick
19e5dd7070SpatrickFor further details, run:
20e5dd7070Spatrickgit clang-format -h
21e5dd7070Spatrick
22e5dd7070SpatrickRequires Python 2.7 or Python 3
23e5dd7070Spatrick"""
24e5dd7070Spatrick
25e5dd7070Spatrickfrom __future__ import absolute_import, division, print_function
26e5dd7070Spatrickimport argparse
27e5dd7070Spatrickimport collections
28e5dd7070Spatrickimport contextlib
29e5dd7070Spatrickimport errno
30e5dd7070Spatrickimport os
31e5dd7070Spatrickimport re
32e5dd7070Spatrickimport subprocess
33e5dd7070Spatrickimport sys
34e5dd7070Spatrick
35e5dd7070Spatrickusage = 'git clang-format [OPTIONS] [<commit>] [<commit>] [--] [<file>...]'
36e5dd7070Spatrick
37e5dd7070Spatrickdesc = '''
38e5dd7070SpatrickIf zero or one commits are given, run clang-format on all lines that differ
39e5dd7070Spatrickbetween the working directory and <commit>, which defaults to HEAD.  Changes are
40e5dd7070Spatrickonly applied to the working directory.
41e5dd7070Spatrick
42e5dd7070SpatrickIf two commits are given (requires --diff), run clang-format on all lines in the
43e5dd7070Spatricksecond <commit> that differ from the first <commit>.
44e5dd7070Spatrick
45e5dd7070SpatrickThe following git-config settings set the default of the corresponding option:
46e5dd7070Spatrick  clangFormat.binary
47e5dd7070Spatrick  clangFormat.commit
48ec727ea7Spatrick  clangFormat.extensions
49e5dd7070Spatrick  clangFormat.style
50e5dd7070Spatrick'''
51e5dd7070Spatrick
52e5dd7070Spatrick# Name of the temporary index file in which save the output of clang-format.
53e5dd7070Spatrick# This file is created within the .git directory.
54e5dd7070Spatricktemp_index_basename = 'clang-format-index'
55e5dd7070Spatrick
56e5dd7070Spatrick
57e5dd7070SpatrickRange = collections.namedtuple('Range', 'start, count')
58e5dd7070Spatrick
59e5dd7070Spatrick
60e5dd7070Spatrickdef main():
61e5dd7070Spatrick  config = load_git_config()
62e5dd7070Spatrick
63e5dd7070Spatrick  # In order to keep '--' yet allow options after positionals, we need to
64e5dd7070Spatrick  # check for '--' ourselves.  (Setting nargs='*' throws away the '--', while
65e5dd7070Spatrick  # nargs=argparse.REMAINDER disallows options after positionals.)
66e5dd7070Spatrick  argv = sys.argv[1:]
67e5dd7070Spatrick  try:
68e5dd7070Spatrick    idx = argv.index('--')
69e5dd7070Spatrick  except ValueError:
70e5dd7070Spatrick    dash_dash = []
71e5dd7070Spatrick  else:
72e5dd7070Spatrick    dash_dash = argv[idx:]
73e5dd7070Spatrick    argv = argv[:idx]
74e5dd7070Spatrick
75e5dd7070Spatrick  default_extensions = ','.join([
76e5dd7070Spatrick      # From clang/lib/Frontend/FrontendOptions.cpp, all lower case
77e5dd7070Spatrick      'c', 'h',  # C
78e5dd7070Spatrick      'm',  # ObjC
79e5dd7070Spatrick      'mm',  # ObjC++
80e5dd7070Spatrick      'cc', 'cp', 'cpp', 'c++', 'cxx', 'hh', 'hpp', 'hxx',  # C++
81*a9ac8606Spatrick      'cu', 'cuh',  # CUDA
82e5dd7070Spatrick      # Other languages that clang-format supports
83e5dd7070Spatrick      'proto', 'protodevel',  # Protocol Buffers
84e5dd7070Spatrick      'java',  # Java
85e5dd7070Spatrick      'js',  # JavaScript
86e5dd7070Spatrick      'ts',  # TypeScript
87e5dd7070Spatrick      'cs',  # C Sharp
88*a9ac8606Spatrick      'json',  # Json
89e5dd7070Spatrick      ])
90e5dd7070Spatrick
91e5dd7070Spatrick  p = argparse.ArgumentParser(
92e5dd7070Spatrick    usage=usage, formatter_class=argparse.RawDescriptionHelpFormatter,
93e5dd7070Spatrick    description=desc)
94e5dd7070Spatrick  p.add_argument('--binary',
95e5dd7070Spatrick                 default=config.get('clangformat.binary', 'clang-format'),
96e5dd7070Spatrick                 help='path to clang-format'),
97e5dd7070Spatrick  p.add_argument('--commit',
98e5dd7070Spatrick                 default=config.get('clangformat.commit', 'HEAD'),
99e5dd7070Spatrick                 help='default commit to use if none is specified'),
100e5dd7070Spatrick  p.add_argument('--diff', action='store_true',
101e5dd7070Spatrick                 help='print a diff instead of applying the changes')
102e5dd7070Spatrick  p.add_argument('--extensions',
103e5dd7070Spatrick                 default=config.get('clangformat.extensions',
104e5dd7070Spatrick                                    default_extensions),
105e5dd7070Spatrick                 help=('comma-separated list of file extensions to format, '
106e5dd7070Spatrick                       'excluding the period and case-insensitive')),
107e5dd7070Spatrick  p.add_argument('-f', '--force', action='store_true',
108e5dd7070Spatrick                 help='allow changes to unstaged files')
109e5dd7070Spatrick  p.add_argument('-p', '--patch', action='store_true',
110e5dd7070Spatrick                 help='select hunks interactively')
111e5dd7070Spatrick  p.add_argument('-q', '--quiet', action='count', default=0,
112e5dd7070Spatrick                 help='print less information')
113e5dd7070Spatrick  p.add_argument('--style',
114e5dd7070Spatrick                 default=config.get('clangformat.style', None),
115e5dd7070Spatrick                 help='passed to clang-format'),
116e5dd7070Spatrick  p.add_argument('-v', '--verbose', action='count', default=0,
117e5dd7070Spatrick                 help='print extra information')
118e5dd7070Spatrick  # We gather all the remaining positional arguments into 'args' since we need
119e5dd7070Spatrick  # to use some heuristics to determine whether or not <commit> was present.
120e5dd7070Spatrick  # However, to print pretty messages, we make use of metavar and help.
121e5dd7070Spatrick  p.add_argument('args', nargs='*', metavar='<commit>',
122e5dd7070Spatrick                 help='revision from which to compute the diff')
123e5dd7070Spatrick  p.add_argument('ignored', nargs='*', metavar='<file>...',
124e5dd7070Spatrick                 help='if specified, only consider differences in these files')
125e5dd7070Spatrick  opts = p.parse_args(argv)
126e5dd7070Spatrick
127e5dd7070Spatrick  opts.verbose -= opts.quiet
128e5dd7070Spatrick  del opts.quiet
129e5dd7070Spatrick
130e5dd7070Spatrick  commits, files = interpret_args(opts.args, dash_dash, opts.commit)
131e5dd7070Spatrick  if len(commits) > 1:
132e5dd7070Spatrick    if not opts.diff:
133e5dd7070Spatrick      die('--diff is required when two commits are given')
134e5dd7070Spatrick  else:
135e5dd7070Spatrick    if len(commits) > 2:
136e5dd7070Spatrick      die('at most two commits allowed; %d given' % len(commits))
137e5dd7070Spatrick  changed_lines = compute_diff_and_extract_lines(commits, files)
138e5dd7070Spatrick  if opts.verbose >= 1:
139e5dd7070Spatrick    ignored_files = set(changed_lines)
140e5dd7070Spatrick  filter_by_extension(changed_lines, opts.extensions.lower().split(','))
141*a9ac8606Spatrick  # The computed diff outputs absolute paths, so we must cd before accessing
142*a9ac8606Spatrick  # those files.
143*a9ac8606Spatrick  cd_to_toplevel()
144*a9ac8606Spatrick  filter_symlinks(changed_lines)
145e5dd7070Spatrick  if opts.verbose >= 1:
146e5dd7070Spatrick    ignored_files.difference_update(changed_lines)
147e5dd7070Spatrick    if ignored_files:
148*a9ac8606Spatrick      print(
149*a9ac8606Spatrick        'Ignoring changes in the following files (wrong extension or symlink):')
150e5dd7070Spatrick      for filename in ignored_files:
151e5dd7070Spatrick        print('    %s' % filename)
152e5dd7070Spatrick    if changed_lines:
153e5dd7070Spatrick      print('Running clang-format on the following files:')
154e5dd7070Spatrick      for filename in changed_lines:
155e5dd7070Spatrick        print('    %s' % filename)
156e5dd7070Spatrick  if not changed_lines:
157*a9ac8606Spatrick    if opts.verbose >= 0:
158e5dd7070Spatrick      print('no modified files to format')
159e5dd7070Spatrick    return
160e5dd7070Spatrick  if len(commits) > 1:
161e5dd7070Spatrick    old_tree = commits[1]
162e5dd7070Spatrick    new_tree = run_clang_format_and_save_to_tree(changed_lines,
163e5dd7070Spatrick                                                 revision=commits[1],
164e5dd7070Spatrick                                                 binary=opts.binary,
165e5dd7070Spatrick                                                 style=opts.style)
166e5dd7070Spatrick  else:
167e5dd7070Spatrick    old_tree = create_tree_from_workdir(changed_lines)
168e5dd7070Spatrick    new_tree = run_clang_format_and_save_to_tree(changed_lines,
169e5dd7070Spatrick                                                 binary=opts.binary,
170e5dd7070Spatrick                                                 style=opts.style)
171e5dd7070Spatrick  if opts.verbose >= 1:
172e5dd7070Spatrick    print('old tree: %s' % old_tree)
173e5dd7070Spatrick    print('new tree: %s' % new_tree)
174e5dd7070Spatrick  if old_tree == new_tree:
175e5dd7070Spatrick    if opts.verbose >= 0:
176e5dd7070Spatrick      print('clang-format did not modify any files')
177e5dd7070Spatrick  elif opts.diff:
178e5dd7070Spatrick    print_diff(old_tree, new_tree)
179e5dd7070Spatrick  else:
180e5dd7070Spatrick    changed_files = apply_changes(old_tree, new_tree, force=opts.force,
181e5dd7070Spatrick                                  patch_mode=opts.patch)
182e5dd7070Spatrick    if (opts.verbose >= 0 and not opts.patch) or opts.verbose >= 1:
183e5dd7070Spatrick      print('changed files:')
184e5dd7070Spatrick      for filename in changed_files:
185e5dd7070Spatrick        print('    %s' % filename)
186e5dd7070Spatrick
187e5dd7070Spatrick
188e5dd7070Spatrickdef load_git_config(non_string_options=None):
189e5dd7070Spatrick  """Return the git configuration as a dictionary.
190e5dd7070Spatrick
191e5dd7070Spatrick  All options are assumed to be strings unless in `non_string_options`, in which
192e5dd7070Spatrick  is a dictionary mapping option name (in lower case) to either "--bool" or
193e5dd7070Spatrick  "--int"."""
194e5dd7070Spatrick  if non_string_options is None:
195e5dd7070Spatrick    non_string_options = {}
196e5dd7070Spatrick  out = {}
197e5dd7070Spatrick  for entry in run('git', 'config', '--list', '--null').split('\0'):
198e5dd7070Spatrick    if entry:
199ec727ea7Spatrick      if '\n' in entry:
200e5dd7070Spatrick        name, value = entry.split('\n', 1)
201ec727ea7Spatrick      else:
202ec727ea7Spatrick        # A setting with no '=' ('\n' with --null) is implicitly 'true'
203ec727ea7Spatrick        name = entry
204ec727ea7Spatrick        value = 'true'
205e5dd7070Spatrick      if name in non_string_options:
206e5dd7070Spatrick        value = run('git', 'config', non_string_options[name], name)
207e5dd7070Spatrick      out[name] = value
208e5dd7070Spatrick  return out
209e5dd7070Spatrick
210e5dd7070Spatrick
211e5dd7070Spatrickdef interpret_args(args, dash_dash, default_commit):
212e5dd7070Spatrick  """Interpret `args` as "[commits] [--] [files]" and return (commits, files).
213e5dd7070Spatrick
214e5dd7070Spatrick  It is assumed that "--" and everything that follows has been removed from
215e5dd7070Spatrick  args and placed in `dash_dash`.
216e5dd7070Spatrick
217e5dd7070Spatrick  If "--" is present (i.e., `dash_dash` is non-empty), the arguments to its
218e5dd7070Spatrick  left (if present) are taken as commits.  Otherwise, the arguments are checked
219e5dd7070Spatrick  from left to right if they are commits or files.  If commits are not given,
220e5dd7070Spatrick  a list with `default_commit` is used."""
221e5dd7070Spatrick  if dash_dash:
222e5dd7070Spatrick    if len(args) == 0:
223e5dd7070Spatrick      commits = [default_commit]
224e5dd7070Spatrick    else:
225e5dd7070Spatrick      commits = args
226e5dd7070Spatrick    for commit in commits:
227e5dd7070Spatrick      object_type = get_object_type(commit)
228e5dd7070Spatrick      if object_type not in ('commit', 'tag'):
229e5dd7070Spatrick        if object_type is None:
230e5dd7070Spatrick          die("'%s' is not a commit" % commit)
231e5dd7070Spatrick        else:
232e5dd7070Spatrick          die("'%s' is a %s, but a commit was expected" % (commit, object_type))
233e5dd7070Spatrick    files = dash_dash[1:]
234e5dd7070Spatrick  elif args:
235e5dd7070Spatrick    commits = []
236e5dd7070Spatrick    while args:
237e5dd7070Spatrick      if not disambiguate_revision(args[0]):
238e5dd7070Spatrick        break
239e5dd7070Spatrick      commits.append(args.pop(0))
240e5dd7070Spatrick    if not commits:
241e5dd7070Spatrick      commits = [default_commit]
242e5dd7070Spatrick    files = args
243e5dd7070Spatrick  else:
244e5dd7070Spatrick    commits = [default_commit]
245e5dd7070Spatrick    files = []
246e5dd7070Spatrick  return commits, files
247e5dd7070Spatrick
248e5dd7070Spatrick
249e5dd7070Spatrickdef disambiguate_revision(value):
250e5dd7070Spatrick  """Returns True if `value` is a revision, False if it is a file, or dies."""
251e5dd7070Spatrick  # If `value` is ambiguous (neither a commit nor a file), the following
252e5dd7070Spatrick  # command will die with an appropriate error message.
253e5dd7070Spatrick  run('git', 'rev-parse', value, verbose=False)
254e5dd7070Spatrick  object_type = get_object_type(value)
255e5dd7070Spatrick  if object_type is None:
256e5dd7070Spatrick    return False
257e5dd7070Spatrick  if object_type in ('commit', 'tag'):
258e5dd7070Spatrick    return True
259e5dd7070Spatrick  die('`%s` is a %s, but a commit or filename was expected' %
260e5dd7070Spatrick      (value, object_type))
261e5dd7070Spatrick
262e5dd7070Spatrick
263e5dd7070Spatrickdef get_object_type(value):
264e5dd7070Spatrick  """Returns a string description of an object's type, or None if it is not
265e5dd7070Spatrick  a valid git object."""
266e5dd7070Spatrick  cmd = ['git', 'cat-file', '-t', value]
267e5dd7070Spatrick  p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
268e5dd7070Spatrick  stdout, stderr = p.communicate()
269e5dd7070Spatrick  if p.returncode != 0:
270e5dd7070Spatrick    return None
271e5dd7070Spatrick  return convert_string(stdout.strip())
272e5dd7070Spatrick
273e5dd7070Spatrick
274e5dd7070Spatrickdef compute_diff_and_extract_lines(commits, files):
275e5dd7070Spatrick  """Calls compute_diff() followed by extract_lines()."""
276e5dd7070Spatrick  diff_process = compute_diff(commits, files)
277e5dd7070Spatrick  changed_lines = extract_lines(diff_process.stdout)
278e5dd7070Spatrick  diff_process.stdout.close()
279e5dd7070Spatrick  diff_process.wait()
280e5dd7070Spatrick  if diff_process.returncode != 0:
281e5dd7070Spatrick    # Assume error was already printed to stderr.
282e5dd7070Spatrick    sys.exit(2)
283e5dd7070Spatrick  return changed_lines
284e5dd7070Spatrick
285e5dd7070Spatrick
286e5dd7070Spatrickdef compute_diff(commits, files):
287e5dd7070Spatrick  """Return a subprocess object producing the diff from `commits`.
288e5dd7070Spatrick
289e5dd7070Spatrick  The return value's `stdin` file object will produce a patch with the
290e5dd7070Spatrick  differences between the working directory and the first commit if a single
291e5dd7070Spatrick  one was specified, or the difference between both specified commits, filtered
292e5dd7070Spatrick  on `files` (if non-empty).  Zero context lines are used in the patch."""
293e5dd7070Spatrick  git_tool = 'diff-index'
294e5dd7070Spatrick  if len(commits) > 1:
295e5dd7070Spatrick    git_tool = 'diff-tree'
296e5dd7070Spatrick  cmd = ['git', git_tool, '-p', '-U0'] + commits + ['--']
297e5dd7070Spatrick  cmd.extend(files)
298e5dd7070Spatrick  p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
299e5dd7070Spatrick  p.stdin.close()
300e5dd7070Spatrick  return p
301e5dd7070Spatrick
302e5dd7070Spatrick
303e5dd7070Spatrickdef extract_lines(patch_file):
304e5dd7070Spatrick  """Extract the changed lines in `patch_file`.
305e5dd7070Spatrick
306e5dd7070Spatrick  The return value is a dictionary mapping filename to a list of (start_line,
307e5dd7070Spatrick  line_count) pairs.
308e5dd7070Spatrick
309e5dd7070Spatrick  The input must have been produced with ``-U0``, meaning unidiff format with
310e5dd7070Spatrick  zero lines of context.  The return value is a dict mapping filename to a
311e5dd7070Spatrick  list of line `Range`s."""
312e5dd7070Spatrick  matches = {}
313e5dd7070Spatrick  for line in patch_file:
314e5dd7070Spatrick    line = convert_string(line)
315e5dd7070Spatrick    match = re.search(r'^\+\+\+\ [^/]+/(.*)', line)
316e5dd7070Spatrick    if match:
317e5dd7070Spatrick      filename = match.group(1).rstrip('\r\n')
318e5dd7070Spatrick    match = re.search(r'^@@ -[0-9,]+ \+(\d+)(,(\d+))?', line)
319e5dd7070Spatrick    if match:
320e5dd7070Spatrick      start_line = int(match.group(1))
321e5dd7070Spatrick      line_count = 1
322e5dd7070Spatrick      if match.group(3):
323e5dd7070Spatrick        line_count = int(match.group(3))
324e5dd7070Spatrick      if line_count > 0:
325e5dd7070Spatrick        matches.setdefault(filename, []).append(Range(start_line, line_count))
326e5dd7070Spatrick  return matches
327e5dd7070Spatrick
328e5dd7070Spatrick
329e5dd7070Spatrickdef filter_by_extension(dictionary, allowed_extensions):
330e5dd7070Spatrick  """Delete every key in `dictionary` that doesn't have an allowed extension.
331e5dd7070Spatrick
332e5dd7070Spatrick  `allowed_extensions` must be a collection of lowercase file extensions,
333e5dd7070Spatrick  excluding the period."""
334e5dd7070Spatrick  allowed_extensions = frozenset(allowed_extensions)
335e5dd7070Spatrick  for filename in list(dictionary.keys()):
336e5dd7070Spatrick    base_ext = filename.rsplit('.', 1)
337e5dd7070Spatrick    if len(base_ext) == 1 and '' in allowed_extensions:
338e5dd7070Spatrick        continue
339e5dd7070Spatrick    if len(base_ext) == 1 or base_ext[1].lower() not in allowed_extensions:
340e5dd7070Spatrick      del dictionary[filename]
341e5dd7070Spatrick
342e5dd7070Spatrick
343*a9ac8606Spatrickdef filter_symlinks(dictionary):
344*a9ac8606Spatrick  """Delete every key in `dictionary` that is a symlink."""
345*a9ac8606Spatrick  for filename in list(dictionary.keys()):
346*a9ac8606Spatrick    if os.path.islink(filename):
347*a9ac8606Spatrick      del dictionary[filename]
348*a9ac8606Spatrick
349*a9ac8606Spatrick
350e5dd7070Spatrickdef cd_to_toplevel():
351e5dd7070Spatrick  """Change to the top level of the git repository."""
352e5dd7070Spatrick  toplevel = run('git', 'rev-parse', '--show-toplevel')
353e5dd7070Spatrick  os.chdir(toplevel)
354e5dd7070Spatrick
355e5dd7070Spatrick
356e5dd7070Spatrickdef create_tree_from_workdir(filenames):
357e5dd7070Spatrick  """Create a new git tree with the given files from the working directory.
358e5dd7070Spatrick
359e5dd7070Spatrick  Returns the object ID (SHA-1) of the created tree."""
360e5dd7070Spatrick  return create_tree(filenames, '--stdin')
361e5dd7070Spatrick
362e5dd7070Spatrick
363e5dd7070Spatrickdef run_clang_format_and_save_to_tree(changed_lines, revision=None,
364e5dd7070Spatrick                                      binary='clang-format', style=None):
365e5dd7070Spatrick  """Run clang-format on each file and save the result to a git tree.
366e5dd7070Spatrick
367e5dd7070Spatrick  Returns the object ID (SHA-1) of the created tree."""
368e5dd7070Spatrick  def iteritems(container):
369e5dd7070Spatrick      try:
370e5dd7070Spatrick          return container.iteritems() # Python 2
371e5dd7070Spatrick      except AttributeError:
372e5dd7070Spatrick          return container.items() # Python 3
373e5dd7070Spatrick  def index_info_generator():
374e5dd7070Spatrick    for filename, line_ranges in iteritems(changed_lines):
375e5dd7070Spatrick      if revision:
376e5dd7070Spatrick        git_metadata_cmd = ['git', 'ls-tree',
377e5dd7070Spatrick                            '%s:%s' % (revision, os.path.dirname(filename)),
378e5dd7070Spatrick                            os.path.basename(filename)]
379e5dd7070Spatrick        git_metadata = subprocess.Popen(git_metadata_cmd, stdin=subprocess.PIPE,
380e5dd7070Spatrick                                        stdout=subprocess.PIPE)
381e5dd7070Spatrick        stdout = git_metadata.communicate()[0]
382e5dd7070Spatrick        mode = oct(int(stdout.split()[0], 8))
383e5dd7070Spatrick      else:
384e5dd7070Spatrick        mode = oct(os.stat(filename).st_mode)
385e5dd7070Spatrick      # Adjust python3 octal format so that it matches what git expects
386e5dd7070Spatrick      if mode.startswith('0o'):
387e5dd7070Spatrick          mode = '0' + mode[2:]
388e5dd7070Spatrick      blob_id = clang_format_to_blob(filename, line_ranges,
389e5dd7070Spatrick                                     revision=revision,
390e5dd7070Spatrick                                     binary=binary,
391e5dd7070Spatrick                                     style=style)
392e5dd7070Spatrick      yield '%s %s\t%s' % (mode, blob_id, filename)
393e5dd7070Spatrick  return create_tree(index_info_generator(), '--index-info')
394e5dd7070Spatrick
395e5dd7070Spatrick
396e5dd7070Spatrickdef create_tree(input_lines, mode):
397e5dd7070Spatrick  """Create a tree object from the given input.
398e5dd7070Spatrick
399e5dd7070Spatrick  If mode is '--stdin', it must be a list of filenames.  If mode is
400e5dd7070Spatrick  '--index-info' is must be a list of values suitable for "git update-index
401e5dd7070Spatrick  --index-info", such as "<mode> <SP> <sha1> <TAB> <filename>".  Any other mode
402e5dd7070Spatrick  is invalid."""
403e5dd7070Spatrick  assert mode in ('--stdin', '--index-info')
404e5dd7070Spatrick  cmd = ['git', 'update-index', '--add', '-z', mode]
405e5dd7070Spatrick  with temporary_index_file():
406e5dd7070Spatrick    p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
407e5dd7070Spatrick    for line in input_lines:
408e5dd7070Spatrick      p.stdin.write(to_bytes('%s\0' % line))
409e5dd7070Spatrick    p.stdin.close()
410e5dd7070Spatrick    if p.wait() != 0:
411e5dd7070Spatrick      die('`%s` failed' % ' '.join(cmd))
412e5dd7070Spatrick    tree_id = run('git', 'write-tree')
413e5dd7070Spatrick    return tree_id
414e5dd7070Spatrick
415e5dd7070Spatrick
416e5dd7070Spatrickdef clang_format_to_blob(filename, line_ranges, revision=None,
417e5dd7070Spatrick                         binary='clang-format', style=None):
418e5dd7070Spatrick  """Run clang-format on the given file and save the result to a git blob.
419e5dd7070Spatrick
420e5dd7070Spatrick  Runs on the file in `revision` if not None, or on the file in the working
421e5dd7070Spatrick  directory if `revision` is None.
422e5dd7070Spatrick
423e5dd7070Spatrick  Returns the object ID (SHA-1) of the created blob."""
424e5dd7070Spatrick  clang_format_cmd = [binary]
425e5dd7070Spatrick  if style:
426e5dd7070Spatrick    clang_format_cmd.extend(['-style='+style])
427e5dd7070Spatrick  clang_format_cmd.extend([
428e5dd7070Spatrick      '-lines=%s:%s' % (start_line, start_line+line_count-1)
429e5dd7070Spatrick      for start_line, line_count in line_ranges])
430e5dd7070Spatrick  if revision:
431e5dd7070Spatrick    clang_format_cmd.extend(['-assume-filename='+filename])
432e5dd7070Spatrick    git_show_cmd = ['git', 'cat-file', 'blob', '%s:%s' % (revision, filename)]
433e5dd7070Spatrick    git_show = subprocess.Popen(git_show_cmd, stdin=subprocess.PIPE,
434e5dd7070Spatrick                                stdout=subprocess.PIPE)
435e5dd7070Spatrick    git_show.stdin.close()
436e5dd7070Spatrick    clang_format_stdin = git_show.stdout
437e5dd7070Spatrick  else:
438e5dd7070Spatrick    clang_format_cmd.extend([filename])
439e5dd7070Spatrick    git_show = None
440e5dd7070Spatrick    clang_format_stdin = subprocess.PIPE
441e5dd7070Spatrick  try:
442e5dd7070Spatrick    clang_format = subprocess.Popen(clang_format_cmd, stdin=clang_format_stdin,
443e5dd7070Spatrick                                    stdout=subprocess.PIPE)
444e5dd7070Spatrick    if clang_format_stdin == subprocess.PIPE:
445e5dd7070Spatrick      clang_format_stdin = clang_format.stdin
446e5dd7070Spatrick  except OSError as e:
447e5dd7070Spatrick    if e.errno == errno.ENOENT:
448e5dd7070Spatrick      die('cannot find executable "%s"' % binary)
449e5dd7070Spatrick    else:
450e5dd7070Spatrick      raise
451e5dd7070Spatrick  clang_format_stdin.close()
452e5dd7070Spatrick  hash_object_cmd = ['git', 'hash-object', '-w', '--path='+filename, '--stdin']
453e5dd7070Spatrick  hash_object = subprocess.Popen(hash_object_cmd, stdin=clang_format.stdout,
454e5dd7070Spatrick                                 stdout=subprocess.PIPE)
455e5dd7070Spatrick  clang_format.stdout.close()
456e5dd7070Spatrick  stdout = hash_object.communicate()[0]
457e5dd7070Spatrick  if hash_object.returncode != 0:
458e5dd7070Spatrick    die('`%s` failed' % ' '.join(hash_object_cmd))
459e5dd7070Spatrick  if clang_format.wait() != 0:
460e5dd7070Spatrick    die('`%s` failed' % ' '.join(clang_format_cmd))
461e5dd7070Spatrick  if git_show and git_show.wait() != 0:
462e5dd7070Spatrick    die('`%s` failed' % ' '.join(git_show_cmd))
463e5dd7070Spatrick  return convert_string(stdout).rstrip('\r\n')
464e5dd7070Spatrick
465e5dd7070Spatrick
466e5dd7070Spatrick@contextlib.contextmanager
467e5dd7070Spatrickdef temporary_index_file(tree=None):
468e5dd7070Spatrick  """Context manager for setting GIT_INDEX_FILE to a temporary file and deleting
469e5dd7070Spatrick  the file afterward."""
470e5dd7070Spatrick  index_path = create_temporary_index(tree)
471e5dd7070Spatrick  old_index_path = os.environ.get('GIT_INDEX_FILE')
472e5dd7070Spatrick  os.environ['GIT_INDEX_FILE'] = index_path
473e5dd7070Spatrick  try:
474e5dd7070Spatrick    yield
475e5dd7070Spatrick  finally:
476e5dd7070Spatrick    if old_index_path is None:
477e5dd7070Spatrick      del os.environ['GIT_INDEX_FILE']
478e5dd7070Spatrick    else:
479e5dd7070Spatrick      os.environ['GIT_INDEX_FILE'] = old_index_path
480e5dd7070Spatrick    os.remove(index_path)
481e5dd7070Spatrick
482e5dd7070Spatrick
483e5dd7070Spatrickdef create_temporary_index(tree=None):
484e5dd7070Spatrick  """Create a temporary index file and return the created file's path.
485e5dd7070Spatrick
486e5dd7070Spatrick  If `tree` is not None, use that as the tree to read in.  Otherwise, an
487e5dd7070Spatrick  empty index is created."""
488e5dd7070Spatrick  gitdir = run('git', 'rev-parse', '--git-dir')
489e5dd7070Spatrick  path = os.path.join(gitdir, temp_index_basename)
490e5dd7070Spatrick  if tree is None:
491e5dd7070Spatrick    tree = '--empty'
492e5dd7070Spatrick  run('git', 'read-tree', '--index-output='+path, tree)
493e5dd7070Spatrick  return path
494e5dd7070Spatrick
495e5dd7070Spatrick
496e5dd7070Spatrickdef print_diff(old_tree, new_tree):
497e5dd7070Spatrick  """Print the diff between the two trees to stdout."""
498e5dd7070Spatrick  # We use the porcelain 'diff' and not plumbing 'diff-tree' because the output
499e5dd7070Spatrick  # is expected to be viewed by the user, and only the former does nice things
500e5dd7070Spatrick  # like color and pagination.
501e5dd7070Spatrick  #
502e5dd7070Spatrick  # We also only print modified files since `new_tree` only contains the files
503e5dd7070Spatrick  # that were modified, so unmodified files would show as deleted without the
504e5dd7070Spatrick  # filter.
505e5dd7070Spatrick  subprocess.check_call(['git', 'diff', '--diff-filter=M', old_tree, new_tree,
506e5dd7070Spatrick                         '--'])
507e5dd7070Spatrick
508e5dd7070Spatrick
509e5dd7070Spatrickdef apply_changes(old_tree, new_tree, force=False, patch_mode=False):
510e5dd7070Spatrick  """Apply the changes in `new_tree` to the working directory.
511e5dd7070Spatrick
512e5dd7070Spatrick  Bails if there are local changes in those files and not `force`.  If
513e5dd7070Spatrick  `patch_mode`, runs `git checkout --patch` to select hunks interactively."""
514e5dd7070Spatrick  changed_files = run('git', 'diff-tree', '--diff-filter=M', '-r', '-z',
515e5dd7070Spatrick                      '--name-only', old_tree,
516e5dd7070Spatrick                      new_tree).rstrip('\0').split('\0')
517e5dd7070Spatrick  if not force:
518e5dd7070Spatrick    unstaged_files = run('git', 'diff-files', '--name-status', *changed_files)
519e5dd7070Spatrick    if unstaged_files:
520e5dd7070Spatrick      print('The following files would be modified but '
521e5dd7070Spatrick                'have unstaged changes:', file=sys.stderr)
522e5dd7070Spatrick      print(unstaged_files, file=sys.stderr)
523e5dd7070Spatrick      print('Please commit, stage, or stash them first.', file=sys.stderr)
524e5dd7070Spatrick      sys.exit(2)
525e5dd7070Spatrick  if patch_mode:
526e5dd7070Spatrick    # In patch mode, we could just as well create an index from the new tree
527e5dd7070Spatrick    # and checkout from that, but then the user will be presented with a
528e5dd7070Spatrick    # message saying "Discard ... from worktree".  Instead, we use the old
529e5dd7070Spatrick    # tree as the index and checkout from new_tree, which gives the slightly
530e5dd7070Spatrick    # better message, "Apply ... to index and worktree".  This is not quite
531e5dd7070Spatrick    # right, since it won't be applied to the user's index, but oh well.
532e5dd7070Spatrick    with temporary_index_file(old_tree):
533e5dd7070Spatrick      subprocess.check_call(['git', 'checkout', '--patch', new_tree])
534e5dd7070Spatrick    index_tree = old_tree
535e5dd7070Spatrick  else:
536e5dd7070Spatrick    with temporary_index_file(new_tree):
537e5dd7070Spatrick      run('git', 'checkout-index', '-a', '-f')
538e5dd7070Spatrick  return changed_files
539e5dd7070Spatrick
540e5dd7070Spatrick
541e5dd7070Spatrickdef run(*args, **kwargs):
542e5dd7070Spatrick  stdin = kwargs.pop('stdin', '')
543e5dd7070Spatrick  verbose = kwargs.pop('verbose', True)
544e5dd7070Spatrick  strip = kwargs.pop('strip', True)
545e5dd7070Spatrick  for name in kwargs:
546e5dd7070Spatrick    raise TypeError("run() got an unexpected keyword argument '%s'" % name)
547e5dd7070Spatrick  p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
548e5dd7070Spatrick                       stdin=subprocess.PIPE)
549e5dd7070Spatrick  stdout, stderr = p.communicate(input=stdin)
550e5dd7070Spatrick
551e5dd7070Spatrick  stdout = convert_string(stdout)
552e5dd7070Spatrick  stderr = convert_string(stderr)
553e5dd7070Spatrick
554e5dd7070Spatrick  if p.returncode == 0:
555e5dd7070Spatrick    if stderr:
556e5dd7070Spatrick      if verbose:
557e5dd7070Spatrick        print('`%s` printed to stderr:' % ' '.join(args), file=sys.stderr)
558e5dd7070Spatrick      print(stderr.rstrip(), file=sys.stderr)
559e5dd7070Spatrick    if strip:
560e5dd7070Spatrick      stdout = stdout.rstrip('\r\n')
561e5dd7070Spatrick    return stdout
562e5dd7070Spatrick  if verbose:
563e5dd7070Spatrick    print('`%s` returned %s' % (' '.join(args), p.returncode), file=sys.stderr)
564e5dd7070Spatrick  if stderr:
565e5dd7070Spatrick    print(stderr.rstrip(), file=sys.stderr)
566e5dd7070Spatrick  sys.exit(2)
567e5dd7070Spatrick
568e5dd7070Spatrick
569e5dd7070Spatrickdef die(message):
570e5dd7070Spatrick  print('error:', message, file=sys.stderr)
571e5dd7070Spatrick  sys.exit(2)
572e5dd7070Spatrick
573e5dd7070Spatrick
574e5dd7070Spatrickdef to_bytes(str_input):
575e5dd7070Spatrick    # Encode to UTF-8 to get binary data.
576e5dd7070Spatrick    if isinstance(str_input, bytes):
577e5dd7070Spatrick        return str_input
578e5dd7070Spatrick    return str_input.encode('utf-8')
579e5dd7070Spatrick
580e5dd7070Spatrick
581e5dd7070Spatrickdef to_string(bytes_input):
582e5dd7070Spatrick    if isinstance(bytes_input, str):
583e5dd7070Spatrick        return bytes_input
584e5dd7070Spatrick    return bytes_input.encode('utf-8')
585e5dd7070Spatrick
586e5dd7070Spatrick
587e5dd7070Spatrickdef convert_string(bytes_input):
588e5dd7070Spatrick    try:
589e5dd7070Spatrick        return to_string(bytes_input.decode('utf-8'))
590e5dd7070Spatrick    except AttributeError: # 'str' object has no attribute 'decode'.
591e5dd7070Spatrick        return str(bytes_input)
592e5dd7070Spatrick    except UnicodeError:
593e5dd7070Spatrick        return str(bytes_input)
594e5dd7070Spatrick
595e5dd7070Spatrickif __name__ == '__main__':
596e5dd7070Spatrick  main()
597