File cvs-exp.pl of Package gource

#!/usr/bin/perl -w
 
# "cvs log" is reasonably good for tracking changes to a single file, but
# for an entire project with lots of branches and tags, it provides _no_
# idea of what's happening on the whole. This cvs-exp.pl works on the output
# of cvs log and arranges events chronologically, merging entries as needed.
# by keeping track of lists of files and revisions, it figures when in the
# sequence of events were various tags and branches created. It prints out a
# cute-looking tree at the end to summarize activity in the repository.
#
# cvs-exp.pl: a global-picture chronological "cvs log" postprocessor
# author: Sitaram Iyer <ssiyer@cs.rice.edu> 11jan00, one afternoon's work
# Copyright (C) 2000 Rice University
# available at http://www.cs.rice.edu/~ssiyer/code/cvs-exp/
#
# cvs-exp.pl, a wrapper around (and a spoof of) the "cvs log" command, is a
# way of chronologically tracking events in a cvs repository, when you're
# lost among lots of branches and tags and little idea of what's going on.
#
# CHANGELOG
#
# Bugfix contributed by Adam Bregenzer <adam@bregenzer.net> on Oct 24, 2002
# to allow for spaces in filenames.
#
# david.fleck@ugs.com (July 2004):
# added -before, -changeset, -search, -info, -last, and -remote functions.
#
# Bugfix from Peter Conrad <conrad@tivano.de> on Nov 30, 2004
# to remove a superfluous line with a "," if a file has a long pathname

# so two reasons for writing this:
#  * I managed a CVS repository for a tolerably largeish project recently,
#    and had lots of trouble keeping up with the overall state of the
#    project, especially when people kept making mistakes of committing to
#    the wrong branches etc. This would've proved quite useful then.
#  * I like writing _long_ cvs commit descriptions, so a significant part of
#    documentation in my code happens to be scattered among a gazillion
#    files in a CVS repository, and I wouldn't mind merging them nicely and
#    chronologically.

# status:
#   sorting and merging text - sortof.
#   list of files changed - yes
#   tag support - yes: 'twas hard
#   vendor tag support - yes
#   branch support - yes

# NOTE: a newly created branch with no commits inside will not show up.
#       this is because CVS works that way, and nothing can be done.
#
# NOTE: tagging files in a new branch will really tag the parent. weird CVS.
#
# NOTE: create branch, it says CREATE BRANCH, then add some files onto the
#       trunk, and it says CREATE BRANCH again for *those* files. this is
#       really what happens, since the branch is created for those files
#       only at that point. so this isn't a bug either.
#
# BUGS: infinite nesting in e.g.413.
#
# BUGS: sorting is not polished up yet, so sometimes entries with almost the
#       same timestamp are repeated - e.g. with Initial revision and Import

use strict;
my $opt_files = 1;
my $opt_log = 1;
my $opt_tree = 1;
my $cset = 0;
my $log = "log";
my $before = 0;
my $lastonly = 0;
my $searchstring = 0;
my $searchdate = 0;
my $case = "";
my @dirs;

while ($#ARGV >= 0) {
	for ($ARGV[0]) {
		/-nofiles/ && do {
			shift; $opt_files = 0; 
			last;
		};

		/-nolog/ && do {
			shift; $opt_log = 0;
			last;
		};

		/-notree/ && do {
			shift; $opt_tree = 0;
			last;
		};

		/-c.*/ && do {
			shift; $cset = shift;
			last;
		};

		/-s.*/ && do {
			shift; $searchstring = shift;
			last;
		};

		/-i.*/ && do {
			shift; $searchdate = shift;
			last;
		};

		/-l.*/ && do {
			shift; $lastonly = 1;
			last;
		};

		/-h.*/ && do {
			usage();
			exit();
		};	

		/-b.*/ && do {
			shift; $before = 1;
			last;
		};

		/-r.*/ && do {
			shift; $log = "rlog";
			last;
		};
		/.*/ && do {
			push(@dirs, (shift));
			last;
		};	
	}
}	

if (-t) {
  open(CVS, "cvs $log ".join(' ',@dirs)." 2> /dev/null |") || die "can't execute cvs: $!\n";
} else {
  *CVS = *STDIN;
}

my $dash = "-"x28;
my $ddash = "="x77;
my $inheader = 1; 
my %symbtag = (); 
my %tagnfiles = ();

my ($file, $branch, %d, $txt, $rev, $date, %tagnfiles1, @commitTable); 

