change
[repair.git] / CVSROOT / cvslog
1 #!/usr/bin/perl -w
2 $ID = q(cvslog,v 1.51 2005/04/16 22:39:39 eagle Exp );
3 #
4 # cvslog -- Mail CVS commit notifications.
5 #
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
9 #
10 # This program is free software; you can redistribute it and/or modify it
11 # under the same terms as Perl itself.
12
13 ##############################################################################
14 # Modules and declarations
15 ##############################################################################
16
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$%);
23 $REPOSITORY ||= '';
24
25 require 5.004;
26
27 use Getopt::Long qw(GetOptions);
28 use IPC::Open2 qw(open2);
29 use POSIX qw(SEEK_SET strftime);
30
31 use strict;
32 use vars qw($DEBUG $ID $REPOSITORY);
33
34 # Clean up $0 for errors.
35 $0 =~ s%^.*/%%;
36
37 ##############################################################################
38 # Utility functions
39 ##############################################################################
40
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
43 # ./ if present.
44 sub simplify {
45     my ($prefix, $list) = @_;
46     my @stripped = @$list;
47     for (@stripped) {
48         s%^\Q$prefix\E/*%%;
49         s%^\./+%%;
50     }
51     return @stripped;
52 }
53
54 # Return the next version for a CVS version, incrementing the last number.
55 sub next_version {
56     my $version = shift;
57     my @version = split (/\./, $version);
58     $version[-1]++;
59     return join ('.', @version);
60 }
61
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.
66 sub find_module {
67     my $module = shift;
68     if (open (MODULES, "$REPOSITORY/CVSROOT/modules")) {
69         local $_;
70         while (<MODULES>) {
71             next if /^\s*\#/;
72             next if /^\s*$/;
73             my ($name, @rest) = split;
74             my $path = pop @rest;
75             next unless ($path =~ m%/%);
76             if ($module =~ s%^$path(\Z|/)%%) {
77                 $module = '/' . $module if $module;
78                 $module = "<$name>$module";
79                 last;
80             }
81         }
82         close MODULES;
83     }
84     return $module;
85 }
86
87 ##############################################################################
88 # Multidirectory commit I/O
89 ##############################################################################
90
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 {
96     my $data = shift;
97     my $prefix = $$data{prefix};
98     for (keys %{ $$data{files} }) {
99         while ($prefix && index ($_, $prefix) != 0) {
100             $prefix =~ s%/*([^/]+)$%%;
101             my $last = $1;
102             $$data{repository} =~ s%/*\Q$last\E$%%;
103             $$data{localpath} =~ s%/*\Q$last\E$%%;
104         }
105     }
106     $$data{prefix} = $prefix;
107     $$data{module} = find_module $prefix;
108 }
109
110 # Build the directory in which we'll find our data.
111 sub build_tmpdir {
112     my $tmpdir = $ENV{TMPDIR} || '/tmp';
113     $tmpdir .= '/cvs.' . $< . '.' . getpgrp;
114     return $tmpdir;
115 }
116
117 # Delete all of the accumulated data for multidirectory commits.
118 sub cleanup_data {
119     my $tmpdir = build_tmpdir;
120     unless (opendir (D, $tmpdir)) {
121         warn "$0: can't open $tmpdir: $!\n";
122         return;
123     }
124     for (grep { $_ ne '.' && $_ ne '..' } readdir D) {
125         unlink "$tmpdir/$_";
126     }
127     closedir D;
128     rmdir $tmpdir or warn "$0: can't remove $tmpdir: $!\n";
129 }
130
131 # Read the file containing the last directory noticed by the commitinfo script
132 # and return that directory name.
133 sub read_lastdir {
134     my $tmpdir = build_tmpdir;
135     my $last;
136     if (!-l $tmpdir && -d _ && (lstat _)[4] == $<) {
137         if (open (LAST, $tmpdir . '/directory')) {
138             $last = <LAST>;
139             chomp $last;
140             close LAST;
141         }
142     }
143     return $last;
144 }
145
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.
150 sub read_files {
151     my ($file, $data, $message) = @_;
152     unless (open (FILES, $file)) {
153         warn "$0: can't open $file: $!\n";
154         return;
155     }
156     my (@added, @removed, @modified);
157     local $_;
158     while (<FILES>) {
159         chomp;
160         my ($name, $old, $new) = /^(.*),([^,]+),([^,]+)$/;
161         next unless $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) }
166     }
167     close FILES;
168     $$message{added} = [ @added ];
169     $$message{removed} = [ @removed ];
170     $$message{modified} = [ @modified ];
171     return 1;
172 }
173
174 # Read in message text from a file and put it in the provided hash.
175 sub read_text {
176     my ($file, $message) = @_;
177     my @text;
178     if (open (TEXT, $file)) {
179         @text = <TEXT>;
180         close TEXT;
181     }
182     $$message{text} = [ @text ];
183 }
184
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.
189 sub merge_message {
190     my ($list, $message) = @_;
191     my $done;
192     for (@$list) {
193         if ("@{ $$_{text} }" eq "@{ $$message{text} }") {
194             push (@{ $$_{added} }, @{ $$message{added} });
195             push (@{ $$_{removed} }, @{ $$message{removed} });
196             push (@{ $$_{modified} }, @{ $$message{modified} });
197             $done = 1;
198         }
199     }
200     push (@$list, $message) unless $done;
201 }
202
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.
207 sub read_data {
208     my $data = shift;
209     my $tmpdir = build_tmpdir;
210     $$data{messages} = [];
211     for (my $i = 1; -f "$tmpdir/files.$i"; $i++) {
212         my %message;
213         read_files ("$tmpdir/files.$i", $data, \%message);
214         read_text ("$tmpdir/text.$i", \%message);
215         merge_message ($$data{messages}, \%message);
216     }
217     merge_message ($$data{messages}, $$data{message});
218     recalculate_prefix ($data);
219     cleanup_data;
220 }
221
222 # Save data for the files modified in this invocation to be picked up later.
223 sub save_data {
224     my $data = shift;
225     my $tmpdir = build_tmpdir;
226     if (-l $tmpdir || !-d _ || (lstat _)[4] != $<) {
227         warn "$0: invalid directory $tmpdir\n";
228         return undef;
229     }
230     my $i = 1;
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";
235         return undef;
236     }
237     for (keys %{ $$data{files} }) {
238         my ($old, $new) = @{ $$data{files}{$_} };
239         print FILES join (',', $_, $old, $new), "\n";
240     }
241     print TEXT @{ $$data{message}{text} };
242     unless (close (FILES) && close (TEXT)) {
243         warn "$0: can't save to $tmpdir/files: $!\n";
244         return undef;
245     }
246 }
247
248 ##############################################################################
249 # Parsing functions
250 ##############################################################################
251
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.
258 #
259 # The complexity here is purely the fault of CVS, which doesn't have a good
260 # interface to logging hooks.
261 sub split_files {
262     my ($files) = @_;
263
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}$/) {
270         $files = $rest;
271     } else {
272         $root = '.';
273     }
274
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');
280     }
281
282     # Now, split apart $files, which contains just the files, at the spaces
283     # after version information.
284     my @files;
285     while ($files =~ s/^((?:.*?)(?:,(?:\d+(?:\.\d+)*|NONE)){2})( |\z)//) {
286         push (@files, $1);
287     }
288     push (@files, $files) if $files;
289     return ($root, 'commit', @files);
290 }
291
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.
297 #
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.
300 sub parse_files {
301     my ($data, @args) = @_;
302     my ($directory, $type, @files);
303     if (@args == 1) {
304         ($directory, $type, @files) = split_files ($args[0]);
305         if ($type eq 'commit') {
306             @files = map { [ /^(.*),([^,]+),([^,]+)$/ ] } @files;
307         }
308     } else {
309         $directory = shift @args;
310         if ($args[0] eq '- New directory') {
311             $type = 'directory';
312         } elsif ($args[0] eq '- Imported sources') {
313             $type = 'import';
314         } else {
315             $type = 'commit';
316             while (@args) {
317                 push (@files, [ splice (@args, 0, 3) ]);
318             }
319         }
320     }
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;
333     } elsif (!@files) {
334         die "$0: no files given by CVS (no \%{sVv}?)\n";
335     } else {
336         $$data{type} = 'commit';
337         my $added = $$data{message}{added};
338         my $modified = $$data{message}{modified};
339         my $removed = $$data{message}{removed};
340         for (@files) {
341             my ($name, $prev, $cur) = @$_;
342             warn "$0: no version numbers given by CVS (no \%{sVv}?)\n"
343                 unless defined $cur;
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") }
348         }
349     }
350 }
351
352 # Parse the header of the CVS log message (containing the path information)
353 # and puts the path information into the data hash.
354 sub parse_paths {
355     my $data = shift;
356
357     # The first line of the log message will be "Update of <path>".
358     my $path = <STDIN>;
359     print $path if $DEBUG;
360     $path =~ s/^Update of //;
361     $path =~ s/\s*$//;
362     $$data{repository} = $path;
363
364     # Now comes the path to the local working directory.  Grab it and clean it
365     # up, and then ignore the next blank line.
366     local $_ = <STDIN>;
367     print if $DEBUG;
368     my ($local) = /directory (\S+)/;
369     $$data{localpath} = $local;
370     $_ = <STDIN>;
371     print if $DEBUG;
372 }
373
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).
379 sub parse_filelist {
380     my $data = shift;
381     my ($current, @added, @modified, @removed);
382     local $_;
383     while (<STDIN>) {
384         print if $DEBUG;
385         last if /^Log Message/;
386         $$data{tag} = $1, next if /^\s*Tag: (\S+)\s*$/;
387     }
388 }
389
390 # Extract the commit message, stripping leading and trailing whitespace.
391 sub parse_message {
392     my $data = shift;
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 ];
398 }
399
400 ##############################################################################
401 # Formatting functions
402 ##############################################################################
403
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
407 # passwd file.
408 sub build_from {
409     my $cvsuser = $ENV{CVSUSER} || scalar (getpwuid $<);
410     my $name = '';
411     my $address = '';
412     if ($cvsuser) {
413         if (open (PASSWD, "$REPOSITORY/CVSROOT/passwd")) {
414             local $_;
415             while (<PASSWD>) {
416                 chomp;
417                 next unless /:/;
418                 my @info = split ':';
419                 if ($info[0] eq $cvsuser) {
420                     $name = $info[3];
421                     $address = $info[4];
422                 }
423             }
424             close PASSWD;
425         }
426         $name ||= (getpwnam $cvsuser)[6];
427     }
428     $address ||= $cvsuser || 'cvs';
429     $name =~ s/,.*//;
430     if ($name =~ /[^\w ]/) {
431         $name = '"' . $name . '"';
432     }
433     return "From: " . ($name ? "$name <$address>" : $address) . "\n";
434 }
435
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.
440 sub build_subject {
441     my ($data, $prefix, $long) = @_;
442     $prefix = "Subject: " . $prefix;
443     my $length = 78 - length ($prefix) - length ($$data{module});
444     $length = 8 if $length < 8;
445     my $subject;
446     if ($$data{type} eq 'directory') {
447         $subject = "[new]";
448     } elsif ($$data{type} eq 'import') {
449         $subject = "[import]";
450     } else {
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") . ')';
457         } else {
458             $subject = "($files)";
459         }
460     }
461     if ($$data{module}) {
462         $subject = "$$data{module} $subject";
463     }
464     if ($$data{tag} && $$data{tag} =~ /[^\d.]/) {
465         $subject = "$$data{tag} $subject";
466     }
467     return "$prefix$subject\n";
468 }
469
470 # Generate file lists, wrapped at 74 columns, with the right prefix for what
471 # type of file they are.
472 sub build_filelist {
473     my ($prefix, @files) = @_;
474     local $_ = join (' ', @files);
475     my $output = '';
476     while (length > 64) {
477         if (s/^(.{0,64})\s+// || s/^(\S+)//) {
478             $output .= (' ' x 10) . $1 . "\n";
479         } else {
480             last;
481         }
482     }
483     $output .= (' ' x 10) . $_;
484     $output =~ s/\s*$/\n/;
485     $prefix = (' ' x (8 - length ($prefix))) . $prefix;
486     $output =~ s/^ {10}/$prefix: /;
487     return $output;
488 }
489
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.
492 sub build_header {
493     my ($data, $showdir, $showauthor) = @_;
494     my $user = $ENV{CVSUSER} || (getpwuid $<)[0] || $<;
495     my $date = strftime ('%A, %B %e, %Y @ %T', localtime time);
496     $date =~ s/  / /;
497     my @header = ("    Date: $date\n");
498     push (@header, "  Author: $user\n") if $showauthor;
499
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%^/[^/]+%...%;
507     }
508     while (length ($local) > 69) {
509         $local =~ s%^([\w.-]+:)\.\.\.%$1%;
510         last unless $local =~ s%^([\w.-]+:)/[^/]+%$1...%;
511     }
512
513     if ($showdir) {
514         push (@header, "     Tag: $$data{tag}\n") if $$data{tag};
515         push (@header, "\n", "Update of $path\n",
516               "     from $local\n");
517     } else {
518         push (@header, "    Path: $path\n");
519         push (@header, "     Tag: $$data{tag}\n") if $$data{tag};
520     }
521     return @header;
522 }
523
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
527 # to the file names.
528 sub build_message {
529     my ($data, $message, $versions) = @_;
530     my @added    = sort @{ $$message{added} };
531     my @modified = sort @{ $$message{modified} };
532     my @removed  = sort @{ $$message{removed} };
533     if ($versions) {
534         @added    = map { "$_ ($$data{files}{$_}[1])" } @added;
535         @removed  = map { "$_ ($$data{files}{$_}[0])" } @removed;
536         @modified = map {
537             print "$_\n";
538             "$_ ($$data{files}{$_}[0] -> $$data{files}{$_}[1])"
539         } @modified;
540     }
541     @added    = simplify ($$data{prefix}, \@added);
542     @modified = simplify ($$data{prefix}, \@modified);
543     @removed  = simplify ($$data{prefix}, \@removed);
544     my @message;
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} });
551     }
552     return @message;
553 }
554
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
557 # the file.
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]);
566     } else {
567         @versions = map { ('-r', $_) } @versions;
568     }
569     return @versions;
570 }
571
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.
575 sub build_cvsweb {
576     my ($data, $cvsweb) = @_;
577     my $options = 'f=h';
578     my @cvsweb = ("Diff URLs:\n");
579     my $file;
580     for (sort keys %{ $$data{files} }) {
581         my @versions = @{ $$data{files}{$_} };
582         next unless @versions;
583         my $file = $_;
584         for ($file, @versions) {
585             s{([^a-zA-Z0-9\$_.+!*\'(),/-])} {sprintf "%%%x", ord ($1)}ge;
586         }
587         my $url = "$cvsweb/$file.diff?$options&r1=$versions[0]"
588             . "&r2=$versions[1]\n";
589         push (@cvsweb, $url);
590     }
591     return @cvsweb;
592 }
593
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.
598 sub build_diff {
599     my $data = shift;
600     my @difflines;
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, '-|');
605         if (!defined $pid) {
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";
611         } else {
612             my @diff = <CVS>;
613             close CVS;
614             if ($diff[1] =~ /failed to read diff file header/) {
615                 @diff = ($diff[0], "<<Binary file>>\n");
616             }
617             push (@difflines, @diff);
618         }
619     }
620     return @difflines;
621 }
622
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
626 # arguments.
627 sub build_summary {
628     my ($data, $diffstat) = @_;
629     $diffstat ||= 'diffstat';
630     open2 (\*OUT, \*IN, $diffstat, '-w', '78')
631         or die "$0: can't fork $diffstat: $!\n";
632     my @binary;
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, '-|');
637         if (!defined $pid) {
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";
643         }
644         local $_;
645         while (<CVS>) {
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/) {
650                 my $short = $file;
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);
657                 last;
658             } else {
659                 print IN $_;
660             }
661         }
662         close CVS;
663     }
664     close IN;
665     my @stats = <OUT>;
666     close OUT;
667     my $offset = index ($stats[0], '|');
668     for my $file (@binary) {
669         @stats = map {
670             s/^( +\Q$file\E +\|  +).*/$1<\<Binary file>>/;
671             $_;
672         } @stats;
673     }
674     unshift (@stats, '-' x $offset, "+\n");
675     return @stats;
676 }
677
678 ##############################################################################
679 # Configuration file handling
680 ##############################################################################
681
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.
690 sub load_config {
691     my $file = $REPOSITORY . '/CVSROOT/cvslog.conf';
692     my $address = '';
693     my $cvsweb = '';
694     my $diffstat = 'diffstat';
695     my $headers = '';
696     my $mailhost = '';
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)) {
701         local $_;
702         while (<CONFIG>) {
703             next if /^\s*\#/;
704             next if /^\s*$/;
705             chomp;
706             my ($key, $value) = /^\s*(\S+):\s+(.*)/;
707             unless ($value) {
708                 warn "$0:$file:$.: invalid config syntax: $_\n";
709                 next;
710             }
711             $value =~ s/\s+$//;
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" }
721         }
722         close CONFIG;
723     }
724     return ($address, $cvsweb, $diffstat, $subject, $mailhost, $headers,
725             $sendmail);
726 }
727
728 ##############################################################################
729 # Main routine
730 ##############################################################################
731
732 # Load the configuration file for defaults.
733 my ($address, $cvsweburl, $diffstat, $subject, $mailhost, $headers, $sendmail)
734     = load_config;
735
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,
743             'diff|d'             => \$diff,
744             'help|h'             => \$help,
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;
752 if ($help) {
753     print "Feeding myself to perldoc, please wait....\n";
754     exec ('perldoc', '-t', $0);
755 } elsif ($version) {
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)/;
761     $version =~ tr%/%-%;
762     print $version, "\n";
763     exit;
764 }
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;
770
771 # Parse the input.
772 print "Options: ", join ('|', @ARGV), "\n" if $DEBUG;
773 print '-' x 78, "\n" if $DEBUG;
774 my %data;
775 parse_files (\%data, @ARGV);
776 parse_paths (\%data);
777 parse_filelist (\%data);
778 parse_message (\%data);
779 print '-' x 78, "\n" if $DEBUG;
780
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.
788     } else {
789         read_data (\%data);
790     }
791 }
792 $data{messages} = [ $data{message} ] unless $data{messages};
793
794 # Exit if there are no addresses to send the message to.
795 exit 0 if (!$address && !@addresses);
796
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;
801 $| = 1;
802 select $oldfh;
803
804 # Build the mail headers.
805 if ($mailhost) {
806     for ($address, @addresses) {
807         if ($_ && !/\@/) {
808             $_ .= '@' . $mailhost unless /\@/;
809         }
810     }
811 }
812 if (@addresses) {
813     print MAIL "To: ", join (', ', @addresses), "\n";
814     print MAIL "Cc: $address\n" if $address;
815 } else {
816     print MAIL "To: $address\n";
817 }
818 print MAIL build_from;
819 print MAIL $headers if $headers;
820 print MAIL build_subject (\%data, $subject, $longsubject), "\n";
821
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);
826 }
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;
831 }
832
833 # Make sure sending mail succeeded.
834 close MAIL;
835 unless ($? == 0) { die "$0: sendmail exit status " . ($? >> 8) . "\n" }
836 exit 0;
837 __END__
838
839 ##############################################################################
840 # Documentation
841 ##############################################################################
842
843 =head1 NAME
844
845 cvslog - Mail CVS commit notifications
846
847 =head1 SYNOPSIS
848
849 B<cvslog> [B<-cDdhilmosvw>] [B<-a> I<address> ...] %{sVv}
850
851 =head1 REQUIREMENTS
852
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.
855
856 =head1 DESCRIPTION
857
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).
865
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.
871
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
875 L<"CONFIGURATION">.
876
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
884 address.
885
886 =head1 OPTIONS
887
888 =over 4
889
890 =item B<-a> I<address>, B<--address>=I<address>
891
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.
895
896 =item B<-c>, B<--cvsweb>
897
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.
903
904 =item B<-D>, B<--debug>
905
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>,
909 separated by C<|>.
910
911 =item B<-d>, B<--diff>
912
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.
918
919 When this option is given, B<cvslog> needs to be able to find B<cvs> in the
920 user's PATH.
921
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
924 binary.
925
926 =item B<-h>, B<--help>
927
928 Print out this documentation (which is done simply by feeding the script to
929 C<perldoc -t>).
930
931 =item B<-i>, B<--include-versions>
932
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
935 given.
936
937 =item B<-l>, B<--long-subject>
938
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.
943
944 =item B<-m>, B<--merge>
945
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">.
950
951 =item B<-o>, B<--omit-author>
952
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.
956
957 =item B<-s>, B<--summary>
958
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">.
963
964 When this option is given, B<cvslog> needs to be able to find B<cvs> in the
965 user's PATH.
966
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
970 the file is binary.
971
972 =item B<-v>, B<--version>
973
974 Print out the version of B<cvslog> and exit.
975
976 =item B<-w>, B<--show-directory>
977
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
981 information.
982
983 =back
984
985 =head1 CONFIGURATION
986
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:
991
992     parameter: value
993
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.
998
999 The following configuration parameters are supported:
1000
1001 =over 4
1002
1003 =item address
1004
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.
1009
1010 =item cvsweb
1011
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.
1015
1016 =item diffstat
1017
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>
1020 option is given.
1021
1022 =item header
1023
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.
1027
1028 =item mailhost
1029
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.
1034
1035 =item sendmail
1036
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>.
1040
1041 =item subject
1042
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 ">.
1047
1048 =back
1049
1050 =head1 INSTALLATION
1051
1052 Follow these steps to add cvslog to your project:
1053
1054 =over 4
1055
1056 =item 1.
1057
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.
1061
1062 =item 2.
1063
1064 Add a line like:
1065
1066     cvslog Unable to check out CVS log notification script cvslog
1067
1068 to F<checkoutlist> in CVSROOT and commit it.
1069
1070 =item 3.
1071
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
1077 F<loginfo>.
1078
1079 =item 4.
1080
1081 If you created a F<cvslog.conf> file, add a line like:
1082
1083     cvslog.conf Unable to check out cvslog configuration cvslog.conf
1084
1085 to F<checkoutlist> in CVSROOT and commit it.
1086
1087 =item 5.
1088
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:
1091
1092     DEFAULT     $CVSROOT/CVSROOT/cvslog %{sVv}
1093
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:
1100
1101     ^foo/       $CVSROOT/CVSROOT/cvslog -a foo-commit %{sVv}
1102     ^bar/       $CVSROOT/CVSROOT/cvslog -a bar-commit %{sVv}
1103
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
1109 be present.
1110
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:
1114
1115     DEFAULT     $CVSROOT/CVSROOT/cvslog -- %p %{sVv}
1116
1117 Any options to B<cvslog> should go before C<-->.  See the CVS documentation
1118 for more details on the new F<loginfo> format.
1119
1120 =item 6.
1121
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
1124 F<cvslog.conf>.
1125
1126 diffstat is at L<http://dickey.his.com/diffstat/diffstat.html>.
1127
1128 =item 7.
1129
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:
1134
1135     cvsprep Unable to check out CVS log notification script cvsprep
1136
1137 to F<checkoutlist> in CVSROOT and commit it.
1138
1139 See L<"WARNINGS"> for some warnings about the security of multi-directory
1140 commit merging.
1141
1142 =item 8.
1143
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.
1149
1150 =back
1151
1152 =head1 EXAMPLES
1153
1154 Send all commits under the baz directory to B<address> defined in
1155 F<cvslog.conf>:
1156
1157     ^baz/       $CVSROOT/CVSROOT/cvslog -msw %{sVv}
1158
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>.
1163
1164 See L<"INSTALLATION"> for more examples.
1165
1166 =head1 DIAGNOSTICS
1167
1168 =over 4
1169
1170 =item can't fork %s: %s
1171
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
1175 it for some reason.
1176
1177 =item can't open %s: %s
1178
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.)
1185
1186 =item can't remove %s: %s
1187
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
1190 TMPDIR or F</tmp>.
1191
1192 =item can't save to %s: %s
1193
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.
1197
1198 =item invalid directory %s
1199
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.
1205
1206 =item invalid config syntax: %s
1207
1208 (Warning) The given line in F<cvslog.conf> was syntactically invalid.  See
1209 L<"CONFIGURATION"> for the correct syntax.
1210
1211 =item no %s given by CVS (no %{sVv}?)
1212
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
1219 than 1.10.
1220
1221 =item no addresses specified
1222
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.
1226
1227 =item sendmail exit status %d
1228
1229 (Fatal) sendmail exited with a non-zero status.  This may mean that the
1230 notification message wasn't sent.
1231
1232 =item unable to determine the repository path
1233
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
1236 this.
1237
1238 =item unrecognized config line: %s
1239
1240 (Warning) The given configuration parameter isn't one of the ones that
1241 B<cvslog> knows about.
1242
1243 =back
1244
1245 =head1 FILES
1246
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
1250 to set $REPOSITORY.
1251
1252 =over 4
1253
1254 =item $CVSROOT/CVSROOT/cvslog.conf
1255
1256 Read for configuration directives if it exists.  See L<"CONFIGURATION">.
1257
1258 =item $CVSROOT/CVSROOT/modules
1259
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.
1269
1270 =item $CVSROOT/CVSROOT/passwd
1271
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.
1276
1277 =item TMPDIR/cvs.%d.%d
1278
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.
1284
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.
1290
1291 If TMPDIR isn't set in the environment, F</tmp> is used for TMPDIR.
1292
1293 =item TMPDIR/cvs.%d.%d/directory
1294
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.
1300
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.
1304
1305 =back
1306
1307 =head1 ENVIRONMENT
1308
1309 =over 4
1310
1311 =item PATH
1312
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.
1317
1318 =item TMPDIR
1319
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
1325 use TMPDIR.)
1326
1327 =back
1328
1329 =head1 WARNINGS
1330
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
1339 aren't trusted.
1340
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.
1347
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.
1355
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.
1359
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.
1365
1366 =head1 BUGS
1367
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
1373 customizable.
1374
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
1378 either.
1379
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.
1383
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.
1387
1388 =head1 NOTES
1389
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.
1394
1395 =head1 SEE ALSO
1396
1397 cvs(1), diffstat(1), cvsprep(1).
1398
1399 diffstat is at L<http://dickey.his.com/diffstat/diffstat.html>.
1400
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.
1404
1405 =head1 AUTHOR
1406
1407 Russ Allbery <rra@stanford.edu>.
1408
1409 =head1 COPYRIGHT AND LICENSE
1410
1411 Copyright 1998, 1999, 2000, 2001, 2002, 2003, 2004 Board of Trustees, Leland
1412 Stanford Jr. University.
1413
1414 This program is free software; you can redistribute it and/or modify it
1415 under the same terms as Perl itself.
1416
1417 =cut