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

Unified 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: Respond to review. Created 4 years, 11 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 side-by-side diff with in-line comments
Download patch
« no previous file with comments | « git_dates.py ('k') | man/html/depot_tools.html » ('j') | no next file with comments »
Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
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))
« no previous file with comments | « git_dates.py ('k') | man/html/depot_tools.html » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698