#!/usr/bin/perl -w
#
# $Id: deluxeloginfo,v 2.122 2007/01/26 18:26:29 marco Exp $
# Copyright 2002, 2003, 2004, 2005, 2006 Bernardo Innocenti
#
# Dedicated to the memory of Gerry, an extraordinary engineer and
# a good friend.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# Loosely based on KDE's loginfo.pl:
# free of any copyright. Originally written by taj@kde.org with small changes
# by coolo@kde.org and daniel.naber@t-online.de
# Many improvements and fixes by dirk@kde.org
#
#
# TODO: Coalesce diffs of same file in same changeset with same comment
# TODO: Use a table for changeset summaries.
# TODO: Show nr. of files and nr. of lines changed in summary.
# TODO: Add review checklist below summary.
#
#
use strict;
use Getopt::Long;
use Pod::Usage;
my $versiontag = '$Revision: 2.122 $';
$versiontag =~ s/.*: (.*) .*/$1/;
# Command line options
my $opt_keeplogs;
my $opt_verbose;
my $opt_logfile = '';
my $opt_sender = "cvs\@develer.com";
my $opt_recipient = '';
my $opt_outfile = '';
my $opt_prjtab = '';
my $opt_rlog;
my $opt_stampdir = '/var/state/loginfo';
my $opt_byauthor;
my $opt_maildomain = '';
my $opt_cvsurl = '';
my $opt_bugurl = '';
my $opt_diff;
my $opt_html = 1;
my $opt_text = 1;
my $opt_encoding = 'UTF-8';
my $opt_difflimit = 0;
my $opt_startdate;
my $opt_enddate;
my $opt_root = '';
my $opt_prefix = '';
my $opt_module = '';
my $opt_index = 999;
my $opt_indexlines = 1;
# Globals
my ($is_cvs, $is_svn, $is_git);
#
# Print a message (arg 1) followed by an optional multi-line preformatted block (arg 2)
#
sub log_output
{
if ($opt_verbose)
{
my ($msg, $text) = @_;
print STDERR " $msg" . ($text ? ":" : "") . "\n";
if ($text)
{
foreach (split '\n', $text) { print " $_\n"; }
}
}
}
#
# Hold a file-revision pair
#
package FileRev;
#
# Construct a FileRev object
#
# SYNOPSIS: FileRev->new(repodir, filename, r1, r2, tag, date, keywords)
#
sub new($$$$$$$)
{
#
# Collect arguments and create instance
#
my $class = shift;
my $repodir = shift;
my $filename = shift;
my $self = {
r1 => shift,
r2 => shift,
tag => shift,
date => shift,
keywords => shift
};
bless $self, $class;
#
# Normalize filename
#
# Prune ,v suffix from filename
$filename =~ s|,v$||;
# Prune CVS repository path from filename
$filename =~ s|^$repodir/|| if $repodir;
# Prune Attic directory from filename
$filename =~ s|/Attic/|/|;
# Prune leading '/' at the beginning of the path
$filename =~ s|^/||;
# Remove any spurious '//' sequences from CVS
$filename =~ s|//|/|g;
$self->{filename} = $filename;
return $self;
}
# Return normalized filename
sub filename { return shift->{filename}; }
# Return old file revision
sub r1 { return shift->{r1} || "NONE"; }
# Return new file revision
sub r2 { return shift->{r2} || "NONE"; }
# Return the branch tag for the file
sub tag { return shift->{tag} || "HEAD"; }
# Return commit timestamp
sub date { return shift->{date}; }
# Tell if this file is binary
sub is_binary { return shift->{keywords} eq "kb"; }
#
# Handle a list of changed files, the associated log message and the author
#
package ChangeSet;
# Construct a new ChangeSet. Arg1 is the author, arg2 is the log message
sub new($$$)
{
my $class = shift;
bless {
author => shift,
log => shift,
files => [],
tag => "HEAD",
date => "1970/01/01 00:00:00"
}, $class;
}
# Add a new FileRev entry
sub add
{
my ($self, $filerev) = @_;
push @{$self->{files}}, $filerev;
# Collect date of newest change
$self->{date} = $filerev->{date} if ($self->{date} lt $filerev->{date} );
# Collect tag - FIXME: only one tag
$self->{tag} = $filerev->tag;
}
# Retrieve the author name
sub author { return shift->{author}; }
# Retrieve the log message
sub log { return shift->{log}; }
# Return the branch tag for the file
sub tag { return shift->{tag} || "HEAD"; }
# Return an array of FileRev objects
sub files
{
my @fr = @{shift->{files}};
# Return base filename and extension
sub split_extension($)
{
my $name = shift;
$name =~ /^(.*)\.([^.\/]*)$/;
return ($1 || $name, $2 || "");
}
sub is_header($) { shift =~ /^h|hh|hxx|hpp|h\+\+$/ }
sub is_source($) { shift =~ /^c|cpp|cc|cxx|C|c\+\+$/ }
# Fancy sort that makes headers appear before sources, to make code
# reviews somewhat easier.
sort {
my $name_a = $a->filename();
my $name_b = $b->filename();
# Split into basename + extension
my ($base_a, $ext_a) = split_extension($name_a);
my ($base_b, $ext_b) = split_extension($name_b);
#DEBUG
#print "base_a=$base_a, ext_a=$ext_a, base_b=$base_b, ext_b=$ext_b\n";
# Same basename and non-null extensions?
if ($base_a eq $base_b)
{
# Sort headers before implementations
return -1 if is_header($ext_a) && is_source($ext_b);
return +1 if is_header($ext_b) && is_source($ext_a);
}
# Sort normally
return $name_a cmp $name_b;
} @fr;
}
#
# Maintain a collection of ChangeSets sorted by authors and log messages.
#
package CSList;
sub new($$$)
{
my $class = shift;
bless { authors => {} }, $class;
}
#
# Add a new FileRev entry to an existing ChangeSet.
# If no ChangeSet exists for the given log, create a new one.
#
sub add_filerev
{
my ($self, $author, $log, $filerev) = @_;
my ($changeset, $l);
# Complain for missing log entries
$log = "*** Empty log message! ***" if !$log;
# Do we know this author yet?
if (!($l = $self->{authors}{$author}))
{
main::log_output("Create new author $author");
$l = $self->{authors}{$author} = {};
}
# Do we know this log yet?
if (!($changeset = ${$l}{$log}))
{
main::log_output("Create new ChangeSet for $author", $log);
$changeset = ChangeSet->new($author, $log);
${$l}{$log} = $changeset;
}
main::log_output("Append new file for $author on " . $filerev->filename
. " rev " . $filerev->r1 . " -> " . $filerev->r2);
$changeset->add($filerev);
}
#
# Construct a FileRev entry and add it with CSList::add_filerev()
#
# SYNOPSIS: cs_list->add_file(author, log, repodir, file, r1, r2, tagname, date, keywords)
#
sub add_file
{
my $self = shift;
my $author = shift;
my $log = shift;
my $filerev = FileRev->new(@_);
$self->add_filerev($author, $log, $filerev);
}
#
# Return a list containing all author names.
#
sub authors
{
return keys %{shift->{authors}};
}
#
# Return a list containing all ChangeSet objects for a specified author
# the list is sorted in ascending date order.
#
sub changesets
{
my ($self, $author) = @_;
return sort { $a->{date} cmp $b->{date} } values %{$self->{authors}{$author}};
}
#
# Main program
#
package main;
sub add_to_cc(\$$)
{
my ($cc, $email) = @_;
$$cc .= length($$cc) ? ", $email" : "$email" if($$cc !~ /$email/);
}
# include first file in second file
sub append_file($$)
{
local $/; # enable localized slurp mode
open(my $in, '<', shift) or die $!;
open(my $out, '>>', shift) or die $!;
print {$out} <$in>;
}
# Create empty file with specified owner
sub create_empty_file($;$$)
{
my $filename = shift;
open(FILE, ">$filename") or die $!;
close FILE;
if (my $user = shift)
{
my (undef,undef,$uid,$gid) = getpwnam($user) or die $!;
chown $uid, $gid, $filename;
if (my $mode = shift)
{
chmod $mode, $filename;
}
}
}
#
# Return date of a file argument in RFC2822 format (the format CVS understands),
# or undef if file couldn't be stat'd.
#
sub rfc2822_date($)
{
use Date::Format;
use File::stat;
my $st = stat(shift) or return undef;
return time2str('%a, %d %b %Y %H:%M:%S %z', $st->mtime, 'UTC')
}
#
# Generate unique filename from the repository root and module.
#
sub stamp_filename($)
{
my $module = shift;
my $stamp = $opt_root;
$stamp .= "/$module" if $module;
$stamp =~ tr|:\/\\@.|_____|;
$stamp .= ".stamp";
$stamp = "$opt_stampdir/$stamp";
return $stamp;
}
#
# Return a date range in the format OLDmtime;
}
else
{
$mtime = time - 60*60*24;
}
return time2str('{%Y-%m-%d %H:%M:%S %z}', $mtime, "UTC")
}
#
# Return an SVN date or revision range in the format "{OLD}:{NEW}" or
# "R1:R2" from the given stamp file.
#
sub svn_date_range($$)
{
my ($module, $stamp) = @_;
my $dates;
if ($opt_startdate)
{
$dates = "{$opt_startdate}";
}
else
{
# Get timestamp from file, touch it and get filestamp again
# (diff since yesterday if no previous stamp file is present).
$dates = svn_date($stamp);
}
$dates .= ":";
if ($opt_enddate)
{
$dates .= "{$opt_enddate}";
}
else
{
# Create a new file with the current datestamp
# (we'll overwrite the old stamp later, only on success)
if (! -d $opt_stampdir)
{
print STDERR "Creating $opt_stampdir since it doesn't exist\n";
mkdir $opt_stampdir || die "Can't create $opt_stampdir: $!\n";
}
create_empty_file("$stamp.new");
svn_save_revision("$stamp.new", svn_last_changed($module)); #svn_last_rev($module));
$dates .= svn_date("$stamp.new");
}
return $dates;
}
#
# module, field -> revision
# Get specified field from svn info output:
#
# Path: .
# [...]
# Last Changed Author: sally
# Last Changed Rev: 11754
# Last Changed Date: 2007-01-16 12:34:13 +0100 (Tue, 16 Jan 2007)
# [...]
#
# TODO: use xml.
#
sub svn_info($$)
{
my ($module,$field) = @_;
open my $svninfo, "svn info --non-interactive $opt_root/$module 2>/dev/null |" or die;
while (<$svninfo>)
{
if (/^$field: (\d+)/)
{
close $svninfo;
return $1;
}
}
close $svninfo;
}
#
# module -> last changed revision
#
sub svn_last_changed($)
{
return svn_info(shift, "Last Changed Rev");
}
#
# module -> head
#
sub svn_head($)
{
return svn_info(shift, "Revision");
}
#
# timestamp_filename -> revision
# If timestamp file does not exist or it's empty, return -1.
#
sub svn_rev($)
{
my $rev;
if (open my $revision, shift)
{
chomp($rev = <$revision>);
close $revision;
return ($rev =~ /\d+/) ? $rev : -1;
}
return -1;
}
#
# Write last committed revision in timestamp file.
#
sub svn_save_revision($$)
{
my ($filename, $last_rev) = @_;
open my $revision, ">$filename" or die;
print $revision "$last_rev";
close $revision;
}
#
# module, timestamp_filename -> revision (or date) range
# Build argument for svn's -r option. If last changeset revision isn't avaible,
# returns date range (see svn_date_range).
# If last revision is HEAD return 0.
#
sub svn_rev_range($$)
{
my ($module, $filename) = @_;
my $last_diff = svn_rev($filename);
my $head = svn_head($module);
if ($last_diff == $head)
{
return 0;
}
my @rev = ($last_diff + 1, $head);
# get date range and update revision in file
# TODO: should be splitted in two functions
my $date_range = svn_date_range($module, $filename);
return $rev[0] > 0 ? join(":", @rev) : $date_range;
}
sub bugzillate($)
{
return unless $opt_bugurl;
# "Bug 123", "bug #123", or just "#123"
$_[0] =~ s{((?:[Bb]ug\s*(?:#|)|#)\s*)(\d{2,6})}{$1$2}g;
}
#
# Print a multi-part MIME header
#
sub mime_header(*)
{
my $fh = shift;
print $fh <--
Generated by Deluxe Loginfo $versiontag by Bernardo Innocenti <bernie\@develer.com>
EOF
unhtml($sig) if $toascii;
print $fh $sig;
}
#
# Print HTML header
#
sub html_header(*$)
{
my $fh = shift;
my $title = shift;
print $fh <
$title
EOF
}
#
# Print HTML footer
#
sub html_footer(*)
{
my $fh = shift;
print $fh <
EOF
}
sub send_mail(\$\$\$$$$$)
{
my ($text, $html, $summary, $module, $to, $cc, $author) = @_;
# Avoid sending empty mails
if (!$$text)
{
print STDERR "Not sending empty mail.\n" if $opt_verbose;
return;
}
# Count the change sets in the summary
my @foo = split /^/, $$summary;
if (!defined($opt_index) || (($opt_index > 0) && @foo <= $opt_index))
{
$summary = "";
}
else
{
# Complete the summary
$summary = "ChangeSet Index:
\n\n";
bugzillate $summary;
}
# Build subject line
my $subject = $author ? "changes by $author" : "change log";
$subject .= " for $module" if ($module);
{
# Compute current time
my (undef , undef, undef, $mday, $mon, $year, undef, undef, undef) = localtime;
$year += 1900;
$mon += 1;
$subject .= sprintf(" (%04d-%02d-%02d)", $year, $mon, $mday);
}
my $from;
if ($author and $opt_maildomain)
{
$from = "$author <$author\@$opt_maildomain>";
}
else
{
$from = "$opt_sender <$opt_sender>";
}
my $newsgroups = "";
# Force recipient if set
if ($opt_recipient)
{
$to = $opt_recipient;
}
# Strip "mailto:" (the default)
if ($to =~ /^mailto:(.+)/)
{
$to = $1;
}
# Match newsgroups
elsif ($to =~ /^news:(.+)/)
{
$newsgroups = $1;
$to = "";
}
if ($opt_outfile)
{
print STDERR "Writing mail to '$opt_outfile'...\n" if $opt_verbose;
open (MAIL, ">>$opt_outfile") or die "Couldn't open outfile: $!";
}
elsif ($newsgroups)
{
print STDERR "Posting article in '$newsgroups'...\n" if $opt_verbose;
open (MAIL, "|/usr/bin/inews -h") or die "Couldn't exec inews: $!";
}
elsif ($to)
{
print STDERR "Sending mail to <$to>...\n" if $opt_verbose;
open (MAIL, "|/usr/lib/sendmail -t") or die "Couldn't exec sendmail: $!";
}
else
{
print STDERR "No recipient, discarding mail.\n" if $opt_verbose;
open (MAIL, ">/dev/null") or die "Couldn't open /dev/null: $!";
}
print MAIL "From: $from\n";
print MAIL "To: $to\n" if ($to);
print MAIL "Cc: $cc\n" if ($cc);
print MAIL "Newsgroups: $newsgroups\n" if ($newsgroups);
print MAIL "Subject: $subject\n";
#
# Our intention here is to generate a plain message when
# no HTML output is present and a MIME multi-part message
# when we have HTML output alone or along with ASCII output.
#
mime_header(*MAIL) if $opt_html;
# End of mail headers
print MAIL "\n";
if ($opt_text && $opt_html)
{
mime_part(*MAIL, 'text/plain');
}
if ($opt_text)
{
print MAIL unhtml($summary);
print MAIL $$text;
signature(*MAIL, 1);
}
if ($opt_html)
{
mime_part(*MAIL, 'text/html');
html_header(*MAIL, $subject);
print MAIL $summary;
print MAIL $$html;
signature(*MAIL, 0);
html_footer(*MAIL);
mime_footer(*MAIL);
}
close(MAIL);
# Clear buffers
$$text = "";
$$html = "";
}
sub dodiff($$$$)
{
my ($module, $file, $r1, $r2) = @_;
my $copts = "";
my $is_diff = 1;
# Regex to match source files (doesn't work with cvs rdiff)
#my $srcfile = '\.(cpp|cc|cxx|C|c\+\+|c|l|y|h|hh|hxx|hpp|h\+\+|pl|jsp|java|py|sh)$';
#$copts = "dpbB" if ($file =~ /$srcfile/);
# Avoid diffs for removed files
return "" if ($r2 eq "NONE");
my @args;
if ($is_cvs)
{
# Prepend module name to file name
$file = "$module/$file" if $module;
# Workaround for first revision of added files (does only work with rdiff!)
$r1 = "1.0" if ($r1 eq "NONE");
@args = ('cvs', '-Qfn', "-d$opt_root", 'rdiff', '-kk', "-u$copts", "-r$r1", "-r$r2", $file);
}
elsif ($is_svn)
{
if ($r1 eq "NONE")
{
@args = ('svn', 'cat', "-r$r2", "$opt_root/$file\@$r2");
$is_diff = 0;
}
else
{
@args = ('svn', 'diff', "-r$r1:$r2", "$opt_root/$file\@$r2");
}
}
print STDERR "+ " . join(' ', @args) . "\n" if $opt_verbose;
open(DIFF, "-|") || exec @args;
my $cnt = 0;
my $hunk = "";
my $interesting = 0;
my $size_limit = 0;
my $diff = "";
if (!$is_diff)
{
$hunk .= ("=" x 67) . "\n";
$hunk .= "--- /dev/null\n";
$hunk .= "+++ $file\t(revision $r2)\n";
}
while()
{
next if /^Index:/;
$cnt++;
# Truncate diff when it exceeds
# or if the size grows over full lines of 80 characters.
if ($opt_difflimit and
(($cnt > $opt_difflimit) or (length($diff) > $opt_difflimit * 80)))
{
# Limit exceeded: discard further input and exit
while () {}
$size_limit = 1;
last;
}
if ($is_diff)
{
# Begin of a new hunk?
if (/^@@/)
{
# Consider the hunk only if it wasn't boring
$diff .= $hunk if $interesting;
$hunk = "";
$interesting = 0;
}
# A change that doesn't only contain keywords output is interesting.
$interesting++ if /^[\-\+]/
&& !/\#\*\#/ && !/\*\#\*/ && !/\$(Version|Revision|Id|Log):/;
# Subversion doesn't print the full pathname with remote diffs
s{(\+\+\+|---) [^/]+\t(.*revision.*)}{$1 $file\t$2};
}
else
{
# Make it look like a diff
$hunk .= "+";
}
$hunk .= $_;
}
close(DIFF);
# Notify diff errors
$? and $diff .= "*** DIFF FAILED: $! ***";
# Pick last hunk too
$diff .= $hunk if ($interesting || !$is_diff);
$size_limit and $diff .= "*** SIZE LIMIT EXCEEDED - DIFF TRUNCATED ***";
$diff .= "\n" if length($diff);
return $diff;
}
#
# Escape characters with special meaning for HTML in the passed string(s).
#
sub htmlize
{
foreach (@_)
{
s/&/&/g;
s/</g;
s/>/>/g;
}
}
#
# Remove HTML tags from the passed string(s), trying to retain some formatting.
#
# Note: When called in void context, arguments are modified in-place,
# otherwise unhtml() returns the translated strings.
#
sub unhtml
{
my @copy;
# Copy arguments if caller expects a result
foreach (defined(wantarray) ? @copy = @_ : @_)
{
# Replace hyperlinks with plain-text versions
s#(.*?)#$3 [$1]#g;
# Break paragraphs
s#
#\n#g;
s##\n#g;
# Remove all tags
s#<(.*?)>##g;
# Translate some commonly used entities
s#&#g;
s#<#<#g;
s#>#>#g;
}
return defined(wantarray) ? @copy : undef;
}
sub diff2html($)
{
my ($newstyle, $oldstyle) = ("", "");
my $out = "\n";
foreach (split /\n/, shift)
{
# Remove trailing \n
chomp;
# Replace HTML reserved chars with entities
htmlize $_;
# Mark leading CRs at EOL
s/\r$/<CR><\/span>/;
# Mark leading whitespace at EOL
s/([^ ])(\s+)$/$1$2<\/span>/;
# Mark hidden whitespace before TABs
s/([^ ])( +)\t/$1$2<\/span>\t/;
# Replace long runs of '===' with a horizontal ruler
s#^={30,}#
#;
SWITCH: {
/^(--- |\+\+\+ |diff)/ && do { $newstyle = "006600"; last SWITCH };
/^\+/ && do { $newstyle = "000088"; last SWITCH };
/^-/ && do { $newstyle = "880000"; last SWITCH };
/^@@/ && do { $newstyle = "997700"; last SWITCH };
/^\*\*\*/ && do { $newstyle = "FF0000"; last SWITCH };
$newstyle = "";
}
# Optimization: avoid printing redundant ... pairs
if ($newstyle ne $oldstyle)
{
$out .= "" if ($oldstyle);
$out .= "" if ($newstyle);
$oldstyle = $newstyle;
}
$out .= "$_\n";
}
$out .= "" if ($oldstyle);
$out .= "\n";
return $out;
### BEGIN: EXPERIMENTAL/UNUSED CODE ###
# Fork
my $pid = open (ENSCRIPT_OUTPUT, "-|");
die ("can't fork") unless defined($pid);
if (!$pid)
{
# In child: pass text diff to enscript and die
open (ENSCRIPT_INPUT, "|-") || exec 'enscript', '--color', '-Whtml', '-Ediffu', '-o', '-', '-';
print ENSCRIPT_INPUT shift;
exit 0;
}
else
{
# In parent: collect enscript output
my $out = "";
while()
{
$out .= $_ if (/^/ .. /<\/PRE>/)
}
return $out;
}
### END: EXPERIMENTAL/UNUSED CODE ###
}
sub format_output($$$\$)
{
my ($csnum, $changeset, $module, $ccrecipients) = @_;
my ($fmtText, $fmtHtml, $summary);
my $diff = "";
my @files = $changeset->files;
my $author = $changeset->author;
my $log = $changeset->log;
my $tagname = $changeset->tag;
log_output("Processing ChangeSet by $author of " . scalar @files . " files.");
# Do nothing for empty file lists
if (@files == 0)
{
log_output("Skipping empty ChangeSet");
return ();
}
if ($log =~ /((CVS|SVN).?SILENT)/)
{
# Ignore this commit and reset silent status
log_output("Ignoring commit by $author because of '$1'", $log);
return ();
}
my $incipit = "";
my $incipit_lines = 0;
foreach (split '\n', $log)
{
if (/^CCMAIL:\s*(.*)\s*$/)
{
add_to_cc($ccrecipients, $1);
next;
}
# Collect first lines
if ($incipit_lines < $opt_indexlines)
{
$incipit .= '
' if $incipit;
htmlize $_;
$incipit .= $_;
}
elsif ($incipit_lines == $opt_indexlines and $opt_indexlines != 0)
{
$incipit .= '[...]';
}
$incipit_lines++;
}
$summary .= "CS$csnum - $incipit\n";
$fmtHtml .= "\n";
$fmtHtml .= "\n";
unless ($opt_byauthor and $tagname eq "HEAD")
{
$fmtHtml .= "| \n";
unless ($opt_byauthor)
{
$fmtHtml .= " $author\n";
$fmtText .= " *$author*";
}
unless ($tagname eq "HEAD")
{
$fmtHtml .= " (on branch $tagname)\n";
$fmtText .= " (on branch $tagname)";
}
$fmtHtml .= " |
\n";
$fmtText .= ":\n";
}
$fmtText .= "$log\n";
htmlize $log;
bugzillate $log;
$fmtHtml .= "$log |
\n";
foreach my $filerev (@files)
{
my $file = $filerev->filename();
my $r1 = $filerev->r1();
my $r2 = $filerev->r2();
# Guess CVS operation from revisions pair
my $state = 'M';
my $added_state = 'A';
my $removed_state = $is_svn ? 'D' : 'R';
$state = $removed_state if ($r2 eq "NONE");
$state = $added_state if ($r1 eq "NONE");
# Find out a "good" revision to link to
my $rev = ($r2 eq "NONE") ? $r1 : $r2;
my $binary_text = "";
$binary_text = "[BIN] " if ($filerev->is_binary);
$fmtText .= sprintf("$state %6s $binary_text$file\n", $rev);
my $statelink = $state;
if ($opt_cvsurl)
{
# Prepend a slash to the module name if needed, define to an empty string otherwise
my $module_path = "";
$module_path = "/$module" if $module;
if ($state eq 'M')
{
$statelink = "$state";
}
else # 'R' or 'A'
{
$statelink = "$state";
}
}
my $color = "";
my $color_end = "";
if ($state eq 'A') { $color = '' };
if ($state eq 'R') { $color = '' };
$color_end = "" if $color;
$fmtHtml .= "| $statelink | $color$rev$color_end | $binary_text$color$file$color_end |
\n";
if ($opt_diff && !$binary_text)
{
$diff .= dodiff($module, $file, $r1, $r2);
}
}
$fmtHtml .= "
\n";
# Put some spacing between the file list and the diff
$fmtText .= "\n";
if (length($diff))
{
$fmtText .= $diff . "\n";
$fmtHtml .= diff2html($diff);
}
# Some spacing
$fmtHtml .= "
\n";
return ($fmtText, $fmtHtml, $summary);
}
#
# Process accumulated logs
#
sub process_cslist($$$)
{
my ($cslist, $module, $recipient) = @_;
my $fmtText = "";
my $fmtHtml = "";
my $summary = "";
my $ccrecipients = "";
my $csnum = 1;
foreach my $author ($cslist->authors)
{
foreach my $changeset ($cslist->changesets($author))
{
if (my @out = format_output($csnum, $changeset, $module, $ccrecipients))
{
$fmtText .= $out[0];
$fmtHtml .= $out[1];
$summary .= $out[2];
$csnum++;
}
}
if ($opt_byauthor)
{
send_mail($fmtText, $fmtHtml, $summary, $module, $recipient, $ccrecipients, $author);
# Reset accumulated output
$fmtText = $fmtHtml = $summary = "";
$ccrecipients = "";
$csnum = 1;
}
}
if (!$opt_byauthor)
{
send_mail($fmtText, $fmtHtml, $summary, $module, $recipient, $ccrecipients, undef);
}
}
#
# Helper for cvs_rlog().
# Parse a CVS revision number and return the best guess for its ancestor.
#
sub guess_ancestor($)
{
my ($rev) = @_;
my $old;
# New files don't have an ancestor
return undef if $rev =~ /1\.1$/;
# Parse revision numbers such as 1.34 or 1.7.2.42
# Assign the radix (i.e.: 1.7.2) and the minor (i.e.: 42)
if (my ($radix, $minor) = ($rev =~ /^([\d.]+)\.(\d+)$/))
{
# Guess the ancestor: usually one less than the new revision
--$minor;
$old = "$radix.$minor";
# If the ancestor's minor revision was 0, perhaps we
# were working with the first revision of a branch.
#
# We can't just diff between version 1.9.2.0 and
# 1.9.2.1, because the former doesn't exist and CVS
# would diff against /dev/null.
#
# The correct ancestor in this example would be 1.9.
#
if ($minor <= 0)
{
($old) = ($radix =~ /^([\d.]+)\.\d+$/);
}
}
unless (defined($old))
{
print STDERR "*** Warning: can't parse revision '$rev'\n";
}
return $old;
}
#
# Process commitlog files
#
sub cvs_commitlog($$)
{
my ($module, $commitlog) = @_;
# initialize with the default values
my $tagname = "";
my $juststarted = 1;
my $beforelogmsg = 1;
my $directory = "";
my $author = "";
my $logmessage = "";
my $date = "1970/01/01 00:00:00";
my @files = ();
my $cslist = CSList->new();
open COMMITLOG, "< $commitlog" or die "couldn't open '$commitlog': $!\n";
while()
{
if ($beforelogmsg)
{
# Skip empty lines
next if /^\s*$/;
chomp;
if (/^Dir: \s*(.*)$/) { $directory = $1; }
elsif (/^File: \s*(.+)$/) { push @files, $1; }
elsif (/^Author: \s*(.+)$/) { $author = $1; }
elsif (/^Date: \s*(.+)$/) { $date = $1; }
elsif (/^\s*Tag: \s*(\S+)$/){ $tagname = $1; }
elsif (/^Log Message:/) { $beforelogmsg = 0; }
elsif (/^(\S*) (.*)$/ && $juststarted)
{
# This is for old-style file list (pre CVS 1.12)
$directory = $1;
$_ = $2;
# Ignore:
# "dir - New Directory"
# "dir - Imported sources"
next if /^- /;
while (/^([^,]+,(?:NONE|[\d\.]+),(?:NONE|[\d\.]+)) ?(.*)/)
{
push @files, $1;
$_ = $2;
}
}
$juststarted = 0;
}
else
{
if (/^---END---$/)
{
# Remove newlines at end of log
while (chomp $logmessage) {}
foreach my $f (@files)
{
next if !length($f);
next if $f =~ /^- /; # skip "- New Directory" or "- Imported Sources"
my ($file, $r1, $r2) = $f =~ /(.+),(.+),(.+)/;
my $relfile = $directory ? "$directory/$file" : $file;
# FIXME: no way to tell if a file is binary?
$cslist->add_file($author, $logmessage, "", $relfile, $r1, $r2, $tagname, $date, 'kv');
}
# Restart
@files = ();
$directory = "";
$logmessage = "";
$juststarted = 1;
$beforelogmsg = 1;
$tagname = "";
$date = "1970/01/01 00:00:00";
}
else
{
# Collect log message
$logmessage .= $_;
}
}
}
close COMMITLOG;
# Rotate log file
if (!$opt_keeplogs)
{
my $oldlog = $commitlog;
if ($commitlog =~ /^(.*)\.new$/)
{
$oldlog = $1;
}
else
{
$oldlog = "$commitlog.old";
}
append_file($commitlog, $oldlog);
create_empty_file($commitlog, "cvs", 0660);
}
return $cslist;
}
#
# Process cvs rlog output
#
sub cvs_rlog($$)
{
my ($module, $commitlog) = @_;
my $stamp;
#
# Extract path prefix from the root
#
my $cvsdir;
if ($opt_prefix)
{
$cvsdir = $opt_prefix;
}
elsif ($opt_root)
{
if ($opt_root =~ /^\//) {
$cvsdir = "$opt_root/$module";
}
elsif ($opt_root =~ /^:[^:@]*:[^:@]*@[^:@]*:([^:@]+)$/)
{
$cvsdir = $1;
}
}
print STDERR "Reading rlog with cvsdir='$cvsdir'\n" if ($opt_verbose);
if ($commitlog)
{
open RLOG, "< $commitlog" or die "couldn't open '$commitlog': $!\n";
}
else
{
$stamp = stamp_filename($module);
my $date_range = cvs_date_range($stamp);
# NOTE: $module may be an empty string, which is fine with CVS
# as it means "fetch the repository root".
my @args = ('cvs', '-Qfn', "-d$opt_root", 'rlog', "-d$date_range", "$module");
print STDERR "+ " . join(' ', @args) . "\n" if $opt_verbose;
open(RLOG, "-|") || exec @args;
}
# A nested data structure to hold all collected log data
my $cslist = CSList->new;
# initialize vars with the default values
my $file = "";
my $r2 = "";
my $log = "";
my $directory = "";
my $date = "";
my $author = "";
my $removed = 0;
my $keywords = "";
my %rev_tags;
# Parser states:
# 1 - parsing file header
# 2 - parsing revision log
# 3 - parsing revision message
my $state = 1;
LOOP: while()
{
if ($state == 1) # parse the header
{
if (/^RCS file: (.*)$/)
{
$file = $1;
%rev_tags = ();
}
elsif (/^keyword substitution: (.*)$/)
{
$keywords = $1;
}
elsif (/^symbolic names:/)
{
# Collect list of branches and tags:
#
# LONDON_DEMO: 1.23
# RELEASE_1_0: 1.14
# RELEASE_1_0_PATCHES: 1.23.0.6
#
while()
{
# Extract the branch name and its revision
if (my ($tag, $r) = /^\t(.*): (.*)$/)
{
my @rev = split(/\./, $r);
# Revisions such as "1.27" are obviously not branches
next if @rev <= 2;
# Branch tags have the second last revision number
# fixed to 0 (e.g.: 1.23.0.6)
next unless ($rev[$#rev - 1] == 0);
# Chop away the second last revision number
@rev = @rev[0 .. ($#rev-2), $#rev];
# Rebuild the branch revision (e.g.: 1.23.6)
$r = join('.', @rev);
# Create both a direct and reverse
# mapping to retrieve branches.
$rev_tags{$r} = $tag;
}
else
{
# bail out on end of tag list,
# reprocessing last input as a valid keyword.
redo LOOP;
}
}
}
elsif (/^---/)
{
$state = 2;
$author = "UNKNOWN";
$removed = 0;
}
}
elsif ($state == 2) # parse revision log
{
# EXAMPLE: date: 2003/11/26 19:46:57; author: codewiz; state: dead; lines: +0 -0
if (/^date: *([^;]*); *author: *([^;]*); *state:([^;]*);/)
{
$date = $1;
$author = $2;
$removed = ($3 =~ /dead/) ? 1 : 0;
$state = 3;
}
elsif (/^revision (.*)/)
{
# This is the new revision
$r2 = $1;
}
elsif (/^===/)
{
$state = 1;
}
}
elsif ($state == 3) # parse log message
{
# End of log message?
if (/^---/ or /^===/)
{
# Compute old/new revision numbers
my $r1 = guess_ancestor($r2);
undef $r2 if $removed;
# Try to match the file revision with a branch tag
# Make sure we have a branch revision number
# which is composed by at least 4 components.
#
# We throw away the minor revision number which
# we don't need in order to match the branch number.
#
my ($tagname, $r);
$r = $r2 || $r1;
if ((($r) = ($r =~ /^(\d+(?:.\d+){2,})\.\d+$/)))
{
$tagname = $rev_tags{$r} || "UNKNOWN";
}
$cslist->add_file($author, $log, $cvsdir, $file, $r1, $r2, $tagname, $date, $keywords);
$log = "";
$state = 2;
$state = 1 if /^===/;
}
else
{
# Append log line
$log .= $_;
}
}
} # end while(<>)
close RLOG;
die "Reading cvs rlog failed: $!" if $?;
# Commit date for next log
rename "$stamp.new", "$stamp" if ($stamp);
return $cslist;
}
#
# Read svn log in XML format.
# svn log command, repository path -> CSList
# Output tree (svn log -v --xml):
#
# log
# logentry
# revision
# author
# date
# msg
# paths
# path
# action
# content
# [copyfrom-path]
# [copyfrom-rev]
#
sub svn_xml_log($$)
{
use XML::Simple;
use Data::Dumper;
my ($cmd, $repodir) = @_;
print STDERR "+ " . join(' ', @{$cmd}) . "\n" if $opt_verbose;
open (my $fh, "-|") || exec @{$cmd};
my $log = XMLin($fh, ForceArray => [ 'logentry', 'path' ]);
my $cslist = CSList->new;
print '*'x79 ."\n" . Dumper($log) . '*'x79 ."\n" if $opt_verbose;
foreach my $logentry (@{$log->{logentry}})
{
foreach my $paths (values (%{$logentry->{paths}}))
{
foreach my $path (@{$paths})
{
my $rev = $path->{action} eq "D"
? undef
: $logentry->{revision};
my $prev = $path->{action} eq "A"
? exists $path->{'copyfrom-rev'}
? $path->{'copyfrom-rev'}
: undef
: $logentry->{revision} - 1;
$cslist->add_file($logentry->{author},
$logentry->{msg},
$repodir,
$path->{content},
$prev,
$rev,
"", # branch
$logentry->{date},
""); # keywords
}
}
}
return $cslist;
}
#
# Process svn log output
#
sub svn_rlog($$)
{
my ($module, $commitlog) = @_;
my $stamp;
my $skip_first_cs;
#
# Extract path prefix from the root
#
my $repodir;
if ($opt_prefix)
{
$repodir = $opt_prefix;
}
else
{
$repodir = $module;
}
# elsif ($opt_root and $opt_root =~ m{^[\w\+]+://((?:[-\w\.]+/*)+)$})
# {
# $repodir = $1;
# }
$stamp = stamp_filename($module);
my $range = svn_rev_range($module, $stamp);
if (! $range)
{
return CSList->new;
}
my @args = $commitlog
? ('cat', "$commitlog")
: ('svn', 'log', '--non-interactive', '-v', '--xml', "-r$range", "$opt_root/$module");
my $cslist = svn_xml_log(\@args, $repodir);
rename "$stamp.new", "$stamp" if ($stamp);
return $cslist;
}
sub dolog($$$)
{
my ($module, $commitlog, $recipient) = @_;
my $cslist;
if ($opt_verbose)
{
print STDERR "Processing changelog";
print STDERR " from '$commitlog'" if $commitlog;
print STDERR " on repository '$module'" if $module;
print STDERR "...\n";
}
if ($opt_root)
{
# Match ":protocol:user@host:path"
$is_cvs = ($opt_root =~ m{^:[^:@]*:[^:@]*@[^:@]*:[^:@]+$});
# Match "protocol://path"
$is_svn = ($opt_root =~ m{^[\w\+]+://(\w+@|)(/|)(?:[-\w\.]+/*)+$});
# Match "git" - FIXME: just a placeholder
$is_git = ($opt_root =~ m{^git});
# None? Try to guess from local repository
if (!($is_cvs or $is_svn or $is_git))
{
# Maybe it's a local path, try to determine whether it's CVS or SVN
$is_cvs = (-d "$opt_root/CVSROOT");
$is_svn = (-f "$opt_root/format");
#TODO: add git
print STDERR "Root appears to be a CVS local repository.\n" if $opt_verbose and $is_cvs;
print STDERR "Root appears to be a SVN local repository.\n" if $opt_verbose and $is_svn;
}
# Multiple matches? Something weird must have happened...
$is_cvs ||= 0; $is_svn ||= 0; $is_git ||= 0;
if (($is_cvs + $is_svn + $is_git) > 1) { die "Ambiguous root path '$opt_root'" };
}
else
{
# For now, we only support CVS with commitlog
$is_cvs = 1;
print STDERR "Assuming CVS since no --root given.\n" if $opt_verbose;
}
if ($is_cvs)
{
if ($opt_rlog)
{
$cslist = $opt_rlog ? cvs_rlog($module, $commitlog) : cvs_commitlog($module, $commitlog);
}
else
{
$cslist = cvs_commitlog($module, $commitlog);
}
}
elsif ($is_svn)
{
$opt_rlog or die "SVN only supported through --rlog";
# FIXME: use svn info to infer $opt_module or die if --root does not match repository root
$cslist = svn_rlog($module, $commitlog);
}
elsif ($is_git)
{
# Abort silently to avoid complaints from cron
return;
}
else
{
die "Malformed root path: $opt_root";
}
process_cslist($cslist, $module, $recipient);
}
#
# MAIN ENTRY POINT
#
# Make sure that CVS doesn't use "/root" as $HOME
delete $ENV{'HOME'};
# Make sure SVN and CVS both run with utf8 encoding
$ENV{'LANG'} = 'en_US.UTF-8';
# Use SSH by default with the :ext: protocol
$ENV{'CVS_RSH'} = "ssh";
unless (GetOptions(
'o|outfile=s' => \$opt_outfile,
'sender=s' => \$opt_sender,
'maildomain=s' => \$opt_maildomain,
'to|recipient=s'=> \$opt_recipient,
'by-author' => \$opt_byauthor,
'diff' => \$opt_diff,
'difflimit=n' => \$opt_difflimit,
'index:n' => \$opt_index,
'index-lines:n' => \$opt_indexlines,
'html!' => \$opt_html,
'text|ascii!' => \$opt_text,
'encoding=s' => \$opt_encoding,
'module=s' => \$opt_module,
'prjtab=s' => \$opt_prjtab,
'rlog' => \$opt_rlog,
'stampdir=s' => \$opt_stampdir,
'startdate=s' => \$opt_startdate,
'enddate=s' => \$opt_enddate,
'keeplogs' => \$opt_keeplogs,
'root|cvsroot=s'=> \$opt_root,
'prefix|cvsprefix=s' => \$opt_prefix,
'cvsurl=s' => \$opt_cvsurl,
'bugurl=s' => \$opt_bugurl,
'v|verbose+' => \$opt_verbose,
'h|help' => sub { pod2usage(1); },
'man' => sub { pod2usage(-exitstatus => 0, -verbose => 2); },
))
{
print STDERR "Try '$0 --help' for more information.\n";
exit(1);
}
if (($opt_prjtab || $opt_rlog) && @ARGV)
{
print STDERR "ERROR: input file arguments are mutually exclusive with --rlog and --prjtab modes.\n";
pod2usage(1);
}
if ($opt_prjtab)
{
print STDERR "Opening prjtab file: $opt_prjtab...\n" if $opt_verbose;
open PRJTAB, $opt_prjtab or die "couldn't open $opt_prjtab: $!\n";
while ()
{
chomp;
# Skip comments and blank lines
next if /^#/ or /^\s*$/;
# NOTE: email hogs all remaining fields
my ($prjname, $root, $email) = split(' ', $_, 3);
if (!$prjname || !$root || !$email)
{
die "$opt_prjtab:$.: Illegal prjtab format.";
}
my $commitlog;
if ($opt_rlog)
{
# Use directory as repository root, unless overridden by user
$opt_root = $root;
# Strip away repository path
#$opt_prefix = "$root/$prjname";
}
else
{
$commitlog = "$root/commitlog.new" if !$opt_rlog;
}
dolog($prjname, $commitlog, $email);
}
close PRJTAB;
}
elsif ($opt_rlog)
{
if (!$opt_root)
{
print STDERR "ERROR: --rlog specified and no --root given.\n";
pod2usage(1);
}
# Fetch from SCM
dolog($opt_module, undef, $opt_recipient);
}
else
{
if (!@ARGV)
{
print STDERR "ERROR: no log files provided as input.\n";
pod2usage(1);
}
# Process command line arguments
while(my $logfile = shift @ARGV)
{
dolog($opt_module, $logfile, $opt_recipient);
}
}
__END__
=head1 NAME
deluxeloginfo - Generate commit logs for CVS or SVN
=head1 SYNOPSIS
deluxeloginfo [OPTION]... [FILE]...
Options to specify the repository:
--root=ROOT base repository path
--prefix=DIR Repository prefix to strip away in logs
--module=NAME CVS module or subtree to work on
--prjtab=FILE use FILE as a prjtab file to find repositories
--rlog use remote repository access instead of loginfo files
--stampdir=DIR where to store timestamps for cvs rlog and svn log
(default: /var/state/loginfo)
--startdate=DATE specify start date instead of picking it from stamp file
--enddate=DATE specify end date instead of picking it from stamp file
--keeplogs keep log files instead of rotating them
Options for output redirection:
-o, --outfile=FILE use FILE as output instead of sending mail
--sender=ADDR sender e-mail address for log messages
--maildomain=DOMAIN mail domain for committers (used for From:)
--to=ADDR comma-separated list of mail recipients or newsgroups
(devtools@lists.develer.com or news:comp.lang.c)
Options for output format:
--by-author send one mail for each committer
--diff show diff for commits (requires --root)
--difflimit=N show up to N lines of diff output
--index[=N] output a summary of ChangeSets. If N is specified,
the index is printed only when it contains at least N
ChangeSets. Set to 0 to always print the index
--index-lines=N Number of log lines in an index entry (default: 1)
--notext Disable text output
--nohtml Disable HTML output
--encoding=ENC Set mail text encoding to ENC (default: UTF-8)
Options for web links:
--cvsurl=URL set URL for ViewCVS viewcvs.cgi (default: none)
--bugurl=URL set URL for Bugzilla's show_bug.cgi (default: none)
Miscellaneous options:
-v, --verbose turn on verbose diagnostic output
(specify twice to increase verbosity).
-h, --help brief help message
--man full documentation
=head1 USAGE
Add this line to your CVSROOT/loginfo:
# For CVS 1.11.x
ALL (echo %{sVv}; echo "Author: $USER"; cat ; echo "---END---") >> $CVSROOT/commitlog.new
# For CVS 1.12.x
ALL perl -e 'print "Author: ".shift()."\nDir: ".shift()."\n"; while(@ARGV) { print "File: ".shift().",".shift().",".shift()."\n" } while() { print }; print "---END---\n"' $USER %p %{sVv} >> $CVSROOT/commitlog.new
Put something like this in your crontab:
29 */6 * * * root /usr/local/bin/deluxeloginfo \
--prjtab=/etc/projects/prjtab \
--cvsurl="http://cvs.develer.com/viewcvs.cgi" \
--bugurl="http://bugs.develer.com/show_bug.cgi"
If you have multiple CVS repositories, you can process all them at once
by setting up a project description file like this:
#NAME ROOT RECIPIENTS
kde /repos/kde kde-devel@lists.kde.org
gnome /cvs/gnome gnome-devel@lists.gnome.org
uclinux :ext:bernie@cvs.uclinux.org:/cvsroot uclinux-dev@uclinux.org
gcc svn+ssh://gcc.gnu.org/svn/gcc news:comp.lang.c++,gnu.misc.discuss
Multiple recipients can be specified separated by commas.
Prefix the address list with "news:" to post to newsgroups.
=head1 AUTHOR
Written by Bernardo Innocenti . Contact me for
patch submissions and bug reports.
=cut