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 |