# create an array of hashes (@commitTable) containing entries for each commit.
fileentry:
while (<CVS>) { 
  chomp;
  if (!$inheader && ($_ eq $dash || $_ eq $ddash)) {
    my $k = join(' ', map { s/\s*;$//; "$file:$_" } (split(/\s+/,$branch)));
    %d = ( 'file'	=> $file,
	   'frev'	=> "$file:$rev",
	   'txt'	=> $txt,
	   'rev'	=> $rev,
	   'date'	=> $date,
	   'branch'	=> $k,
	 );
    push @commitTable, { %d };
  }

  if ($_ eq $dash) {
    $inheader = 0;
    $_ = <CVS>; chomp; s/revision\s*//; $rev = $_;
    $_ = <CVS>; chomp; $date = $_;
    $_ = <CVS>; chomp;
    $txt = "";
    $branch = (/^branches:\s*(.*)$/) ? $1 : do { $txt .= "$_\n"; ""; };
  } elsif ($_ eq $ddash) {
    $inheader = 1; undef $file;
    next fileentry;
  } else {
    if ($inheader) {
#      $file = $1 if (/^Working file: (.*)$/);
      $file = $1 if (/^RCS file: (.*)(,v)?$/);
      if (/^\t([^\s]*)\s*:\s*([^\s]*)\s*$/) {
	die if (!defined($file));
	my ($tag,$ver) = ($1,$2);

	# if $ver has an even number of dot-separateds and the
	# second-to-last is zero, then wipe it. this is a branch.
	my @ver = split(/\./,$ver);
	$ver = join('.',(@ver[0..$#ver-2],$ver[$#ver]))
	  if ($#ver >= 1 && ($#ver % 2) && $ver[$#ver-1] eq "0");

	defined($tagnfiles{$tag}) ?
	  do { $tagnfiles{$tag}++ } :
	  do { $tagnfiles{$tag} = 1 };
	my @t = (defined($symbtag{$file}{$ver}) ?
	    @{ $symbtag{$file}{$ver} } : ());
	push @t, $tag;
	$symbtag{$file}{$ver} = [ @t ];
      }
    } else {
      $txt .= "$_\n";
    }
  }
}

foreach (keys(%tagnfiles)) { $tagnfiles1{$_} = $tagnfiles{$_}; } # backup

@commitTable = sort { cmpval($a) cmp cmpval($b) } @commitTable;

# merge consecutive entries with the same text - not all entries, note.
my $m = "garbage";
my @mtable = ();

foreach (@commitTable) {
  my %entry = %{$_};
  if ($m eq $entry{txt}) { # then skip
    $mtable[$#mtable]{frev} .= " " . $entry{frev};
    $mtable[$#mtable]{file} .= " " . $entry{file};
    foreach my $tag (@{ $symbtag{$entry{file}}{$entry{rev}} }) {
      $tagnfiles{$tag}--;
      unshift (@{$mtable[$#mtable]{tags}},$tag) if ($tagnfiles{$tag} <= 0);
    }
  } else {
    $m = $entry{txt};
    $entry{tags} = ();
    foreach my $tag (@{ $symbtag{$entry{file}}{$entry{rev}} }) {
      $tagnfiles{$tag}--;
      unshift (@{$entry{tags}},$tag) if ($tagnfiles{$tag} <= 0);
    }
    push @mtable, { %entry };
  }
}

# Having slurped in all the info. from the 'cvs log' command, now figure out
# how to output it.

my %child = ();
my $iter = 0;

# return revision numbers of *all* files as they were prior to commit ($cset).
if ($before) {		
	unless ($cset) {
		print "A -changeset value must be provided for this option.\n";
	}	
	my (%entry, %latest) = ();
	xprint("State of repository before change ",sprintf("%06u:\n", $cset));	
	for (my $i = 0; $i<$cset; $i++) {
		%entry = %{$mtable[$i]};
		foreach (split(/ /,$entry{frev})) {
			my ($file, $rev) = split(/:/);
			$latest{$file} =$rev;
		}	
	}
		 
	%entry = %{$mtable[$cset]};
	my $b = brname($entry{frev});
	($b ne "") && xprint("BRANCH [$b]\n");
	xprint("$entry{txt}\n");
	xprint("($entry{date})\n");
	foreach (sort keys %latest) {
		xprint($_,":",$latest{$_},"\n");
	}	 
	
	foreach (@{$entry{tags}}) {
		xprint("*** CREATE TAG [$_]" . nfiles($_));
		push @{$child{$b}}, "t $_";
	}
	foreach (split(/\s+/,brname($entry{branch}))) {
		xprint("*** CREATE BRANCH [$_]" . nfiles($_));
		push @{$child{$b}}, "b $_";
	}
	xprint("" . ("="x78) . "\n");
	exit(0);
}

# return data only on a single specific change, then exit.
# re-arrange the output format to make life easy for cvs-undo.pl
if ($cset) {		
	xprint(sprintf("\n%06u:\n", $cset));	
	my %entry = %{$mtable[$cset]};
	my $b = brname($entry{frev});
	($b ne "") && xprint("BRANCH [$b]\n");
	xprint("$entry{txt}\n");
	xprint("($entry{date})\n");
	xprint(join("\n",split(/ /,$entry{frev})) . "\n"); 
	
	foreach (@{$entry{tags}}) {
		xprint("*** CREATE TAG [$_]" . nfiles($_));
		push @{$child{$b}}, "t $_";
	}
	foreach (split(/\s+/,brname($entry{branch}))) {
		xprint("*** CREATE BRANCH [$_]" . nfiles($_));
		push @{$child{$b}}, "b $_";
	}
	xprint("" . ("="x78) . "\n");
	exit(0);
}

# return data on all changes whose date component matches '$searchdate', then
# exit.
if ($searchdate) {
	my $nseen = 0;
	my $laststring = "";
	($lastonly) && ($laststring = "last instance of ");
	print "Searching for $laststring\'$searchdate\'\n";
	for (my $i = 0; $i <= $#mtable; $i++) {
		my %entry = %{$mtable[$i]};
		if ($entry{'date'} =~ /$searchdate/) {
			$nseen = $i;
			unless ($lastonly) {
				xprint(sprintf("\n%06u:", $i));	
				my $b = brname($entry{frev});
				($b ne "") && xprint("\nBRANCH [$b]\n");
				xprint("\n($entry{date})\n");
				xprint(wraprint($entry{frev},77,"  | ",",") . "\n  `" . ("-"x40) . "\n"); 
				xprint("\n$entry{txt}\n");
				foreach (@{$entry{tags}}) {
					xprint("*** CREATE TAG [$_]" . nfiles($_));
					push @{$child{$b}}, "t $_";
				}
				foreach (split(/\s+/,brname($entry{branch}))) {
					xprint("*** CREATE BRANCH [$_]" . nfiles($_));
			 		push @{$child{$b}}, "b $_";
				}
				xprint("" . ("="x78) . "\n");
			}
		}	
	}
	if (($lastonly) && ($nseen)) {
		my %entry = %{$mtable[$nseen]};
		xprint(sprintf("\n%06u:", $nseen));	
		my $b = brname($entry{frev});
		($b ne "") && xprint("\nBRANCH [$b]\n");
		xprint("\n($entry{date})\n");
		xprint(wraprint($entry{frev},77,"  | ",",") . "\n  `" . ("-"x40) . "\n"); 
		xprint("\n$entry{txt}\n");
		foreach (@{$entry{tags}}) {
			xprint("*** CREATE TAG [$_]" . nfiles($_));
			push @{$child{$b}}, "t $_";
		}
		foreach (split(/\s+/,brname($entry{branch}))) {
			xprint("*** CREATE BRANCH [$_]" . nfiles($_));
	 		push @{$child{$b}}, "b $_";
		}
		xprint("" . ("="x78) . "\n");
	}
		
	print "No relevant entries found\n" if not ($nseen);
	exit(0);
}

# return data on all changes whose text component matches '$searchstring', then
# exit.
if ($searchstring) {
	my $nseen = 0;
	print "Searching for \'$searchstring\'\n";
	foreach (@mtable) {
		my %entry = %{$_};
		if ($entry{'txt'} =~ /$searchstring/) {
			$nseen++;
			xprint(sprintf("\n%06u:", $iter));	
			my $b = brname($entry{frev});
			($b ne "") && xprint("\nBRANCH [$b]\n");
			xprint("\n($entry{date})\n");
			xprint(wraprint($entry{frev},77,"  | ",",") . "\n  `" . ("-"x40) . "\n"); 
			xprint("\n$entry{txt}\n");
			foreach (@{$entry{tags}}) {
				xprint("*** CREATE TAG [$_]" . nfiles($_));
				push @{$child{$b}}, "t $_";
			}
			foreach (split(/\s+/,brname($entry{branch}))) {
				xprint("*** CREATE BRANCH [$_]" . nfiles($_));
		 		push @{$child{$b}}, "b $_";
			}
			xprint("" . ("="x78) . "\n");
		}
		$iter++;
	}
	print "No relevant entries found\n" if not ($nseen);
	exit(0);
}

# return all data, with changeset number prepended.
foreach (@mtable) {
	xprint(sprintf("\n%06u:", $iter));	
	my %entry = %{$_};
	my $b = brname($entry{frev});
	($b ne "") && xprint("\nBRANCH [$b]\n");
	xprint("\n($entry{date})\n");
	($opt_files) &&  
		xprint(wraprint($entry{frev},77,"  | ",",") . "\n  `" . ("-"x40) . "\n"); 
	xprint("\n$entry{txt}\n");
	foreach (@{$entry{tags}}) {
		xprint("*** CREATE TAG [$_]" . nfiles($_));
		push @{$child{$b}}, "t $_";
	}
	foreach (split(/\s+/,brname($entry{branch}))) {
		xprint("*** CREATE BRANCH [$_]" . nfiles($_));
		push @{$child{$b}}, "b $_";
	}
	xprint("" . ("="x78) . "\n");
	$iter++;
}

my %seen = ();

do { print "HEAD\n"; print_branch("", ()); } if ($opt_tree);

### End main ###

sub nfiles { " <$tagnfiles1{$_[0]} file".($tagnfiles1{$_[0]}==1?"":"s").">\n" }

sub xprint { print @_ if ($opt_log); }

sub brname {
  my %b = ();
  my $line = $_[0];
  while ($line =~ s/(.+?):([0-9.]+)\s*//) {
    my $file = $1;
    my $ver = $2;
    $ver =~ s/;$//;
    my @ver = split(/\./,$ver);
    pop @ver if ($#ver % 2);
    $ver = join('.',@ver);
    my $x = $symbtag{$file}{$ver};
    $b{@{$x}[0]} = 1 if (defined($x));
  }
  return join(' ',keys(%b));
}

# complicated sort/merge:
# sort on timestamp, largely.
# however, if two entries have the same hours and minutes, then *merge* on
# text and sort on timestamp of the first one anyway.
# XXX ABOVE NOT YET FULLY IMPLEMENTED.
sub cmpval {
  my %a = %{$_[0]}; my $s = $a{date};
  $s =~ s/^([^:]*:[^:]*:[^:]*):.*/$1/; $s.$a{rev}.$a{txt}.$a{frev};
}

sub wraprint {
  my $n = $_[1];
  my $pad = $_[2];
  my $join = $_[3];
  my $len = 0;
  my $res = "";
  my $line = $_[0];
  while ($line =~ s/(.+?:[0-9.]+)\s*//) {
    if ($len + length($1) + 2 < $n) {
      do { $res .= "$join "; $len += 1+length($join); } if ($res ne "");
    } elsif ($len > 0) {
      $res .= "$join\n"; $len = 0;
    }
    do { $res .= $pad; $len += length($pad); } if ($len == 0);
    $res .= $1; $len += length($1);
  }
  return $res;
}

sub print_branch {
  $seen{$_[0]} = 1;
  my $t = $child{$_[0]};
  my @t = (defined($t)?@{$t}:());
  shift;
  my @were_last = @_;
  my $am_last = 0;
  foreach (@t) {
    $am_last = 1 if ($_ eq $t[$#t]);
    /^(.) (.*)/; my $tb = $1; $_ = $2;
    print(join('',map {($_?" ":"|")."  "} @were_last).($am_last?"`":"|")."- ".($tb eq "b"?"[":"")."$_".($tb eq "b"?"]":"").nfiles($_))
      if ($tb ne "b" || !defined($seen{$_}));
    print_branch($_, (@were_last,$am_last))
      if ($tb eq "b" && !defined($seen{$_}));
  }
}
sub usage {
	print "Usage:\n",
	"    cvs-exp.pl [opts] [arguments to cvs log] [dir]\n",
	"or  cvs log | cvs-exp.pl [opts] \n",
	"\n",
	"cvs-exp.pl creates a chronological view of the commits, tags, and branching\n",
	"events within a CVS module or set of CVS modules. cvs-exp.pl uses the 'cvs log'\n",
	"(or 'cvs rlog') command to gather raw materials for formatted output.\n",
	"\n",
	"Options:\n",
	"   -nolog           don't do the log; only show the tag tree at the end\n",
	"   -notree          vice versa - the default is to print both.\n",
	"   -nofiles         don't list files in the log\n",
	"\n",
	"                   The previous three options are overridden by the\n",
	"                   following options:\n",
	"\n",
	"   -before          return version information for all files as they were\n",
	"                    before a given changeset. (Requires -changeset .)\n",
	"   -changeset nn    return information on specific checkin nn\n",
	"   -search str      search for all commits whose comments match the regular\n",
	"                    expression 'str'\n",
	"   -info str        search for all commits whose date or author match the\n",
	"                    regular expression 'str'\n",
	"   -last            return only the most recent commit matching a search\n",
	"                    (only meaningful with the '-info' option)\n",
	"   -remote          use 'cvs rlog' rather than 'cvs log'\n",
	"   -help            when all else fails\n";
}