2 $ID = q(cvslog,v 1.51 2005/04/16 22:39:39 eagle Exp );
4 # cvslog -- Mail CVS commit notifications.
6 # Written by Russ Allbery <rra@stanford.edu>
7 # Copyright 1998, 1999, 2000, 2001, 2002, 2003, 2004
8 # Board of Trustees, Leland Stanford Jr. University
10 # This program is free software; you can redistribute it and/or modify it
11 # under the same terms as Perl itself.
13 ##############################################################################
14 # Modules and declarations
15 ##############################################################################
17 # The path to the repository. If your platform or CVS implementation doesn't
18 # pass the full path to the cvslog script in $0 (or if your cvslog script
19 # isn't in the CVSROOT directory of your repository for some reason), you will
20 # need to explicitly set $REPOSITORY to the root directory of your repository
21 # (the same thing that you would set CVSROOT to).
22 ($REPOSITORY) = ($0 =~ m%^(.*)/CVSROOT/cvslog$%);
27 use Getopt::Long qw(GetOptions);
28 use IPC::Open2 qw(open2);
29 use POSIX qw(SEEK_SET strftime);
32 use vars qw($DEBUG $ID $REPOSITORY);
34 # Clean up $0 for errors.
37 ##############################################################################
39 ##############################################################################
41 # Given a prefix and a reference to an array, return a list of all strings in
42 # that array with the common prefix stripped off. Also strip off any leading
45 my ($prefix, $list) = @_;
46 my @stripped = @$list;
54 # Return the next version for a CVS version, incrementing the last number.
57 my @version = split (/\./, $version);
59 return join ('.', @version);
62 # Given a directory name, find the corresponding CVS module. We do this by
63 # looking in the modules file, finding the last "word" on each line, making
64 # sure it contains a / (and is therefore assumed to be a directory), and
65 # seeing if it's a prefix of the module path.
68 if (open (MODULES, "$REPOSITORY/CVSROOT/modules")) {
73 my ($name, @rest) = split;
75 next unless ($path =~ m%/%);
76 if ($module =~ s%^$path(\Z|/)%%) {
77 $module = '/' . $module if $module;
78 $module = "<$name>$module";
87 ##############################################################################
88 # Multidirectory commit I/O
89 ##############################################################################
91 # Recalculate the file prefix and module after having loaded a new set of
92 # data. We do this by starting with the prefix from the last set of data and
93 # then stripping off one directory at a time until we find something that is a
94 # common prefix of every affected file.
95 sub recalculate_prefix {
97 my $prefix = $$data{prefix};
98 for (keys %{ $$data{files} }) {
99 while ($prefix && index ($_, $prefix) != 0) {
100 $prefix =~ s%/*([^/]+)$%%;
102 $$data{repository} =~ s%/*\Q$last\E$%%;
103 $$data{localpath} =~ s%/*\Q$last\E$%%;
106 $$data{prefix} = $prefix;
107 $$data{module} = find_module $prefix;
110 # Build the directory in which we'll find our data.
112 my $tmpdir = $ENV{TMPDIR} || '/tmp';
113 $tmpdir .= '/cvs.' . $< . '.' . getpgrp;
117 # Delete all of the accumulated data for multidirectory commits.
119 my $tmpdir = build_tmpdir;
120 unless (opendir (D, $tmpdir)) {
121 warn "$0: can't open $tmpdir: $!\n";
124 for (grep { $_ ne '.' && $_ ne '..' } readdir D) {
128 rmdir $tmpdir or warn "$0: can't remove $tmpdir: $!\n";
131 # Read the file containing the last directory noticed by the commitinfo script
132 # and return that directory name.
134 my $tmpdir = build_tmpdir;
136 if (!-l $tmpdir && -d _ && (lstat _)[4] == $<) {
137 if (open (LAST, $tmpdir . '/directory')) {
146 # Read in a list of files with revisions, one per line, and fill in the
147 # provided hashes. The first one gets the file information put into its files
148 # key, and the second gets lists of added, removed, and modified files.
149 # Returns success or failure.
151 my ($file, $data, $message) = @_;
152 unless (open (FILES, $file)) {
153 warn "$0: can't open $file: $!\n";
156 my (@added, @removed, @modified);
160 my ($name, $old, $new) = /^(.*),([^,]+),([^,]+)$/;
162 $$data{files}{$name} = [ $old, $new ];
163 if ($old eq 'NONE') { push (@added, $name) }
164 elsif ($new eq 'NONE') { push (@removed, $name) }
165 else { push (@modified, $name) }
168 $$message{added} = [ @added ];
169 $$message{removed} = [ @removed ];
170 $$message{modified} = [ @modified ];
174 # Read in message text from a file and put it in the provided hash.
176 my ($file, $message) = @_;
178 if (open (TEXT, $file)) {
182 $$message{text} = [ @text ];
185 # Given a list of message hashes and a new one, merge the new one into the
186 # list. This is done by checking its commit message against the existing ones
187 # and merging the list of affected files if a match is found. If a match
188 # isn't found, the new message is appended to the end of the list.
190 my ($list, $message) = @_;
193 if ("@{ $$_{text} }" eq "@{ $$message{text} }") {
194 push (@{ $$_{added} }, @{ $$message{added} });
195 push (@{ $$_{removed} }, @{ $$message{removed} });
196 push (@{ $$_{modified} }, @{ $$message{modified} });
200 push (@$list, $message) unless $done;
203 # Read in saved data from previous directories. This involves reading in its
204 # affected files and its commit message, merging this with the previous list
205 # of affected files and commit messages, and then recalculating the common
206 # prefix for all the files and deleting all the data we read in.
209 my $tmpdir = build_tmpdir;
210 $$data{messages} = [];
211 for (my $i = 1; -f "$tmpdir/files.$i"; $i++) {
213 read_files ("$tmpdir/files.$i", $data, \%message);
214 read_text ("$tmpdir/text.$i", \%message);
215 merge_message ($$data{messages}, \%message);
217 merge_message ($$data{messages}, $$data{message});
218 recalculate_prefix ($data);
222 # Save data for the files modified in this invocation to be picked up later.
225 my $tmpdir = build_tmpdir;
226 if (-l $tmpdir || !-d _ || (lstat _)[4] != $<) {
227 warn "$0: invalid directory $tmpdir\n";
231 $i++ while -f "$tmpdir/files.$i";
232 unless (open (FILES, "> $tmpdir/files.$i")
233 && open (TEXT, "> $tmpdir/text.$i")) {
234 warn "$0: can't save to $tmpdir/files: $!\n";
237 for (keys %{ $$data{files} }) {
238 my ($old, $new) = @{ $$data{files}{$_} };
239 print FILES join (',', $_, $old, $new), "\n";
241 print TEXT @{ $$data{message}{text} };
242 unless (close (FILES) && close (TEXT)) {
243 warn "$0: can't save to $tmpdir/files: $!\n";
248 ##############################################################################
250 ##############################################################################
252 # Split apart the file names that are passed to cvslog. Unfortunately, CVS
253 # passes all the affected files as one string rather than as separate
254 # arguments, which means that file names that contain spaces and commas pose
255 # problems. Returns the path in the repository and then a list of files with
256 # attached version information; that list may be just a couple of special-case
257 # strings indicating a cvs add of a directory or a cvs import.
259 # The complexity here is purely the fault of CVS, which doesn't have a good
260 # interface to logging hooks.
264 # This ugly hack is here to deal with files at the top level of the
265 # repository; CVS reports those files without including a directory
266 # before the file list. Check to see if what would normally be the
267 # directory name looks more like a file with revisions.
268 my ($root, $rest) = split (' ', $files, 2);
269 if ($rest && $root !~ /(,(\d+(\.\d+)*|NONE)){2}$/) {
275 # Special-case directory adds and imports.
276 if ($files =~ /^- New directory(,NONE,NONE)?$/) {
277 return ($root, 'directory');
278 } elsif ($files =~ /^- Imported sources(,NONE,NONE)?$/) {
279 return ($root, 'import');
282 # Now, split apart $files, which contains just the files, at the spaces
283 # after version information.
285 while ($files =~ s/^((?:.*?)(?:,(?:\d+(?:\.\d+)*|NONE)){2})( |\z)//) {
288 push (@files, $files) if $files;
289 return ($root, 'commit', @files);
292 # Given the summary line passed to the script, parse it into file names and
293 # version numbers (if available). Takes the log information hash and adds a
294 # key for the type of change (directory, import, or commit) and for commits a
295 # hash of file names with values being a list of the previous and the now-
296 # current version number. Also finds the module and stores that in the hash.
298 # The path in the repository (the first argument) is prepended to all of the
299 # file names; we'll pull off the common prefix later.
301 my ($data, @args) = @_;
302 my ($directory, $type, @files);
304 ($directory, $type, @files) = split_files ($args[0]);
305 if ($type eq 'commit') {
306 @files = map { [ /^(.*),([^,]+),([^,]+)$/ ] } @files;
309 $directory = shift @args;
310 if ($args[0] eq '- New directory') {
312 } elsif ($args[0] eq '- Imported sources') {
317 push (@files, [ splice (@args, 0, 3) ]);
321 die "$0: no module given by CVS (no \%{sVv}?)\n" unless $directory;
322 $$data{prefix} = $directory;
323 $$data{module} = find_module $directory;
324 $$data{message}{added} ||= [];
325 $$data{message}{modified} ||= [];
326 $$data{message}{removed} ||= [];
327 if ($type eq 'directory') {
328 $$data{type} = 'directory';
329 $$data{root} = $directory;
330 } elsif ($type eq 'import') {
331 $$data{type} = 'import';
332 $$data{root} = $directory;
334 die "$0: no files given by CVS (no \%{sVv}?)\n";
336 $$data{type} = 'commit';
337 my $added = $$data{message}{added};
338 my $modified = $$data{message}{modified};
339 my $removed = $$data{message}{removed};
341 my ($name, $prev, $cur) = @$_;
342 warn "$0: no version numbers given by CVS (no \%{sVv}?)\n"
344 $$data{files}{"$directory/$name"} = [ $prev, $cur ];
345 if ($prev eq 'NONE') { push (@$added, "$directory/$name") }
346 elsif ($cur eq 'NONE') { push (@$removed, "$directory/$name") }
347 else { push (@$modified, "$directory/$name") }
352 # Parse the header of the CVS log message (containing the path information)
353 # and puts the path information into the data hash.
357 # The first line of the log message will be "Update of <path>".
359 print $path if $DEBUG;
360 $path =~ s/^Update of //;
362 $$data{repository} = $path;
364 # Now comes the path to the local working directory. Grab it and clean it
365 # up, and then ignore the next blank line.
368 my ($local) = /directory (\S+)/;
369 $$data{localpath} = $local;
374 # Extract the tag. We assume that all files will be committed with the same
375 # tag; probably not the best assumption, but it seems workable. Note that we
376 # ignore all of the file lists, since we build those ourself from the version
377 # information (saving the hard challenge of parsing a whitespace-separated
378 # list that could contain filenames with whitespace).
381 my ($current, @added, @modified, @removed);
385 last if /^Log Message/;
386 $$data{tag} = $1, next if /^\s*Tag: (\S+)\s*$/;
390 # Extract the commit message, stripping leading and trailing whitespace.
393 my @message = <STDIN>;
394 print @message if $DEBUG;
395 shift @message while (@message && $message[0] =~ /^\s*$/);
396 pop @message while (@message && $message[-1] =~ /^\s*$/);
397 $$data{message}{text} = [ @message ];
400 ##############################################################################
401 # Formatting functions
402 ##############################################################################
404 # Determine the From header of the message. If CVSUSER is set, we're running
405 # from inside a CVS server, and the From header should reflect information
406 # from the CVS passwd file. Otherwise, pull the information from the system
409 my $cvsuser = $ENV{CVSUSER} || scalar (getpwuid $<);
413 if (open (PASSWD, "$REPOSITORY/CVSROOT/passwd")) {
418 my @info = split ':';
419 if ($info[0] eq $cvsuser) {
426 $name ||= (getpwnam $cvsuser)[6];
428 $address ||= $cvsuser || 'cvs';
430 if ($name =~ /[^\w ]/) {
431 $name = '"' . $name . '"';
433 return "From: " . ($name ? "$name <$address>" : $address) . "\n";
436 # Takes the data hash, a prefix to add to the subject header, and a flag
437 # saying whether to give a full list of files no matter how long it is. Form
438 # the subject line of our message. Try to keep the subject under 78
439 # characters by just giving a count of files if there are a lot of them.
441 my ($data, $prefix, $long) = @_;
442 $prefix = "Subject: " . $prefix;
443 my $length = 78 - length ($prefix) - length ($$data{module});
444 $length = 8 if $length < 8;
446 if ($$data{type} eq 'directory') {
448 } elsif ($$data{type} eq 'import') {
449 $subject = "[import]";
451 my @files = sort keys %{ $$data{files} };
452 @files = simplify ($$data{prefix}, \@files);
453 my $files = join (' ', @files);
454 $files =~ s/[\n\r]/ /g;
455 if (!$long && length ($files) > $length) {
456 $subject = '(' . @files . (@files > 1 ? " files" : " file") . ')';
458 $subject = "($files)";
461 if ($$data{module}) {
462 $subject = "$$data{module} $subject";
464 if ($$data{tag} && $$data{tag} =~ /[^\d.]/) {
465 $subject = "$$data{tag} $subject";
467 return "$prefix$subject\n";
470 # Generate file lists, wrapped at 74 columns, with the right prefix for what
471 # type of file they are.
473 my ($prefix, @files) = @_;
474 local $_ = join (' ', @files);
476 while (length > 64) {
477 if (s/^(.{0,64})\s+// || s/^(\S+)//) {
478 $output .= (' ' x 10) . $1 . "\n";
483 $output .= (' ' x 10) . $_;
484 $output =~ s/\s*$/\n/;
485 $prefix = (' ' x (8 - length ($prefix))) . $prefix;
486 $output =~ s/^ {10}/$prefix: /;
490 # Build the subheader of the report, listing the files changed and some other
491 # information about the change. Returns the header as a list.
493 my ($data, $showdir, $showauthor) = @_;
494 my $user = $ENV{CVSUSER} || (getpwuid $<)[0] || $<;
495 my $date = strftime ('%A, %B %e, %Y @ %T', localtime time);
497 my @header = (" Date: $date\n");
498 push (@header, " Author: $user\n") if $showauthor;
500 # If the paths are too long, trim them by taking off a leading path
501 # component until the length is under 70 characters.
502 my $path = $$data{repository};
503 my $local = $$data{localpath};
504 while (length ($path) > 69) {
505 $path =~ s%^\.\.\.%%;
506 last unless $path =~ s%^/[^/]+%...%;
508 while (length ($local) > 69) {
509 $local =~ s%^([\w.-]+:)\.\.\.%$1%;
510 last unless $local =~ s%^([\w.-]+:)/[^/]+%$1...%;
514 push (@header, " Tag: $$data{tag}\n") if $$data{tag};
515 push (@header, "\n", "Update of $path\n",
518 push (@header, " Path: $path\n");
519 push (@header, " Tag: $$data{tag}\n") if $$data{tag};
524 # Build a report for a particular commit; this includes the list of affected
525 # files and the commit message. Returns the report as a list. Takes the
526 # data, the commit message, and a flag saying whether to add version numbers
529 my ($data, $message, $versions) = @_;
530 my @added = sort @{ $$message{added} };
531 my @modified = sort @{ $$message{modified} };
532 my @removed = sort @{ $$message{removed} };
534 @added = map { "$_ ($$data{files}{$_}[1])" } @added;
535 @removed = map { "$_ ($$data{files}{$_}[0])" } @removed;
538 "$_ ($$data{files}{$_}[0] -> $$data{files}{$_}[1])"
541 @added = simplify ($$data{prefix}, \@added);
542 @modified = simplify ($$data{prefix}, \@modified);
543 @removed = simplify ($$data{prefix}, \@removed);
545 push (@message, build_filelist ('Added', @added)) if @added;
546 push (@message, build_filelist ('Modified', @modified)) if @modified;
547 push (@message, build_filelist ('Removed', @removed)) if @removed;
548 if (@{ $$message{text} }) {
549 push (@message, "\n") if (@added || @modified || @removed);
550 push (@message, @{ $$message{text} });
555 # Builds an array of -r flags to pass to CVS to get diffs between the
556 # appropriate versions, given a reference to the %data hash and the name of
558 sub build_version_flags {
559 my ($data, $file) = @_;
560 my @versions = @{ $$data{files}{$file} };
561 return unless $versions[1] && ($versions[0] ne $versions[1]);
562 if ($versions[0] eq 'NONE') {
563 @versions = ('-r', '0.0', '-r', $versions[1]);
564 } elsif ($versions[1] eq 'NONE') {
565 @versions = ('-r', $versions[0], '-r', next_version $versions[0]);
567 @versions = map { ('-r', $_) } @versions;
572 # Build cvsweb diff URLs. Right now, this is very specific to cvsweb, but
573 # could probably be extended for other web interfaces to CVS. Takes the data
574 # hash and the base URL for cvsweb.
576 my ($data, $cvsweb) = @_;
578 my @cvsweb = ("Diff URLs:\n");
580 for (sort keys %{ $$data{files} }) {
581 my @versions = @{ $$data{files}{$_} };
582 next unless @versions;
584 for ($file, @versions) {
585 s{([^a-zA-Z0-9\$_.+!*\'(),/-])} {sprintf "%%%x", ord ($1)}ge;
587 my $url = "$cvsweb/$file.diff?$options&r1=$versions[0]"
588 . "&r2=$versions[1]\n";
589 push (@cvsweb, $url);
594 # Run a cvs rdiff between the old and new versions and return the output.
595 # This is useful for small changes where you want to see the changes in
596 # e-mail, but probably creates too large of messages when the changes get
597 # bigger. Note that this stores the full diff output in memory.
601 for my $file (sort keys %{ $$data{files} }) {
602 my @versions = build_version_flags ($data, $file);
603 next unless @versions;
604 my $pid = open (CVS, '-|');
606 die "$0: can't fork cvs: $!\n";
607 } elsif ($pid == 0) {
608 open (STDERR, '>&STDOUT') or die "$0: can't reopen stderr: $!\n";
609 exec ('cvs', '-fnQq', '-d', $REPOSITORY, 'rdiff', '-u',
610 @versions, $file) or die "$0: can't fork cvs: $!\n";
614 if ($diff[1] =~ /failed to read diff file header/) {
615 @diff = ($diff[0], "<<Binary file>>\n");
617 push (@difflines, @diff);
623 # Build a summary of the changes by building the patch it represents in /tmp
624 # and then running diffstat on it. This gives a basic idea of the order of
625 # magnitude of the changes. Takes the data hash and the path to diffstat as
628 my ($data, $diffstat) = @_;
629 $diffstat ||= 'diffstat';
630 open2 (\*OUT, \*IN, $diffstat, '-w', '78')
631 or die "$0: can't fork $diffstat: $!\n";
633 for my $file (sort keys %{ $$data{files} }) {
634 my @versions = build_version_flags ($data, $file);
635 next unless @versions;
636 my $pid = open (CVS, '-|');
638 die "$0: can't fork cvs: $!\n";
639 } elsif ($pid == 0) {
640 open (STDERR, '>&STDOUT') or die "$0: can't reopen stderr: $!\n";
641 exec ('cvs', '-fnQq', '-d', $REPOSITORY, 'rdiff', '-u',
642 @versions, $file) or die "$0: can't fork cvs: $!\n";
646 s%^(\*\*\*|---|\+\+\+) \Q$$data{prefix}\E/*%$1 %;
647 s%^Index: \Q$$data{prefix}\E/*%Index: %;
648 if (/^diff -c/) { s% \Q$$data{prefix}\E/*% %g }
649 if (/: failed to read diff file header/) {
651 $short =~ s%^\Q$$data{prefix}\E/*%%;
652 my $date = localtime;
653 print IN "Index: $short\n";
654 print IN "--- $short\t$date\n+++ $short\t$date\n";
655 print IN "@@ -1,1 +1,1 @@\n+<<Binary file>>\n";
656 push (@binary, $short);
667 my $offset = index ($stats[0], '|');
668 for my $file (@binary) {
670 s/^( +\Q$file\E +\| +).*/$1<\<Binary file>>/;
674 unshift (@stats, '-' x $offset, "+\n");
678 ##############################################################################
679 # Configuration file handling
680 ##############################################################################
682 # Load defaults from a configuration file, if any. The syntax is keyword
683 # colon value, where value may be enclosed in quotes. Returns a list
684 # containing the address to which to send all commits (defaults to not sending
685 # any message), the base URL for cvsweb (defaults to not including cvsweb
686 # URLs), the full path to diffstat (defaults to just "diffstat", meaning the
687 # user's path will be searched), the subject prefix, a default host for
688 # unqualified e-mail addresses, additional headers to add to the mail message,
689 # and the full path to sendmail.
691 my $file = $REPOSITORY . '/CVSROOT/cvslog.conf';
694 my $diffstat = 'diffstat';
697 my ($sendmail) = grep { -x $_ } qw(/usr/sbin/sendmail /usr/lib/sendmail);
698 $sendmail ||= '/usr/lib/sendmail';
699 my $subject = 'CVS update of ';
700 if (open (CONFIG, $file)) {
706 my ($key, $value) = /^\s*(\S+):\s+(.*)/;
708 warn "$0:$file:$.: invalid config syntax: $_\n";
712 $value =~ s/^\"(.*)\"$/$1/;
713 if (lc $key eq 'address') { $address = $value }
714 elsif (lc $key eq 'cvsweb') { $cvsweb = $value }
715 elsif (lc $key eq 'diffstat') { $diffstat = $value }
716 elsif (lc $key eq 'mailhost') { $mailhost = $value }
717 elsif (lc $key eq 'sendmail') { $sendmail = $value }
718 elsif (lc $key eq 'subject') { $subject = $value }
719 elsif (lc $key eq 'header') { $headers .= $value . "\n" }
720 else { warn "$0:$file:$.: unrecognized config line: $_\n" }
724 return ($address, $cvsweb, $diffstat, $subject, $mailhost, $headers,
728 ##############################################################################
730 ##############################################################################
732 # Load the configuration file for defaults.
733 my ($address, $cvsweburl, $diffstat, $subject, $mailhost, $headers, $sendmail)
736 # Parse command-line options.
737 my (@addresses, $cvsweb, $diff, $help, $longsubject, $merge, $omitauthor,
738 $showdir, $summary, $version, $versions);
739 Getopt::Long::config ('bundling', 'no_ignore_case', 'require_order');
740 GetOptions ('address|a=s' => \@addresses,
741 'cvsweb|c' => \$cvsweb,
742 'debug|D' => \$DEBUG,
745 'include-versions|i' => \$versions,
746 'long-subject|l' => \$longsubject,
747 'merge|m' => \$merge,
748 'omit-author|o' => \$omitauthor,
749 'show-directory|w' => \$showdir,
750 'summary|s' => \$summary,
751 'version|v' => \$version) or exit 1;
753 print "Feeding myself to perldoc, please wait....\n";
754 exec ('perldoc', '-t', $0);
756 my @version = split (' ', $ID);
757 shift @version if $ID =~ /^\$Id/;
758 my $version = join (' ', @version[0..2]);
759 $version =~ s/,v\b//;
760 $version =~ s/(\S+)$/($1)/;
762 print $version, "\n";
765 die "$0: no addresses specified\n" unless ($address || @addresses);
766 die "$0: unable to determine the repository path\n" unless $REPOSITORY;
767 die "$0: no cvsweb URL specified in the configuration file\n"
768 if $cvsweb && !$cvsweburl;
769 my $showauthor = !$omitauthor;
772 print "Options: ", join ('|', @ARGV), "\n" if $DEBUG;
773 print '-' x 78, "\n" if $DEBUG;
775 parse_files (\%data, @ARGV);
776 parse_paths (\%data);
777 parse_filelist (\%data);
778 parse_message (\%data);
779 print '-' x 78, "\n" if $DEBUG;
781 # Check to see if this is part of a multipart commit. If so, just save the
782 # data for later. Otherwise, read in any saved data and add it to our data.
783 if ($merge && $data{type} eq 'commit') {
784 my $lastdir = read_lastdir;
785 if ($lastdir && $data{repository} ne $lastdir) {
786 save_data (\%data) and exit 0;
787 # Fall through and send a notification if save_data fails.
792 $data{messages} = [ $data{message} ] unless $data{messages};
794 # Exit if there are no addresses to send the message to.
795 exit 0 if (!$address && !@addresses);
797 # Open our mail program.
798 open (MAIL, "| $sendmail -t -oi -oem")
799 or die "$0: can't fork $sendmail: $!\n";
800 my $oldfh = select MAIL;
804 # Build the mail headers.
806 for ($address, @addresses) {
808 $_ .= '@' . $mailhost unless /\@/;
813 print MAIL "To: ", join (', ', @addresses), "\n";
814 print MAIL "Cc: $address\n" if $address;
816 print MAIL "To: $address\n";
818 print MAIL build_from;
819 print MAIL $headers if $headers;
820 print MAIL build_subject (\%data, $subject, $longsubject), "\n";
822 # Build the message and write it out.
823 print MAIL build_header (\%data, $showdir, $showauthor);
824 for (@{ $data{messages} }) {
825 print MAIL "\n", build_message (\%data, $_, $versions);
827 if ($data{type} eq 'commit') {
828 print MAIL "\n\n", build_summary (\%data, $diffstat) if $summary;
829 print MAIL "\n\n", build_cvsweb (\%data, $cvsweburl) if $cvsweb;
830 print MAIL "\n\n", build_diff (\%data) if $diff;
833 # Make sure sending mail succeeded.
835 unless ($? == 0) { die "$0: sendmail exit status " . ($? >> 8) . "\n" }
839 ##############################################################################
841 ##############################################################################
845 cvslog - Mail CVS commit notifications
849 B<cvslog> [B<-cDdhilmosvw>] [B<-a> I<address> ...] %{sVv}
853 CVS 1.10 or later, Perl 5.004 or later, diffstat for the B<-s> option, and a
854 sendmail command that can accept formatted mail messages for delivery.
858 B<cvslog> is intended to be run out of CVS's F<loginfo> administrative file.
859 It parses the (undocumented) format of CVS's commit notifications, cleans it
860 up and reformats it, and mails the notification to one or more e-mail
861 addresses. Optionally, a diffstat(1) summary of the changes can be added to
862 the notification, and a CVS commit spanning multiple directories can be
863 combined into a single notification (by default, CVS generates a separate
864 notification for each directory).
866 To combine a commit spanning multiple directories into a single notification,
867 B<cvslog> needs the help of an additional program run from the F<commitinfo>
868 administrative file that records the last directory affected by the commit.
869 See the description in L<"FILES"> for what files and directories must be
870 created. One such suitable program is B<cvsprep> by the same author.
872 For information on how to add B<cvslog> to your CVS repository, see
873 L<"INSTALLATION"> below. B<cvslog> also looks for a configuration file named
874 F<cvslog.conf>; for details on the format of that file, see
877 The From: header of the mail message sent by B<cvslog> is formed from the user
878 making the commit. The contents of the environment variable CVSUSER or the
879 name of the user doing the commit if CVSUSER isn't set is looked up in the
880 F<passwd> file in the CVS repository, if present, and the fourth field is used
881 as the full name and the fifth as the user's e-mail address. If that user
882 isn't found in F<passwd>, it's looked up in the system password file instead
883 to try to find a full name. Otherwise, that user is just used as an e-mail
890 =item B<-a> I<address>, B<--address>=I<address>
892 Send the commit notification to I<address> (possibly in addition to the
893 address defined in F<cvslog.conf>). This option may occur more than once,
894 and all specified addresses will receive a copy of the notification.
896 =item B<-c>, B<--cvsweb>
898 Append the cvsweb URLs for all the diffs covered in the commit message to
899 the message. The base cvsweb URL must be set in the configuration file.
900 The file name will be added and the C<r1> and C<r2> parameters will be
901 appended with the appropriate values, along with C<f=h> to request formatted
902 diff output. Currently, the cvsweb URLs are not further configurable.
904 =item B<-D>, B<--debug>
906 Prints out the information B<cvslog> got from CVS as it works. This option
907 is mostly useful for developing B<cvslog> and checking exactly what data CVS
908 provides. The first line of output will be the options passed to B<cvsweb>,
911 =item B<-d>, B<--diff>
913 Append the full diff output for each change to the notification message.
914 This is probably only useful if you know that all changes for which
915 B<cvslog> is run will be small. Note that the entire diff output is
916 temporarily stored in memory, so this could result in excessive memory usage
917 in B<cvslog> for very large changes.
919 When this option is given, B<cvslog> needs to be able to find B<cvs> in the
922 If one of the committed files is binary and this is detected by B<cvs>,
923 B<cvslog> will suppress the diff and replace it with a note that the file is
926 =item B<-h>, B<--help>
928 Print out this documentation (which is done simply by feeding the script to
931 =item B<-i>, B<--include-versions>
933 Include version numbers (in parentheses) after the file names in the lists
934 of added, removed, and changed files. By default, only the file names are
937 =item B<-l>, B<--long-subject>
939 Normally, B<cvslog> will just list the number of changed files rather than the
940 complete list of them if the subject would otherwise be too long. This flag
941 disables that behavior and includes the full list of modified files in the
942 subject header of the mail, no matter how long it is.
944 =item B<-m>, B<--merge>
946 Merge multidirectory commits into a single notification. This requires that a
947 program be run from F<commitinfo> to record the last directory affected by the
948 commit. Using this option will cause B<cvslog> to temporarily record
949 information about a commit in progress in TMPDIR or /tmp; see L<"FILES">.
951 =item B<-o>, B<--omit-author>
953 Omit the author information from the commit notification. This is useful
954 where all commits are done by the same person (so the author information is
955 just noise) or where the author information isn't actually available.
957 =item B<-s>, B<--summary>
959 Append to each commit notification a summary of the changes, produced by
960 generating diffs and feeding those diffs to diffstat(1). diffstat(1) must be
961 installed to use this option; see also the B<diffstat> configuration parameter
962 in L<"CONFIGURATION">.
964 When this option is given, B<cvslog> needs to be able to find B<cvs> in the
967 If one of the committed files is binary and this is detected by B<cvs>,
968 B<cvslog> will replace the uninformative B<diffstat> line corresponding to
969 that file (B<diffstat> will indicate that nothing changed) with a note that
972 =item B<-v>, B<--version>
974 Print out the version of B<cvslog> and exit.
976 =item B<-w>, B<--show-directory>
978 Show the working directory from which the commit was made. This is usually
979 not enlightening and when running CVS in server mode will always be some
980 uninteresting directory in /tmp, so the default is to not include this
987 B<cvslog> will look for a configuration file named F<cvslog.conf> in the
988 CVSROOT directory of your repository. Absence of this file is not an error;
989 it just means that all of the defaults will be used. The syntax of this file
990 is one configuration parameter per line in the format:
994 The value may be enclosed in double-quotes and must be enclosed in
995 double-quotes if there is trailing whitespace that should be part of the
996 value. There is no way to continue a line; each parameter must be a single
997 line. Lines beginning with C<#> are comments.
999 The following configuration parameters are supported:
1005 The address or comma-separated list of addresses to which all commit messages
1006 should be sent. If this parameter is not given, the default is to send the
1007 commit message only to those addresses specified with B<-a> options on the
1008 command line, and there must be at least one B<-a> option on the command line.
1012 The base URL for cvsweb diffs for this repository. Only used if the B<-c>
1013 option is given; see the description of that option for more information
1014 about how the full URL is constructed.
1018 The full path to the diffstat(1) program. If this parameter is not given, the
1019 default is to look for diffstat(1) on the user's PATH. Only used if the B<-s>
1024 The value should be a valid mail header, such as "X-Ticket: cvs". This header
1025 will be added to the mail message sent. This configuration parameter may
1026 occur multiple times, and all of those headers will be added to the message.
1030 The hostname to append to unqualified addresses given on the command line with
1031 B<-a>. If set, an C<@> and this value will be appended to any address given
1032 with B<-a> that doesn't contain C<@>. This parameter exists solely to allow
1033 for shorter lines in the F<loginfo> file.
1037 The full path to the sendmail binary. If not given, this setting defaults to
1038 either C</usr/sbin/sendmail> or C</usr/lib/sendmail>, whichever is found,
1039 falling back on C</usr/lib/sendmail>.
1043 The subject prefix to use for the mailed notifications. Appended to this
1044 prefix will be the module or path in the repository of the affected directory
1045 and then either a list of files or a count of files depending on the available
1046 space. The default is C<"CVS update of ">.
1052 Follow these steps to add cvslog to your project:
1058 Check out CVSROOT for your repository (see the CVS manual if you're not sure
1059 how to do this), copy this script into that directory, change the first line
1060 to point to your installation of Perl if necessary, and cvs add and commit it.
1066 cvslog Unable to check out CVS log notification script cvslog
1068 to F<checkoutlist> in CVSROOT and commit it.
1072 If needed, create a F<cvslog.conf> file as described above and cvs add and
1073 commit it. Most installations will probably want to set B<address>, since the
1074 most common CVS configuration is a single repository per project with all
1075 commit notifications sent to the same address. If you don't set B<address>,
1076 you'll need to add B<-a> options to every invocation of B<cvslog> in
1081 If you created a F<cvslog.conf> file, add a line like:
1083 cvslog.conf Unable to check out cvslog configuration cvslog.conf
1085 to F<checkoutlist> in CVSROOT and commit it.
1089 Set up your rules in F<loginfo> for those portions of the repository you want
1090 to send CVS commit notifications for. A good starting rule is:
1092 DEFAULT $CVSROOT/CVSROOT/cvslog %{sVv}
1094 which will send notifications for every commit to your repository that doesn't
1095 have a separate, more specific rule to the value of B<address> in
1096 F<cvslog.conf>. You must always invoke B<cvslog> as $CVSROOT/CVSROOT/cvslog;
1097 B<cvslog> uses the path it was invoked as to find the root of the repository.
1098 If you have different portions of your repository that should send
1099 notifications to different places, you can use a series of rules like:
1101 ^foo/ $CVSROOT/CVSROOT/cvslog -a foo-commit %{sVv}
1102 ^bar/ $CVSROOT/CVSROOT/cvslog -a bar-commit %{sVv}
1104 This will send notification of commits to anything in the C<foo> directory
1105 tree in the repository to foo-commit (possibly qualified with B<mailhost> from
1106 F<cvslog.conf>) and everything in C<bar> to bar-commit. No commit
1107 notifications will be sent for any other commits. The C<%{sVv}> string is
1108 replaced by CVS with information about the committed files and should always
1111 If you are using CVS version 1.12.6 or later, the format strings for
1112 F<loginfo> rules have changed. Instead of C<%{sVv}>, use C<-- %p %{sVv}>,
1113 once you've set UseNewInfoFmtStrings=yes in F<config>. For example:
1115 DEFAULT $CVSROOT/CVSROOT/cvslog -- %p %{sVv}
1117 Any options to B<cvslog> should go before C<-->. See the CVS documentation
1118 for more details on the new F<loginfo> format.
1122 If you want summaries of changes, obtain and compile diffstat and add B<-s> to
1123 the appropriate lines in F<loginfo>. You may also need to set B<diffstat> in
1126 diffstat is at L<http://dickey.his.com/diffstat/diffstat.html>.
1130 If you want merging of multidirectory commits, add B<-m> to the invocations of
1131 B<cvslog>, copy B<cvsprep> into your checked out copy of CVSROOT, change the
1132 first line of the script if necessary to point to your installation of Perl,
1133 and cvs add and cvs commit it. Then, a line like:
1135 cvsprep Unable to check out CVS log notification script cvsprep
1137 to F<checkoutlist> in CVSROOT and commit it.
1139 See L<"WARNINGS"> for some warnings about the security of multi-directory
1144 If your operating system doesn't pass the full path to the B<cvslog>
1145 executable to this script when it runs, you'll need to edit the beginning of
1146 this script and set $REPOSITORY to the correct path to the root of your
1147 repository. This should not normally be necessary. See the comments in this
1148 script for additional explanation.
1154 Send all commits under the baz directory to B<address> defined in
1157 ^baz/ $CVSROOT/CVSROOT/cvslog -msw %{sVv}
1159 Multidirectory commits will be merged if B<cvsprep> is also installed, a
1160 diffstat(1) summary will be appended to the notification, and the working
1161 directory from which the files were committed will also be included. This
1162 line should be put in F<loginfo>.
1164 See L<"INSTALLATION"> for more examples.
1170 =item can't fork %s: %s
1172 (Fatal) B<cvslog> was unable to run a program that it wanted to run. This may
1173 result in no notification being sent or in information missing. Generally
1174 this means that the program in question was missing or B<cvslog> couldn't find
1177 =item can't open %s: %s
1179 (Warning) B<cvslog> was unable to open a file. For the modules file, this
1180 means that B<cvslog> won't do any directory to module mapping. For files
1181 related to multidirectory commits, this means that B<cvslog> can't gather
1182 together information about such a commit and will instead send an individual
1183 notification for the files affected in the current directory. (This means
1184 that some information may have been lost.)
1186 =item can't remove %s: %s
1188 (Warning) B<cvslog> was unable to clean up after itself for some reason, and
1189 the temporary files from a multidirectory commit have been left behind in
1192 =item can't save to %s: %s
1194 (Warning) B<cvslog> encountered an error saving information about a
1195 multidirectory commit and will instead send an individual notification for the
1196 files affected in the current directory.
1198 =item invalid directory %s
1200 (Warning) Something was strange about the given directory when B<cvslog> went
1201 to use it to store information about a multidirectory commit, so instead a
1202 separate notification for the affected files in the current directory will be
1203 sent. This means that the directory was actually a symlink, wasn't a
1204 directory, or wasn't owned by the right user.
1206 =item invalid config syntax: %s
1208 (Warning) The given line in F<cvslog.conf> was syntactically invalid. See
1209 L<"CONFIGURATION"> for the correct syntax.
1211 =item no %s given by CVS (no %{sVv}?)
1213 (Fatal) The arguments CVS passes to B<cvslog> should be the directory within
1214 the repository that's being changed and a list of files being changed with
1215 version information for each file. Something in that was missing. This error
1216 generally means that the invocation of B<cvslog> in F<loginfo> doesn't have
1217 the magic C<%{sVv}> variable at the end but instead has no variables or some
1218 other variable like C<%s>, or means that you're using a version of CVS older
1221 =item no addresses specified
1223 (Fatal) There was no B<address> parameter in F<cvslog.conf> and no B<-a>
1224 options on the command line. At least one recipient address must be specified
1225 for the CVS commit notification.
1227 =item sendmail exit status %d
1229 (Fatal) sendmail exited with a non-zero status. This may mean that the
1230 notification message wasn't sent.
1232 =item unable to determine the repository path
1234 (Fatal) B<cvslog> was unable to find the root of your CVS repository from the
1235 path by which it was invoked. See L<"INSTALLATION"> for hints on how to fix
1238 =item unrecognized config line: %s
1240 (Warning) The given configuration parameter isn't one of the ones that
1241 B<cvslog> knows about.
1247 All files relative to $CVSROOT will be found by looking at the full path
1248 B<cvslog> was invoked as and pulling off the path before C<CVSROOT/cvslog>.
1249 If this doesn't work on your operating system, you'll need to edit this script
1254 =item $CVSROOT/CVSROOT/cvslog.conf
1256 Read for configuration directives if it exists. See L<"CONFIGURATION">.
1258 =item $CVSROOT/CVSROOT/modules
1260 Read to find the module a given file is part of. Rather than always giving
1261 the full path relative to $CVSROOT of the changed files, B<cvslog> tries to
1262 find the module that that directory belongs to and replaces the path of that
1263 module with the name of the module in angle brackets. Modules are found by
1264 reading this file, looking at the last white-space-separated word on each
1265 line, and if it contains a C</>, checking to see if it is a prefix of the path
1266 to the files affected by a commit. If so, the first white-space-separated
1267 word on that line of F<modules> is taken to be the affected module. The first
1268 matching entry is used.
1270 =item $CVSROOT/CVSROOT/passwd
1272 Read to find the full name and e-mail address corresponding to a particular
1273 user. The full name is expected to be the fourth field colon-separated field
1274 and the e-mail address the fifth. Defaults derived from the system password
1275 file are used if these are not provided.
1277 =item TMPDIR/cvs.%d.%d
1279 Information about multidirectory commits is read from and stored in this
1280 directory. This script will never create this directory (the helper script
1281 B<cvsprep> that runs from F<commitinfo> has to do that), but it will read and
1282 store information in it and when the commit message is sent, it will delete
1283 everything in this directory and remove the directory.
1285 The first %d is the numeric UID of the user running B<cvslog>. The second %d
1286 is the process group B<cvslog> is part of. The process group is included in
1287 the directory name so that if you're running a shell that calls setpgrp() (any
1288 modern shell with job control should), multiple commits won't collide with
1289 each other even when done from the same shell.
1291 If TMPDIR isn't set in the environment, F</tmp> is used for TMPDIR.
1293 =item TMPDIR/cvs.%d.%d/directory
1295 B<cvslog> expects this file to contain the name of the final directory
1296 affected by a multidirectory commit. Each B<cvslog> invocation will save the
1297 data that it's given until B<cvslog> is invoked for this directory, and then
1298 all of the saved data will be combined with the data for that directory and
1299 sent out as a single notification.
1301 This file must be created by a script such as B<cvsprep> run from
1302 F<commitinfo>. If it isn't present, B<cvslog> doesn't attempt to combine
1303 multidirectory commits, even if B<-m> is used.
1313 Used to find cvs and diffstat when the B<-s> option is in effect. If the
1314 B<diffstat> configuration option is set, diffstat isn't searched for on the
1315 user's PATH, but cvs must always be found on the user's PATH in order for
1316 diffstat summaries to work.
1320 If set, specifies the temporary directory to use instead of F</tmp> for
1321 storing information about multidirectory commits. Setting this to some
1322 private directory is recommended if you're doing CVS commits on a multiuser
1323 machine with other untrusted users due to the standard troubles with safely
1324 creating files in F</tmp>. (Note that other programs besides B<cvslog> also
1331 Merging multidirectory commits requires creating predictably-named files to
1332 communicate information between different processes. By default, those files
1333 are created in F</tmp> in a directory created for that purpose. While this
1334 should be reasonably safe on systems that don't allow one to remove
1335 directories owned by other people in F</tmp>, since a directory is used rather
1336 than an individual file and since various sanity checks are made on the
1337 directory before using it, this is still inherently risky on a multiuser
1338 machine with a world-writeable F</tmp> directory if any of the other users
1341 For this reason, I highly recommend setting TMPDIR to some directory, perhaps
1342 in your home directory, that only you have access to if you're in that
1343 situation. Not only will this make B<cvslog> more secure, it may make some of
1344 the other programs you run somewhat more secure (lots of programs will use the
1345 value of TMPDIR if set). I really don't trust the security of creating any
1346 predictably-named files or directories in F</tmp> and neither should you.
1348 Multiple separate B<cvslog> invocations in F<loginfo> interact oddly with
1349 merging of multidirectory commits. The commit notification will be sent to
1350 the addresses and in the style configured for the last invocation of
1351 B<cvslog>, even if some of the earlier directories had different notification
1352 configurations. As a general rule, it's best not to merge multidirectory
1353 commits that span separate portions of the repository with different
1354 notification policies.
1356 B<cvslog> doesn't support using B<commit_prep> (which comes with CVS) as a
1357 F<commitinfo> script to provide information about multidirectory commits
1358 because it writes files directly in F</tmp> rather than using a subdirectory.
1360 Some file names simply cannot be supported correctly in CVS versions prior
1361 to 1.12.6 (with new-style info format strings turned on) because of
1362 ambiguities in the output from CVS. For example, file names beginning with
1363 spaces are unlikely to produce the correct output, and file names containing
1364 newlines will likely result in odd-looking mail messages.
1368 There probably should be a way to specify the path to cvs for generating
1369 summaries and diffs, to turn off the automatic module detection stuff, to
1370 provide for transformations of the working directory (stripping the domain
1371 off the hostname, shortening directory paths in AFS), and to configure the
1372 maximum subject length. The cvsweb support could stand to be more
1375 Many of the logging scripts out there are based on B<log_accum>, which comes
1376 with CVS and uses a different output format for multidirectory commits. I
1377 prefer the one in B<cvslog>, but it would be nice if B<cvslog> could support
1380 File names containing spaces may be wrapped at the space in the lists of
1381 files added, modified, or removed. The lists may also be wrapped in the
1382 middle of the appended version information if B<-i> is used.
1384 Multi-directory commit merging may mishandle file names that contain
1385 embedded newlines even with CVS version 1.12.6 or later due to the file
1386 format that B<cvslog> uses to save the intermediate data.
1390 Some parts of this script are horrible hacks because the entirety of commit
1391 notification handling in CVS is a horrible, undocumented hack. Better commit
1392 notification support in CVS proper would be welcome, even if it would make
1393 this script obsolete.
1397 cvs(1), diffstat(1), cvsprep(1).
1399 diffstat is at L<http://dickey.his.com/diffstat/diffstat.html>.
1401 Current versions of this program are available from its web site at
1402 L<http://www.eyrie.org/~eagle/software/cvslog/>. B<cvsprep> is available
1403 from this same location.
1407 Russ Allbery <rra@stanford.edu>.
1409 =head1 COPYRIGHT AND LICENSE
1411 Copyright 1998, 1999, 2000, 2001, 2002, 2003, 2004 Board of Trustees, Leland
1412 Stanford Jr. University.
1414 This program is free software; you can redistribute it and/or modify it
1415 under the same terms as Perl itself.