461 lines
16 KiB
Python
461 lines
16 KiB
Python
#!/usr/bin/env python
|
|
# -*-python-*-
|
|
#
|
|
# Copyright (C) 1999-2013 The ViewCVS Group. All Rights Reserved.
|
|
# Copyright (C) 2000 Curt Hagenlocher <curt@hagenlocher.org>
|
|
#
|
|
# By using this file, you agree to the terms and conditions set forth in
|
|
# the LICENSE.html file which can be found at the top level of the ViewVC
|
|
# distribution or at http://viewvc.org/license-1.html.
|
|
#
|
|
# For more information, visit http://viewvc.org/
|
|
#
|
|
# -----------------------------------------------------------------------
|
|
#
|
|
# blame.py: Annotate each line of a CVS file with its author,
|
|
# revision #, date, etc.
|
|
#
|
|
# -----------------------------------------------------------------------
|
|
#
|
|
# This file is based on the cvsblame.pl portion of the Bonsai CVS tool,
|
|
# developed by Steve Lamm for Netscape Communications Corporation. More
|
|
# information about Bonsai can be found at
|
|
# http://www.mozilla.org/bonsai.html
|
|
#
|
|
# cvsblame.pl, in turn, was based on Scott Furman's cvsblame script
|
|
#
|
|
# -----------------------------------------------------------------------
|
|
|
|
import re
|
|
import time
|
|
import math
|
|
import rcsparse
|
|
import vclib
|
|
|
|
class CVSParser(rcsparse.Sink):
|
|
# Precompiled regular expressions
|
|
trunk_rev = re.compile('^[0-9]+\\.[0-9]+$')
|
|
last_branch = re.compile('(.*)\\.[0-9]+')
|
|
is_branch = re.compile('^(.*)\\.0\\.([0-9]+)$')
|
|
d_command = re.compile('^d(\d+)\\s(\\d+)')
|
|
a_command = re.compile('^a(\d+)\\s(\\d+)')
|
|
|
|
SECONDS_PER_DAY = 86400
|
|
|
|
def __init__(self):
|
|
self.Reset()
|
|
|
|
def Reset(self):
|
|
self.last_revision = {}
|
|
self.prev_revision = {}
|
|
self.revision_date = {}
|
|
self.revision_author = {}
|
|
self.revision_branches = {}
|
|
self.next_delta = {}
|
|
self.prev_delta = {}
|
|
self.tag_revision = {}
|
|
self.timestamp = {}
|
|
self.revision_ctime = {}
|
|
self.revision_age = {}
|
|
self.revision_log = {}
|
|
self.revision_deltatext = {}
|
|
self.revision_map = [] # map line numbers to revisions
|
|
self.lines_added = {}
|
|
self.lines_removed = {}
|
|
|
|
# Map a tag to a numerical revision number. The tag can be a symbolic
|
|
# branch tag, a symbolic revision tag, or an ordinary numerical
|
|
# revision number.
|
|
def map_tag_to_revision(self, tag_or_revision):
|
|
try:
|
|
revision = self.tag_revision[tag_or_revision]
|
|
match = self.is_branch.match(revision)
|
|
if match:
|
|
branch = match.group(1) + '.' + match.group(2)
|
|
if self.last_revision.get(branch):
|
|
return self.last_revision[branch]
|
|
else:
|
|
return match.group(1)
|
|
else:
|
|
return revision
|
|
except:
|
|
return ''
|
|
|
|
# Construct an ordered list of ancestor revisions to the given
|
|
# revision, starting with the immediate ancestor and going back
|
|
# to the primordial revision (1.1).
|
|
#
|
|
# Note: The generated path does not traverse the tree the same way
|
|
# that the individual revision deltas do. In particular,
|
|
# the path traverses the tree "backwards" on branches.
|
|
def ancestor_revisions(self, revision):
|
|
ancestors = []
|
|
revision = self.prev_revision.get(revision)
|
|
while revision:
|
|
ancestors.append(revision)
|
|
revision = self.prev_revision.get(revision)
|
|
|
|
return ancestors
|
|
|
|
# Split deltatext specified by rev to each line.
|
|
def deltatext_split(self, rev):
|
|
lines = self.revision_deltatext[rev].split('\n')
|
|
if lines[-1] == '':
|
|
del lines[-1]
|
|
return lines
|
|
|
|
# Extract the given revision from the digested RCS file.
|
|
# (Essentially the equivalent of cvs up -rXXX)
|
|
def extract_revision(self, revision):
|
|
path = []
|
|
add_lines_remaining = 0
|
|
start_line = 0
|
|
count = 0
|
|
while revision:
|
|
path.append(revision)
|
|
revision = self.prev_delta.get(revision)
|
|
path.reverse()
|
|
path = path[1:] # Get rid of head revision
|
|
|
|
text = self.deltatext_split(self.head_revision)
|
|
|
|
# Iterate, applying deltas to previous revision
|
|
for revision in path:
|
|
adjust = 0
|
|
diffs = self.deltatext_split(revision)
|
|
self.lines_added[revision] = 0
|
|
self.lines_removed[revision] = 0
|
|
lines_added_now = 0
|
|
lines_removed_now = 0
|
|
|
|
for command in diffs:
|
|
dmatch = self.d_command.match(command)
|
|
amatch = self.a_command.match(command)
|
|
if add_lines_remaining > 0:
|
|
# Insertion lines from a prior "a" command
|
|
text.insert(start_line + adjust, command)
|
|
add_lines_remaining = add_lines_remaining - 1
|
|
adjust = adjust + 1
|
|
elif dmatch:
|
|
# "d" - Delete command
|
|
start_line = int(dmatch.group(1))
|
|
count = int(dmatch.group(2))
|
|
begin = start_line + adjust - 1
|
|
del text[begin:begin + count]
|
|
adjust = adjust - count
|
|
lines_removed_now = lines_removed_now + count
|
|
elif amatch:
|
|
# "a" - Add command
|
|
start_line = int(amatch.group(1))
|
|
count = int(amatch.group(2))
|
|
add_lines_remaining = count
|
|
lines_added_now = lines_added_now + count
|
|
else:
|
|
raise RuntimeError, 'Error parsing diff commands'
|
|
|
|
self.lines_added[revision] = self.lines_added[revision] + lines_added_now
|
|
self.lines_removed[revision] = self.lines_removed[revision] + lines_removed_now
|
|
return text
|
|
|
|
def set_head_revision(self, revision):
|
|
self.head_revision = revision
|
|
|
|
def set_principal_branch(self, branch_name):
|
|
self.principal_branch = branch_name
|
|
|
|
def define_tag(self, name, revision):
|
|
# Create an associate array that maps from tag name to
|
|
# revision number and vice-versa.
|
|
self.tag_revision[name] = revision
|
|
|
|
def set_comment(self, comment):
|
|
self.file_description = comment
|
|
|
|
def set_description(self, description):
|
|
self.rcs_file_description = description
|
|
|
|
# Construct dicts that represent the topology of the RCS tree
|
|
# and other arrays that contain info about individual revisions.
|
|
#
|
|
# The following dicts are created, keyed by revision number:
|
|
# self.revision_date -- e.g. "96.02.23.00.21.52"
|
|
# self.timestamp -- seconds since 12:00 AM, Jan 1, 1970 GMT
|
|
# self.revision_author -- e.g. "tom"
|
|
# self.revision_branches -- descendant branch revisions, separated by spaces,
|
|
# e.g. "1.21.4.1 1.21.2.6.1"
|
|
# self.prev_revision -- revision number of previous *ancestor* in RCS tree.
|
|
# Traversal of this array occurs in the direction
|
|
# of the primordial (1.1) revision.
|
|
# self.prev_delta -- revision number of previous revision which forms
|
|
# the basis for the edit commands in this revision.
|
|
# This causes the tree to be traversed towards the
|
|
# trunk when on a branch, and towards the latest trunk
|
|
# revision when on the trunk.
|
|
# self.next_delta -- revision number of next "delta". Inverts prev_delta.
|
|
#
|
|
# Also creates self.last_revision, keyed by a branch revision number, which
|
|
# indicates the latest revision on a given branch,
|
|
# e.g. self.last_revision{"1.2.8"} == 1.2.8.5
|
|
def define_revision(self, revision, timestamp, author, state,
|
|
branches, next):
|
|
self.tag_revision[revision] = revision
|
|
branch = self.last_branch.match(revision).group(1)
|
|
self.last_revision[branch] = revision
|
|
|
|
#self.revision_date[revision] = date
|
|
self.timestamp[revision] = timestamp
|
|
|
|
# Pretty print the date string
|
|
ltime = time.localtime(self.timestamp[revision])
|
|
formatted_date = time.strftime("%d %b %Y %H:%M", ltime)
|
|
self.revision_ctime[revision] = formatted_date
|
|
|
|
# Save age
|
|
self.revision_age[revision] = ((time.time() - self.timestamp[revision])
|
|
/ self.SECONDS_PER_DAY)
|
|
|
|
# save author
|
|
self.revision_author[revision] = author
|
|
|
|
# ignore the state
|
|
|
|
# process the branch information
|
|
branch_text = ''
|
|
for branch in branches:
|
|
self.prev_revision[branch] = revision
|
|
self.next_delta[revision] = branch
|
|
self.prev_delta[branch] = revision
|
|
branch_text = branch_text + branch + ''
|
|
self.revision_branches[revision] = branch_text
|
|
|
|
# process the "next revision" information
|
|
if next:
|
|
self.next_delta[revision] = next
|
|
self.prev_delta[next] = revision
|
|
is_trunk_revision = self.trunk_rev.match(revision) is not None
|
|
if is_trunk_revision:
|
|
self.prev_revision[revision] = next
|
|
else:
|
|
self.prev_revision[next] = revision
|
|
|
|
# Construct associative arrays containing info about individual revisions.
|
|
#
|
|
# The following associative arrays are created, keyed by revision number:
|
|
# revision_log -- log message
|
|
# revision_deltatext -- Either the complete text of the revision,
|
|
# in the case of the head revision, or the
|
|
# encoded delta between this revision and another.
|
|
# The delta is either with respect to the successor
|
|
# revision if this revision is on the trunk or
|
|
# relative to its immediate predecessor if this
|
|
# revision is on a branch.
|
|
def set_revision_info(self, revision, log, text):
|
|
self.revision_log[revision] = log
|
|
self.revision_deltatext[revision] = text
|
|
|
|
def parse_cvs_file(self, rcs_pathname, opt_rev = None, opt_m_timestamp = None):
|
|
# Args in: opt_rev - requested revision
|
|
# opt_m - time since modified
|
|
# Args out: revision_map
|
|
# timestamp
|
|
# revision_deltatext
|
|
|
|
# CheckHidden(rcs_pathname)
|
|
try:
|
|
rcsfile = open(rcs_pathname, 'rb')
|
|
except:
|
|
raise RuntimeError, ('error: %s appeared to be under CVS control, ' +
|
|
'but the RCS file is inaccessible.') % rcs_pathname
|
|
|
|
rcsparse.parse(rcsfile, self)
|
|
rcsfile.close()
|
|
|
|
if opt_rev in [None, '', 'HEAD']:
|
|
# Explicitly specified topmost revision in tree
|
|
revision = self.head_revision
|
|
else:
|
|
# Symbolic tag or specific revision number specified.
|
|
revision = self.map_tag_to_revision(opt_rev)
|
|
if revision == '':
|
|
raise RuntimeError, 'error: -r: No such revision: ' + opt_rev
|
|
|
|
# The primordial revision is not always 1.1! Go find it.
|
|
primordial = revision
|
|
while self.prev_revision.get(primordial):
|
|
primordial = self.prev_revision[primordial]
|
|
|
|
# Don't display file at all, if -m option is specified and no
|
|
# changes have been made in the specified file.
|
|
if opt_m_timestamp and self.timestamp[revision] < opt_m_timestamp:
|
|
return ''
|
|
|
|
# Figure out how many lines were in the primordial, i.e. version 1.1,
|
|
# check-in by moving backward in time from the head revision to the
|
|
# first revision.
|
|
line_count = 0
|
|
if self.revision_deltatext.get(self.head_revision):
|
|
tmp_array = self.deltatext_split(self.head_revision)
|
|
line_count = len(tmp_array)
|
|
|
|
skip = 0
|
|
|
|
rev = self.prev_revision.get(self.head_revision)
|
|
while rev:
|
|
diffs = self.deltatext_split(rev)
|
|
for command in diffs:
|
|
dmatch = self.d_command.match(command)
|
|
amatch = self.a_command.match(command)
|
|
if skip > 0:
|
|
# Skip insertion lines from a prior "a" command
|
|
skip = skip - 1
|
|
elif dmatch:
|
|
# "d" - Delete command
|
|
start_line = int(dmatch.group(1))
|
|
count = int(dmatch.group(2))
|
|
line_count = line_count - count
|
|
elif amatch:
|
|
# "a" - Add command
|
|
start_line = int(amatch.group(1))
|
|
count = int(amatch.group(2))
|
|
skip = count
|
|
line_count = line_count + count
|
|
else:
|
|
raise RuntimeError, 'error: illegal RCS file'
|
|
|
|
rev = self.prev_revision.get(rev)
|
|
|
|
# Now, play the delta edit commands *backwards* from the primordial
|
|
# revision forward, but rather than applying the deltas to the text of
|
|
# each revision, apply the changes to an array of revision numbers.
|
|
# This creates a "revision map" -- an array where each element
|
|
# represents a line of text in the given revision but contains only
|
|
# the revision number in which the line was introduced rather than
|
|
# the line text itself.
|
|
#
|
|
# Note: These are backward deltas for revisions on the trunk and
|
|
# forward deltas for branch revisions.
|
|
|
|
# Create initial revision map for primordial version.
|
|
self.revision_map = [primordial] * line_count
|
|
|
|
ancestors = [revision, ] + self.ancestor_revisions(revision)
|
|
ancestors = ancestors[:-1] # Remove "1.1"
|
|
last_revision = primordial
|
|
ancestors.reverse()
|
|
for revision in ancestors:
|
|
is_trunk_revision = self.trunk_rev.match(revision) is not None
|
|
|
|
if is_trunk_revision:
|
|
diffs = self.deltatext_split(last_revision)
|
|
|
|
# Revisions on the trunk specify deltas that transform a
|
|
# revision into an earlier revision, so invert the translation
|
|
# of the 'diff' commands.
|
|
for command in diffs:
|
|
if skip > 0:
|
|
skip = skip - 1
|
|
else:
|
|
dmatch = self.d_command.match(command)
|
|
amatch = self.a_command.match(command)
|
|
if dmatch:
|
|
start_line = int(dmatch.group(1))
|
|
count = int(dmatch.group(2))
|
|
temp = []
|
|
while count > 0:
|
|
temp.append(revision)
|
|
count = count - 1
|
|
self.revision_map = (self.revision_map[:start_line - 1] +
|
|
temp + self.revision_map[start_line - 1:])
|
|
elif amatch:
|
|
start_line = int(amatch.group(1))
|
|
count = int(amatch.group(2))
|
|
del self.revision_map[start_line:start_line + count]
|
|
skip = count
|
|
else:
|
|
raise RuntimeError, 'Error parsing diff commands'
|
|
|
|
else:
|
|
# Revisions on a branch are arranged backwards from those on
|
|
# the trunk. They specify deltas that transform a revision
|
|
# into a later revision.
|
|
adjust = 0
|
|
diffs = self.deltatext_split(revision)
|
|
for command in diffs:
|
|
if skip > 0:
|
|
skip = skip - 1
|
|
else:
|
|
dmatch = self.d_command.match(command)
|
|
amatch = self.a_command.match(command)
|
|
if dmatch:
|
|
start_line = int(dmatch.group(1))
|
|
count = int(dmatch.group(2))
|
|
adj_begin = start_line + adjust - 1
|
|
adj_end = start_line + adjust - 1 + count
|
|
del self.revision_map[adj_begin:adj_end]
|
|
adjust = adjust - count
|
|
elif amatch:
|
|
start_line = int(amatch.group(1))
|
|
count = int(amatch.group(2))
|
|
skip = count
|
|
temp = []
|
|
while count > 0:
|
|
temp.append(revision)
|
|
count = count - 1
|
|
self.revision_map = (self.revision_map[:start_line + adjust] +
|
|
temp + self.revision_map[start_line + adjust:])
|
|
adjust = adjust + skip
|
|
else:
|
|
raise RuntimeError, 'Error parsing diff commands'
|
|
|
|
last_revision = revision
|
|
|
|
return revision
|
|
|
|
|
|
class BlameSource:
|
|
def __init__(self, rcs_file, opt_rev=None, include_text=False):
|
|
# Parse the CVS file
|
|
parser = CVSParser()
|
|
revision = parser.parse_cvs_file(rcs_file, opt_rev)
|
|
count = len(parser.revision_map)
|
|
lines = parser.extract_revision(revision)
|
|
if len(lines) != count:
|
|
raise RuntimeError, 'Internal consistency error'
|
|
|
|
# set up some state variables
|
|
self.revision = revision
|
|
self.lines = lines
|
|
self.num_lines = count
|
|
self.parser = parser
|
|
self.include_text = include_text
|
|
|
|
# keep track of where we are during an iteration
|
|
self.idx = -1
|
|
self.last = None
|
|
|
|
def __getitem__(self, idx):
|
|
if idx == self.idx:
|
|
return self.last
|
|
if idx >= self.num_lines:
|
|
raise IndexError("No more annotations")
|
|
if idx != self.idx + 1:
|
|
raise BlameSequencingError()
|
|
|
|
# Get the line and metadata for it.
|
|
rev = self.parser.revision_map[idx]
|
|
prev_rev = self.parser.prev_revision.get(rev)
|
|
line_number = idx + 1
|
|
author = self.parser.revision_author[rev]
|
|
thisline = self.lines[idx]
|
|
if not self.include_text:
|
|
thisline = None
|
|
### TODO: Put a real date in here.
|
|
item = vclib.Annotation(thisline, line_number, rev, prev_rev, author, None)
|
|
self.last = item
|
|
self.idx = idx
|
|
return item
|
|
|
|
|
|
class BlameSequencingError(Exception):
|
|
pass
|