Index: git_hyper_blame.py |
diff --git a/git_hyper_blame.py b/git_hyper_blame.py |
new file mode 100755 |
index 0000000000000000000000000000000000000000..17424511ab178c62a7d22f8e3f77f343053b46bc |
--- /dev/null |
+++ b/git_hyper_blame.py |
@@ -0,0 +1,266 @@ |
+#!/usr/bin/env python |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is governed by a BSD-style license that can be |
+# found in the LICENSE file. |
+ |
+"""Wrapper around git blame that ignores certain commits. |
+""" |
+ |
+from __future__ import print_function |
+ |
+import argparse |
+import collections |
+import logging |
+import os |
+import subprocess2 |
+import sys |
+ |
+import git_common |
+import git_dates |
+ |
+ |
+logging.getLogger().setLevel(logging.INFO) |
+ |
+ |
+class Commit(object): |
+ """Info about a commit.""" |
+ def __init__(self, commithash): |
+ self.commithash = commithash |
+ self.author = None |
+ self.author_mail = None |
+ self.author_time = None |
+ self.author_tz = None |
+ self.committer = None |
+ self.committer_mail = None |
+ self.committer_time = None |
+ self.committer_tz = None |
+ self.summary = None |
+ self.boundary = None |
+ self.previous = None |
+ self.filename = None |
+ |
+ def __repr__(self): # pragma: no cover |
+ return '<Commit %s>' % self.commithash |
+ |
+ |
+BlameLine = collections.namedtuple( |
+ 'BlameLine', |
+ 'commit context lineno_then lineno_now modified') |
+ |
+ |
+def parse_blame(blameoutput): |
+ """Parses the output of git blame -p into a data structure.""" |
+ lines = blameoutput.split('\n') |
+ i = 0 |
+ commits = {} |
+ |
+ while i < len(lines): |
+ # Read a commit line and parse it. |
+ line = lines[i] |
+ i += 1 |
+ if not line.strip(): |
+ continue |
+ commitline = line.split() |
+ commithash = commitline[0] |
+ lineno_then = int(commitline[1]) |
+ lineno_now = int(commitline[2]) |
+ |
+ try: |
+ commit = commits[commithash] |
+ except KeyError: |
+ commit = Commit(commithash) |
+ commits[commithash] = commit |
+ |
+ # Read commit details until we find a context line. |
+ while i < len(lines): |
+ line = lines[i] |
+ i += 1 |
+ if line.startswith('\t'): |
+ break |
+ |
+ try: |
+ key, value = line.split(' ', 1) |
+ except ValueError: |
+ key = line |
+ value = True |
+ setattr(commit, key.replace('-', '_'), value) |
+ |
+ context = line[1:] |
+ |
+ yield BlameLine(commit, context, lineno_then, lineno_now, False) |
+ |
+ |
+def print_table(table, colsep=' ', rowsep='\n', align=None, out=sys.stdout): |
+ """Print a 2D rectangular array, aligning columns with spaces. |
+ |
+ Args: |
+ align: Optional string of 'l' and 'r', designating whether each column is |
+ left- or right-aligned. Defaults to left aligned. |
+ """ |
+ if len(table) == 0: |
+ return |
+ |
+ colwidths = None |
+ for row in table: |
+ if colwidths is None: |
+ colwidths = [len(x) for x in row] |
+ else: |
+ colwidths = [max(colwidths[i], len(x)) for i, x in enumerate(row)] |
+ |
+ if align is None: # pragma: no cover |
+ align = 'l' * len(colwidths) |
+ |
+ for row in table: |
+ cells = [] |
+ for i, cell in enumerate(row): |
+ padding = ' ' * (colwidths[i] - len(cell)) |
+ if align[i] == 'r': |
+ cell = padding + cell |
+ elif i < len(row) - 1: |
+ # Do not pad the final column if left-aligned. |
+ cell += padding |
+ cells.append(cell) |
+ try: |
+ print(*cells, sep=colsep, end=rowsep, file=out) |
+ except IOError: # pragma: no cover |
+ # Can happen on Windows if the pipe is closed early. |
+ pass |
+ |
+ |
+def pretty_print(parsedblame, show_filenames=False, out=sys.stdout): |
+ """Pretty-prints the output of parse_blame.""" |
+ table = [] |
+ for line in parsedblame: |
+ author_time = git_dates.timestamp_offset_to_datetime( |
+ line.commit.author_time, line.commit.author_tz) |
+ row = [line.commit.commithash[:8], |
+ '(' + line.commit.author, |
+ git_dates.datetime_string(author_time), |
+ str(line.lineno_now) + ('*' if line.modified else '') + ')', |
+ line.context] |
+ if show_filenames: |
+ row.insert(1, line.commit.filename) |
+ table.append(row) |
+ print_table(table, align='llllrl' if show_filenames else 'lllrl', out=out) |
+ |
+ |
+def get_parsed_blame(filename, revision='HEAD'): |
+ blame = git_common.blame(filename, revision=revision, porcelain=True) |
+ return list(parse_blame(blame)) |
+ |
+ |
+def hyper_blame(ignored, filename, revision='HEAD', out=sys.stdout, |
+ err=sys.stderr): |
+ # Map from commit to parsed blame from that commit. |
+ blame_from = {} |
+ |
+ def cache_blame_from(filename, commithash): |
+ try: |
+ return blame_from[commithash] |
+ except KeyError: |
+ parsed = get_parsed_blame(filename, commithash) |
+ blame_from[commithash] = parsed |
+ return parsed |
+ |
+ try: |
+ parsed = cache_blame_from(filename, git_common.hash_one(revision)) |
+ except subprocess2.CalledProcessError as e: |
+ err.write(e.stderr) |
+ return e.returncode |
+ |
+ new_parsed = [] |
+ |
+ # We don't show filenames in blame output unless we have to. |
+ show_filenames = False |
+ |
+ for line in parsed: |
+ # If a line references an ignored commit, blame that commit's parent |
+ # repeatedly until we find a non-ignored commit. |
+ while line.commit.commithash in ignored: |
+ if line.commit.previous is None: |
+ # You can't ignore the commit that added this file. |
+ break |
+ |
+ previouscommit, previousfilename = line.commit.previous.split(' ', 1) |
+ parent_blame = cache_blame_from(previousfilename, previouscommit) |
+ |
+ if len(parent_blame) == 0: |
+ # The previous version of this file was empty, therefore, you can't |
+ # ignore this commit. |
+ break |
+ |
+ # line.lineno_then is the line number in question at line.commit. |
+ # TODO(mgiuca): This will be incorrect if line.commit added or removed |
+ # lines. Translate that line number so that it refers to the position of |
+ # the same line on previouscommit. |
+ lineno_previous = line.lineno_then |
+ logging.debug('ignore commit %s on line p%d/t%d/n%d', |
+ line.commit.commithash, lineno_previous, line.lineno_then, |
+ line.lineno_now) |
+ |
+ # Get the line at lineno_previous in the parent commit. |
+ assert lineno_previous > 0 |
+ try: |
+ newline = parent_blame[lineno_previous - 1] |
+ except IndexError: |
+ # lineno_previous is a guess, so it may be past the end of the file. |
+ # Just grab the last line in the file. |
+ newline = parent_blame[-1] |
+ |
+ # Replace the commit and lineno_then, but not the lineno_now or context. |
+ logging.debug(' replacing with %r', newline) |
+ line = BlameLine(newline.commit, line.context, lineno_previous, |
+ line.lineno_now, True) |
+ |
+ # If any line has a different filename to the file's current name, turn on |
+ # filename display for the entire blame output. |
+ if line.commit.filename != filename: |
+ show_filenames = True |
+ |
+ new_parsed.append(line) |
+ |
+ pretty_print(new_parsed, show_filenames=show_filenames, out=out) |
+ |
+ return 0 |
+ |
+def main(args, stdout=sys.stdout, stderr=sys.stderr): |
+ parser = argparse.ArgumentParser( |
+ prog='git hyper-blame', |
+ description='git blame with support for ignoring certain commits.') |
+ parser.add_argument('-i', metavar='REVISION', action='append', dest='ignored', |
+ default=[], help='a revision to ignore') |
+ parser.add_argument('revision', nargs='?', default='HEAD', metavar='REVISION', |
+ help='revision to look at') |
+ parser.add_argument('filename', metavar='FILE', help='filename to blame') |
+ |
+ args = parser.parse_args(args) |
+ try: |
+ repo_root = git_common.repo_root() |
+ except subprocess2.CalledProcessError as e: |
+ stderr.write(e.stderr) |
+ return e.returncode |
+ |
+ # Make filename relative to the repository root, and cd to the root dir (so |
+ # all filenames throughout this script are relative to the root). |
+ filename = os.path.relpath(args.filename, repo_root) |
+ os.chdir(repo_root) |
+ |
+ # Normalize filename so we can compare it to other filenames git gives us. |
+ filename = os.path.normpath(filename) |
+ filename = os.path.normcase(filename) |
+ |
+ ignored = set() |
+ for c in args.ignored: |
+ try: |
+ ignored.add(git_common.hash_one(c)) |
+ except subprocess2.CalledProcessError as e: |
+ # Custom error message (the message from git-rev-parse is inappropriate). |
+ stderr.write('fatal: unknown revision \'%s\'.\n' % c) |
+ return e.returncode |
+ |
+ return hyper_blame(ignored, filename, args.revision, out=stdout, err=stderr) |
+ |
+ |
+if __name__ == '__main__': # pragma: no cover |
+ with git_common.less() as less_input: |
+ sys.exit(main(sys.argv[1:], stdout=less_input)) |