Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(161)

Side by Side Diff: git_hyper_blame.py

Issue 1559943003: Added git hyper-blame, a tool that skips unwanted commits in git blame. (Closed) Base URL: https://chromium.googlesource.com/chromium/tools/depot_tools.git@master
Patch Set: Fix comment. Created 4 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(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())
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698