Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 1 #!/usr/bin/env python | |
| 2 # Copyright 2016 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 """Wrapper around git blame that ignores certain commits. | |
| 7 """ | |
| 8 | |
| 9 from __future__ import print_function | |
| 10 | |
| 11 import argparse | |
| 12 import collections | |
| 13 import logging | |
| 14 import os | |
| 15 import subprocess2 | |
| 16 import sys | |
| 17 | |
| 18 import git_common | |
| 19 import git_dates | |
| 20 | |
| 21 | |
| 22 logging.getLogger().setLevel(logging.INFO) | |
| 23 | |
| 24 | |
| 25 class Commit(object): | |
| 26 """Info about a commit.""" | |
| 27 def __init__(self, commithash): | |
| 28 self.commithash = commithash | |
| 29 self.author = None | |
| 30 self.author_mail = None | |
| 31 self.author_time = None | |
| 32 self.author_tz = None | |
| 33 self.committer = None | |
| 34 self.committer_mail = None | |
| 35 self.committer_time = None | |
| 36 self.committer_tz = None | |
| 37 self.summary = None | |
| 38 self.boundary = None | |
| 39 self.previous = None | |
| 40 self.filename = None | |
| 41 | |
| 42 def __repr__(self): # pragma: no cover | |
| 43 return '<Commit %s>' % self.commithash | |
| 44 | |
| 45 | |
| 46 BlameLine = collections.namedtuple( | |
| 47 'BlameLine', | |
| 48 'commit context lineno_then lineno_now modified') | |
| 49 | |
| 50 | |
| 51 def parse_blame(blameoutput): | |
| 52 """Parses the output of git blame -p into a data structure.""" | |
| 53 lines = blameoutput.split('\n') | |
| 54 i = 0 | |
| 55 commits = {} | |
| 56 | |
| 57 while i < len(lines): | |
| 58 # Read a commit line and parse it. | |
| 59 line = lines[i] | |
| 60 i += 1 | |
| 61 if not line.strip(): | |
| 62 continue | |
| 63 commitline = line.split() | |
| 64 commithash = commitline[0] | |
| 65 lineno_then = int(commitline[1]) | |
| 66 lineno_now = int(commitline[2]) | |
| 67 | |
| 68 try: | |
| 69 commit = commits[commithash] | |
| 70 except KeyError: | |
| 71 commit = Commit(commithash) | |
| 72 commits[commithash] = commit | |
| 73 | |
| 74 # Read commit details until we find a context line. | |
| 75 while i < len(lines): | |
| 76 line = lines[i] | |
| 77 i += 1 | |
| 78 if line.startswith('\t'): | |
| 79 break | |
| 80 | |
| 81 try: | |
| 82 key, value = line.split(' ', 1) | |
| 83 except ValueError: | |
| 84 key = line | |
| 85 value = True | |
| 86 setattr(commit, key.replace('-', '_'), value) | |
| 87 | |
| 88 context = line[1:] | |
| 89 | |
| 90 yield BlameLine(commit, context, lineno_then, lineno_now, False) | |
| 91 | |
| 92 | |
| 93 def print_table(table, colsep=' ', rowsep='\n', align=None, out=sys.stdout): | |
| 94 """Print a 2D rectangular array, aligning columns with spaces. | |
| 95 | |
| 96 Args: | |
| 97 align: Optional string of 'l' and 'r', designating whether each column is | |
| 98 left- or right-aligned. Defaults to left aligned. | |
| 99 """ | |
| 100 if len(table) == 0: | |
| 101 return | |
| 102 | |
| 103 colwidths = None | |
| 104 for row in table: | |
| 105 if colwidths is None: | |
| 106 colwidths = [len(x) for x in row] | |
| 107 else: | |
| 108 colwidths = [max(colwidths[i], len(x)) for i, x in enumerate(row)] | |
| 109 | |
| 110 if align is None: # pragma: no cover | |
| 111 align = 'l' * len(colwidths) | |
| 112 | |
| 113 for row in table: | |
| 114 cells = [] | |
| 115 for i, cell in enumerate(row): | |
| 116 padding = ' ' * (colwidths[i] - len(cell)) | |
| 117 if align[i] == 'r': | |
| 118 cell = padding + cell | |
| 119 elif i < len(row) - 1: | |
| 120 # Do not pad the final column if left-aligned. | |
| 121 cell += padding | |
| 122 cells.append(cell) | |
| 123 try: | |
| 124 print(*cells, sep=colsep, end=rowsep, file=out) | |
| 125 except IOError: # pragma: no cover | |
| 126 # Can happen on Windows if the pipe is closed early. | |
| 127 pass | |
| 128 | |
| 129 | |
| 130 def pretty_print(parsedblame, show_filenames=False, out=sys.stdout): | |
| 131 """Pretty-prints the output of parse_blame.""" | |
| 132 table = [] | |
| 133 for line in parsedblame: | |
| 134 author_time = git_dates.timestamp_offset_to_datetime( | |
| 135 line.commit.author_time, line.commit.author_tz) | |
| 136 row = [line.commit.commithash[:8], | |
| 137 '(' + line.commit.author, | |
| 138 git_dates.datetime_string(author_time), | |
| 139 str(line.lineno_now) + ('*' if line.modified else '') + ')', | |
| 140 line.context] | |
| 141 if show_filenames: | |
| 142 row.insert(1, line.commit.filename) | |
| 143 table.append(row) | |
| 144 print_table(table, align='llllrl' if show_filenames else 'lllrl', out=out) | |
| 145 | |
| 146 | |
| 147 def get_parsed_blame(filename, revision='HEAD'): | |
| 148 blame = git_common.blame(filename, revision=revision, porcelain=True) | |
| 149 return list(parse_blame(blame)) | |
| 150 | |
| 151 | |
| 152 def hyperblame(ignored, filename, revision='HEAD', out=sys.stdout, | |
| 153 err=sys.stderr): | |
| 154 # Map from commit to parsed blame from that commit. | |
| 155 blame_from = {} | |
| 156 | |
| 157 def cache_blame_from(filename, commithash): | |
| 158 try: | |
| 159 return blame_from[commithash] | |
| 160 except KeyError: | |
| 161 parsed = get_parsed_blame(filename, commithash) | |
| 162 blame_from[commithash] = parsed | |
| 163 return parsed | |
| 164 | |
| 165 try: | |
| 166 parsed = cache_blame_from(filename, git_common.hash_one(revision)) | |
| 167 except subprocess2.CalledProcessError as e: | |
| 168 err.write(e.stderr) | |
| 169 return e.returncode | |
| 170 | |
| 171 new_parsed = [] | |
| 172 | |
| 173 # We don't show filenames in blame output unless we have to. | |
| 174 show_filenames = False | |
| 175 | |
| 176 for line in parsed: | |
| 177 # If a line references an ignored commit, blame that commit's parent | |
| 178 # repeatedly until we find a non-ignored commit. | |
| 179 while line.commit.commithash in ignored: | |
| 180 if line.commit.previous is None: | |
| 181 # You can't ignore the commit that added this file. | |
| 182 break | |
| 183 | |
| 184 previouscommit, previousfilename = line.commit.previous.split(' ', 1) | |
| 185 parent_blame = cache_blame_from(previousfilename, previouscommit) | |
| 186 | |
| 187 if len(parent_blame) == 0: | |
| 188 # The previous version of this file was empty, therefore, you can't | |
| 189 # ignore this commit. | |
| 190 break | |
| 191 | |
| 192 # line.lineno_then is the line number in question at line.commit. | |
| 193 # TODO(mgiuca): This will be incorrect if line.commit added or removed | |
| 194 # lines. Translate that line number so that it refers to the position of | |
| 195 # the same line on previouscommit. | |
| 196 lineno_previous = line.lineno_then | |
| 197 logging.debug('ignore commit %s on line p%d/t%d/n%d', | |
| 198 line.commit.commithash, lineno_previous, line.lineno_then, | |
| 199 line.lineno_now) | |
| 200 | |
| 201 # Get the line at lineno_previous in the parent commit. | |
| 202 assert lineno_previous > 0 | |
| 203 try: | |
| 204 newline = parent_blame[lineno_previous - 1] | |
| 205 except IndexError: | |
| 206 # lineno_previous is a guess, so it may be past the end of the file. | |
| 207 # Just grab the last line in the file. | |
| 208 newline = parent_blame[-1] | |
| 209 | |
| 210 # Replace the commit and lineno_then, but not the lineno_now or context. | |
| 211 logging.debug(' replacing with %r', newline) | |
| 212 line = BlameLine(newline.commit, line.context, lineno_previous, | |
| 213 line.lineno_now, True) | |
| 214 | |
| 215 # If any line has a different filename to the file's current name, turn on | |
| 216 # filename display for the entire blame output. | |
| 217 if line.commit.filename != filename: | |
| 218 show_filenames = True | |
| 219 | |
| 220 new_parsed.append(line) | |
| 221 | |
| 222 pretty_print(new_parsed, show_filenames=show_filenames, out=out) | |
| 223 | |
| 224 return 0 | |
| 225 | |
| 226 def main(args=None, stdout=sys.stdout, stderr=sys.stderr): | |
|
iannucci
2016/02/03 01:14:51
make args mandatory
Matt Giuca
2016/02/03 07:27:58
I'm following the convention established by Guido
| |
| 227 if args is None: # pragma: no cover | |
| 228 args = sys.argv[1:] | |
|
iannucci
2016/02/03 01:14:51
then delete
Matt Giuca
2016/02/03 07:27:58
Done.
| |
| 229 | |
| 230 parser = argparse.ArgumentParser( | |
| 231 prog='git hyper-blame', | |
| 232 description='git blame with support for ignoring certain commits.') | |
| 233 parser.add_argument('-i', metavar='REVISION', action='append', dest='ignored', | |
| 234 default=[], help='a revision to ignore') | |
| 235 parser.add_argument('revision', nargs='?', default='HEAD', metavar='REVISION', | |
| 236 help='revision to look at') | |
| 237 parser.add_argument('filename', metavar='FILE', help='filename to blame') | |
| 238 | |
| 239 args = parser.parse_args(args) | |
| 240 try: | |
| 241 repo_root = git_common.repo_root() | |
| 242 except subprocess2.CalledProcessError as e: | |
| 243 stderr.write(e.stderr) | |
| 244 return e.returncode | |
| 245 | |
| 246 # Make filename relative to the repository root, and cd to the root dir (so | |
| 247 # all filenames throughout this script are relative to the root). | |
| 248 filename = os.path.relpath(args.filename, repo_root) | |
| 249 os.chdir(repo_root) | |
| 250 | |
| 251 # Normalize filename so we can compare it to other filenames git gives us. | |
| 252 filename = os.path.normpath(filename) | |
| 253 filename = os.path.normcase(filename) | |
| 254 | |
| 255 ignored = set() | |
| 256 for c in args.ignored: | |
| 257 try: | |
| 258 ignored.add(git_common.hash_one(c)) | |
| 259 except subprocess2.CalledProcessError as e: | |
| 260 # Custom error message (the message from git-rev-parse is inappropriate). | |
| 261 stderr.write('fatal: unknown revision \'%s\'.\n' % c) | |
| 262 return e.returncode | |
| 263 | |
| 264 return hyperblame(ignored, filename, args.revision, out=stdout, err=stderr) | |
| 265 | |
| 266 | |
| 267 if __name__ == '__main__': # pragma: no cover | |
| 268 with git_common.less() as less_input: | |
| 269 sys.exit(main(stdout=less_input)) | |
|
iannucci
2016/02/03 01:14:51
then pass sys.argv[1:] here
Matt Giuca
2016/02/03 07:27:58
Done.
| |
| OLD | NEW |