blob: 6a8dae88aadcaf41ef7838ad5e1deda05067039a [file] [log] [blame]
#!/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
from __future__ import unicode_literals
import argparse
import collections
import logging
import os
import subprocess2
import sys
import git_common
import git_dates
import setup_color
logging.getLogger().setLevel(logging.INFO)
DEFAULT_IGNORE_FILE_NAME = '.git-blame-ignore-revs'
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(outbuf, table, align):
"""Print a 2D rectangular array, aligning columns with spaces.
Args:
align: string of 'l' and 'r', designating whether each column is left- or
right-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)]
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.encode('utf-8', 'replace'))
try:
outbuf.write(b' '.join(cells) + b'\n')
except IOError: # pragma: no cover
# Can happen on Windows if the pipe is closed early.
pass
def pretty_print(outbuf, parsedblame, show_filenames=False):
"""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(outbuf, table, align='llllrl' if show_filenames else 'lllrl')
def get_parsed_blame(filename, revision='HEAD'):
blame = git_common.blame(filename, revision=revision, porcelain=True)
return list(parse_blame(blame))
# Map from (oldrev, newrev) to hunk list (caching the results of git diff, but
# only the hunk line numbers, not the actual diff contents).
# hunk list contains (old, new) pairs, where old and new are (start, length)
# pairs. A hunk list can also be None (if the diff failed).
diff_hunks_cache = {}
def cache_diff_hunks(oldrev, newrev):
def parse_start_length(s):
# Chop the '-' or '+'.
s = s[1:]
# Length is optional (defaults to 1).
try:
start, length = s.split(',')
except ValueError:
start = s
length = 1
return int(start), int(length)
try:
return diff_hunks_cache[(oldrev, newrev)]
except KeyError:
pass
# Use -U0 to get the smallest possible hunks.
diff = git_common.diff(oldrev, newrev, '-U0')
# Get all the hunks.
hunks = []
for line in diff.split('\n'):
if not line.startswith('@@'):
continue
ranges = line.split(' ', 3)[1:3]
ranges = tuple(parse_start_length(r) for r in ranges)
hunks.append(ranges)
diff_hunks_cache[(oldrev, newrev)] = hunks
return hunks
def approx_lineno_across_revs(filename, newfilename, revision, newrevision,
lineno):
"""Computes the approximate movement of a line number between two revisions.
Consider line |lineno| in |filename| at |revision|. This function computes the
line number of that line in |newfilename| at |newrevision|. This is
necessarily approximate.
Args:
filename: The file (within the repo) at |revision|.
newfilename: The name of the same file at |newrevision|.
revision: A git revision.
newrevision: Another git revision. Note: Can be ahead or behind |revision|.
lineno: Line number within |filename| at |revision|.
Returns:
Line number within |newfilename| at |newrevision|.
"""
# This doesn't work that well if there are a lot of line changes within the
# hunk (demonstrated by GitHyperBlameLineMotionTest.testIntraHunkLineMotion).
# A fuzzy heuristic that takes the text of the new line and tries to find a
# deleted line within the hunk that mostly matches the new line could help.
# Use the <revision>:<filename> syntax to diff between two blobs. This is the
# only way to diff a file that has been renamed.
old = '%s:%s' % (revision, filename)
new = '%s:%s' % (newrevision, newfilename)
hunks = cache_diff_hunks(old, new)
cumulative_offset = 0
# Find the hunk containing lineno (if any).
for (oldstart, oldlength), (newstart, newlength) in hunks:
cumulative_offset += newlength - oldlength
if lineno >= oldstart + oldlength:
# Not there yet.
continue
if lineno < oldstart:
# Gone too far.
break
# lineno is in [oldstart, oldlength] at revision; [newstart, newlength] at
# newrevision.
# If newlength == 0, newstart will be the line before the deleted hunk.
# Since the line must have been deleted, just return that as the nearest
# line in the new file. Caution: newstart can be 0 in this case.
if newlength == 0:
return max(1, newstart)
newend = newstart + newlength - 1
# Move lineno based on the amount the entire hunk shifted.
lineno = lineno + newstart - oldstart
# Constrain the output within the range [newstart, newend].
return min(newend, max(newstart, lineno))
# Wasn't in a hunk. Figure out the line motion based on the difference in
# length between the hunks seen so far.
return lineno + cumulative_offset
def hyper_blame(outbuf, ignored, filename, revision):
# Map from commit to parsed blame from that commit.
blame_from = {}
filename = os.path.normpath(filename)
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:
sys.stderr.write(e.stderr.decode())
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. We need
# to translate that line number so that it refers to the position of the
# same line on previouscommit.
lineno_previous = approx_lineno_across_revs(
line.commit.filename, previousfilename, line.commit.commithash,
previouscommit, 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 1 <= lineno_previous <= len(parent_blame)
newline = parent_blame[lineno_previous - 1]
# Replace the commit and lineno_then, but not the lineno_now or context.
line = BlameLine(newline.commit, line.context, newline.lineno_then,
line.lineno_now, True)
logging.debug(' replacing with %r', line)
# If any line has a different filename to the file's current name, turn on
# filename display for the entire blame output.
# Use normpath to make variable consistent across platforms.
if os.path.normpath(line.commit.filename) != filename:
show_filenames = True
new_parsed.append(line)
pretty_print(outbuf, new_parsed, show_filenames=show_filenames)
return 0
def parse_ignore_file(ignore_file):
for line in ignore_file:
line = line.split('#', 1)[0].strip()
if line:
yield line
def main(args, outbuf):
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('--ignore-file', metavar='FILE', dest='ignore_file',
help='a file containing a list of revisions to ignore')
parser.add_argument('--no-default-ignores', dest='no_default_ignores',
action='store_true',
help='Do not ignore commits from .git-blame-ignore-revs.')
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:
sys.stderr.write(e.stderr.decode())
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_list = list(args.ignored)
if not args.no_default_ignores and os.path.exists(DEFAULT_IGNORE_FILE_NAME):
with open(DEFAULT_IGNORE_FILE_NAME) as ignore_file:
ignored_list.extend(parse_ignore_file(ignore_file))
if args.ignore_file:
with open(args.ignore_file) as ignore_file:
ignored_list.extend(parse_ignore_file(ignore_file))
ignored = set()
for c in ignored_list:
try:
ignored.add(git_common.hash_one(c))
except subprocess2.CalledProcessError as e:
# Custom warning string (the message from git-rev-parse is inappropriate).
sys.stderr.write('warning: unknown revision \'%s\'.\n' % c)
return hyper_blame(outbuf, ignored, filename, args.revision)
if __name__ == '__main__': # pragma: no cover
setup_color.init()
with git_common.less() as less_input:
sys.exit(main(sys.argv[1:], less_input))