#!/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 <bernie@develer.com>
#
# 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 OLD<NEW from the given stamp file.
#
sub cvs_date_range($)
{
	my $stamp = shift;
	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 = rfc2822_date($stamp) || "yesterday";
	}

	$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");
		$dates .= rfc2822_date("$stamp.new");
	}

	return $dates;
}

sub svn_date($)
{
	use Date::Format;
	use File::stat;

	my $mtime;
	if (my $st = stat(shift))
	{
		$mtime = $st->mtime;
	}
	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})}{<a href='$opt_bugurl?id=$2'>$1$2</a>}g;
}

#
# Print a multi-part MIME header
#
sub mime_header(*)
{
	my $fh = shift;
	print $fh <<EOF;
MIME-Version: 1.0
Content-Type: multipart/alternative;
  boundary="Boundary-00=_R+zZ+ucfVzM1q7w"

This is a multi-part message in MIME format.

EOF
}

#
# Print MIME part header
#
sub mime_part(*$)
{
	my $fh = shift;
	my $type = shift;

	print $fh <<EOF;
--Boundary-00=_R+zZ+ucfVzM1q7w
Content-Type: $type;
  charset="$opt_encoding"
Content-Disposition: inline

EOF
}

#
# Print MIME footer
#
sub mime_footer(*)
{
	my $fh = shift;
	print $fh <<EOF;
--Boundary-00=_R+zZ+ucfVzM1q7w--
EOF
}

#
# Print ASCII e-mail signature
#
sub signature(*$)
{
	my $fh = shift;
	my $toascii = shift;

	my $sig = <<EOF;

<p>--<br />
<small>Generated by <a href="http://www.codewiz.org/projects/index.html#loginfo">Deluxe Loginfo</a> $versiontag by Bernardo Innocenti &lt;bernie\@develer.com&gt;</small></p>
EOF

	unhtml($sig) if $toascii;
	print $fh $sig;
}

#
# Print HTML header
#
sub html_header(*$)
{
	my $fh = shift;
	my $title = shift;

	print $fh <<EOF;
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>$title</title>
</head>
<body text='#000000' bgcolor='#ffffff'>
EOF
}

#
# Print HTML footer
#
sub html_footer(*)
{
	my $fh = shift;

	print $fh <<EOF;
</body>
</html>
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 = "<h3>ChangeSet Index:</h3>\n<ul>\n" . $$summary . "</ul>\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(<DIFF>)
	{
		next if /^Index:/;
		$cnt++;

		# Truncate diff when it exceeds <difflimit>
		# or if the size grows over <difflimit> 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 (<DIFF>) {}
			$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/&/&amp;/g;
		s/</&lt;/g;
		s/>/&gt;/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#<a\s+.*href=['"](.*?)['"](.*?)>(.*?)</a>#$3 [$1]#g;

		# Break paragraphs
		s#<br\s*/?>#\n#g;
		s#</p>#\n#g;

		# Remove all tags
		s#<(.*?)>##g;

		# Translate some commonly used entities
		s#&amp;#&#g;
		s#&lt;#<#g;
		s#&gt;#>#g;
	}

	return defined(wantarray) ? @copy : undef;
}

sub diff2html($)
{
	my ($newstyle, $oldstyle) = ("", "");
	my $out = "<pre>\n";

	foreach (split /\n/, shift)
	{
		# Remove trailing \n
		chomp;

		# Replace HTML reserved chars with entities
		htmlize $_;

		# Mark leading CRs at EOL
		s/\r$/<span style="background-color: #FF0000">&lt;CR&gt;<\/span>/;

		# Mark leading whitespace at EOL
		s/([^ ])(\s+)$/$1<span style="background-color: #FF0000">$2<\/span>/;

		# Mark hidden whitespace before TABs
		s/([^ ])( +)\t/$1<span style="background-color: #FF0000">$2<\/span>\t/;

		# Replace long runs of '===' with a horizontal ruler
		s#^={30,}#<hr />#;

		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 <font>...</font> pairs
		if ($newstyle ne $oldstyle)
		{
			$out .= "</font>" if ($oldstyle);
			$out .= "<font color='#$newstyle'>" if ($newstyle);
			$oldstyle = $newstyle;
		}

		$out .= "$_\n";
	}
	$out .= "</font>" if ($oldstyle);
	$out .= "</pre>\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(<ENSCRIPT_OUTPUT>)
		{
			$out .= $_ if (/^<PRE>/ .. /<\/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 .= '<br />' if $incipit;
			htmlize $_;
			$incipit .= $_;
		}
		elsif ($incipit_lines == $opt_indexlines and $opt_indexlines != 0)
		{
			$incipit .= '[...]';
		}
		$incipit_lines++;
	}

	$summary .= "<li><a href='#cs$csnum'>CS$csnum</a> - $incipit</li>\n";

	$fmtHtml .= "<a name='cs$csnum'></a>\n";
	$fmtHtml .= "<table border='0' cellspacing='0' cellpadding='5' width='100%' bgcolor='#eeeeee'>\n";
	unless ($opt_byauthor and $tagname eq "HEAD")
	{
		$fmtHtml .= "<tr><td colspan='3' bgcolor='#dddddd'>\n";
		unless ($opt_byauthor)
		{
			$fmtHtml .= " <font color='#bb2222'><strong>$author</strong></font>\n";
			$fmtText .= " *$author*";
		}
		unless ($tagname eq "HEAD")
		{
			$fmtHtml .= " <font color='#225522'><em>(on branch $tagname)</em></font>\n";
			$fmtText .= " (on branch $tagname)";
		}
		$fmtHtml .= "</td></tr>\n";
		$fmtText .= ":\n";
	}

	$fmtText .= "$log\n";
	htmlize $log;
	bugzillate $log;
	$fmtHtml .= "<tr><td colspan='3' bgcolor='#dddddd'><pre>$log</pre></td></tr>\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 = "<a href=\"$opt_cvsurl$module_path/$file.diff?r1=text&amp;tr1=$r1&amp;r2=text&amp;tr2=$r2&amp;diff_format=h\">$state</a>";
			}
			else # 'R' or 'A'
			{
				$statelink = "<a href=\"$opt_cvsurl$module_path/$file?rev=$rev&amp;content-type=text/vnd.viewcvs-markup\">$state</a>";
			}
		}

		my $color = "";
		my $color_end = "";
		if ($state eq 'A') { $color = '<font color="#000088">' };
		if ($state eq 'R') { $color = '<font color="#880000">' };
		$color_end = "</font>" if $color;

		$fmtHtml .= "<tr><td width='1%'>$statelink</td><td width='1%'>$color$rev$color_end</td><td width='100%'>$binary_text$color$file$color_end</td></tr>\n";


		if ($opt_diff && !$binary_text)
		{
			$diff .= dodiff($module, $file, $r1, $r2);
		}
	}
	$fmtHtml .= "</table>\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 .= "<p>&nbsp;</p>\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(<COMMITLOG>)
	{
		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(<RLOG>)
	{
		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(<RLOG>)
				{
					# 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 (<PRJTAB>)
	{
		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(<STDIN>) { 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 <bernie@develer.com>. Contact me for
patch submissions and bug reports.

=cut

