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 print(*cells, sep=colsep, end=rowsep, file=out) | |
124 | |
125 | |
126 def pretty_print(parsedblame, show_filenames=False, out=sys.stdout): | |
127 """Pretty-prints the output of parse_blame.""" | |
128 table = [] | |
129 for line in parsedblame: | |
130 author_time = git_dates.timestamp_offset_to_datetime( | |
131 line.commit.author_time, line.commit.author_tz) | |
132 row = [line.commit.commithash[:8], | |
133 '(' + line.commit.author, | |
134 git_dates.datetime_string(author_time), | |
135 str(line.lineno_now) + ('*' if line.modified else '') + ')', | |
136 line.context] | |
137 if show_filenames: | |
138 row.insert(1, line.commit.filename) | |
139 table.append(row) | |
140 print_table(table, align='llllrl' if show_filenames else 'lllrl', out=out) | |
141 | |
142 | |
143 def get_parsed_blame(filename, revision='HEAD'): | |
144 blame = git_common.blame(filename, revision=revision, porcelain=True) | |
145 return list(parse_blame(blame)) | |
146 | |
147 | |
148 def hyperblame(ignored, filename, revision='HEAD', out=sys.stdout): | |
149 # Map from commit to parsed blame from that commit. | |
150 blame_from = {} | |
151 | |
152 def cache_blame_from(filename, commithash): | |
153 try: | |
154 return blame_from[commithash] | |
155 except KeyError: | |
156 parsed = get_parsed_blame(filename, commithash) | |
157 blame_from[commithash] = parsed | |
158 return parsed | |
159 | |
160 try: | |
161 parsed = cache_blame_from(filename, git_common.hash_one(revision)) | |
162 except subprocess2.CalledProcessError as e: | |
163 sys.stderr.write(e.stderr) | |
164 return e.returncode | |
165 | |
166 new_parsed = [] | |
167 | |
168 # We don't show filenames in blame output unless we have to. | |
169 show_filenames = False | |
170 | |
171 for line in parsed: | |
172 # If a line references an ignored commit, blame that commit's parent | |
173 # repeatedly until we find a non-ignored commit. | |
174 while line.commit.commithash in ignored: | |
175 if line.commit.previous is None: | |
176 # You can't ignore the commit that added this file. | |
177 break | |
178 | |
179 previouscommit, previousfilename = line.commit.previous.split(' ', 1) | |
180 parent_blame = cache_blame_from(previousfilename, previouscommit) | |
181 | |
182 if len(parent_blame) == 0: | |
183 # The previous version of this file was empty, therefore, you can't | |
184 # ignore this commit. | |
185 break | |
186 | |
187 # line.lineno_then is the line number in question at line.commit. | |
188 # TODO(mgiuca): This will be incorrect if line.commit added or removed | |
189 # lines. Translate that line number so that it refers to the position of | |
190 # the same line on previouscommit. | |
191 lineno_previous = line.lineno_then | |
192 logging.debug('ignore commit %s on line p%d/t%d/n%d', | |
193 line.commit.commithash, lineno_previous, line.lineno_then, | |
194 line.lineno_now) | |
195 | |
196 # Get the line at lineno_previous in the parent commit. | |
197 assert lineno_previous > 0 | |
198 try: | |
199 newline = parent_blame[lineno_previous - 1] | |
200 except IndexError: | |
201 # lineno_previous is a guess, so it may be past the end of the file. | |
202 # Just grab the last line in the file. | |
203 newline = parent_blame[-1] | |
204 | |
205 # Replace the commit and lineno_then, but not the lineno_now or context. | |
206 logging.debug(' replacing with %r', newline) | |
207 line = BlameLine(newline.commit, line.context, lineno_previous, | |
208 line.lineno_now, True) | |
209 | |
210 # If any line has a different filename to the file's current name, turn on | |
211 # filename display for the entire blame output. | |
212 if line.commit.filename != filename: | |
213 show_filenames = True | |
214 | |
215 new_parsed.append(line) | |
216 | |
217 pretty_print(new_parsed, show_filenames=show_filenames, out=out) | |
218 | |
219 return 0 | |
220 | |
221 def main(args=None): # pragma: no cover | |
iannucci
2016/01/29 19:29:25
no covers are sad :(
Matt Giuca
2016/02/01 03:51:06
I didn't think main was worth testing. But, I adde
| |
222 if args is None: | |
223 args = sys.argv[1:] | |
224 | |
225 parser = argparse.ArgumentParser( | |
226 prog='git hyper-blame', | |
227 description='git blame with support for ignoring certain commits.') | |
228 parser.add_argument('-i', metavar='REVISION', action='append', dest='ignored', | |
229 help='a revision to ignore') | |
230 parser.add_argument('revision', nargs='?', default='HEAD', metavar='REVISION', | |
231 help='revision to look at') | |
232 parser.add_argument('filename', metavar='FILE', help='filename to blame') | |
233 | |
234 args = parser.parse_args() | |
235 try: | |
236 repo_root = git_common.repo_root() | |
237 except subprocess2.CalledProcessError as e: | |
238 sys.stderr.write(e.stderr) | |
239 return e.returncode | |
240 | |
241 # Make filename relative to the repository root, and cd to the root dir (so | |
242 # all filenames throughout this script are relative to the root). | |
243 filename = os.path.relpath(args.filename, repo_root) | |
244 os.chdir(repo_root) | |
245 | |
246 # Normalize filename so we can compare it to other filenames git gives us. | |
247 filename = os.path.normpath(filename) | |
248 filename = os.path.normcase(filename) | |
249 | |
250 ignored = args.ignored | |
251 if ignored is None: | |
252 ignored = [] | |
253 ignored = frozenset(git_common.hash_one(c) for c in ignored) | |
254 | |
255 with git_common.less() as less_input: | |
256 return hyperblame(ignored, filename, args.revision, out=less_input) | |
257 | |
258 | |
259 if __name__ == '__main__': # pragma: no cover | |
260 sys.exit(main()) | |
OLD | NEW |