3 # ====================================================================
4 # commit-email.pl: send a commit email for commit REVISION in
5 # repository REPOS to some email addresses.
7 # For usage, see the usage subroutine or run the script with no
8 # command line arguments.
10 # $HeadURL: http://svn.collab.net/repos/svn/branches/1.2.x/tools/hook-scripts/commit-email.pl.in $
11 # $LastChangedDate: 2005-02-22 04:24:08 -0500 (Tue, 22 Feb 2005) $
12 # $LastChangedBy: maxb $
13 # $LastChangedRevision: 13107 $
15 # ====================================================================
16 # Copyright (c) 2000-2004 CollabNet. All rights reserved.
18 # This software is licensed as described in the file COPYING, which
19 # you should have received as part of this distribution. The terms
20 # are also available at http://subversion.tigris.org/license-1.html.
21 # If newer versions of this license are posted there, you may use a
22 # newer version instead, at your option.
24 # This software consists of voluntary contributions made by many
25 # individuals. For exact contribution history, see the revision
26 # history and logs, available at http://subversion.tigris.org/.
27 # ====================================================================
29 # Turn on warnings the best way depending on the Perl version.
32 { require warnings; import warnings; }
40 ######################################################################
41 # Configuration section.
44 my $sendmail = "/usr/sbin/sendmail";
47 my $svnlook = "/usr/bin/svnlook";
49 # By default, when a file is deleted from the repository, svnlook diff
50 # prints the entire contents of the file. If you want to save space
51 # in the log and email messages by not printing the file, then set
52 # $no_diff_deleted to 1.
53 my $no_diff_deleted = 0;
54 # By default, when a file is added to the repository, svnlook diff
55 # prints the entire contents of the file. If you want to save space
56 # in the log and email messages by not printing the file, then set
57 # $no_diff_added to 1.
58 my $no_diff_added = 0;
60 # End of Configuration section.
61 ######################################################################
63 # Since the path to svnlook depends upon the local installation
64 # preferences, check that the required programs exist to insure that
65 # the administrator has set up the script properly.
68 foreach my $program ($sendmail, $svnlook)
74 warn "$0: required program `$program' is not executable, ",
81 warn "$0: required program `$program' does not exist, edit $0.\n";
89 ######################################################################
90 # Initial setup/command-line handling.
92 # Each value in this array holds a hash reference which contains the
93 # associated email information for one project. Start with an
94 # implicit rule that matches all paths.
95 my @project_settings_list = (&new_project);
97 # Process the command line arguments till there are none left. The
98 # first two arguments that are not used by a command line option are
99 # the repository path and the revision number.
103 # Use the reference to the first project to populate.
104 my $current_project = $project_settings_list[0];
106 # This hash matches the command line option to the hash key in the
107 # project. If a key exists but has a false value (''), then the
108 # command line option is allowed but requires special handling.
109 my %opt_to_hash_key = ('--from' => 'from_address',
114 '-s' => 'subject_prefix');
118 my $arg = shift @ARGV;
121 my $hash_key = $opt_to_hash_key{$arg};
122 unless (defined $hash_key)
124 die "$0: command line option `$arg' is not recognized.\n";
129 die "$0: command line option `$arg' is missing a value.\n";
131 my $value = shift @ARGV;
135 $current_project->{$hash_key} = $value;
140 unless ($arg eq '-m')
142 die "$0: internal error: should only handle -m here.\n";
144 $current_project = &new_project;
145 $current_project->{match_regex} = $value;
146 push(@project_settings_list, $current_project);
151 die "$0: command line option `$arg' is not recognized.\n";
155 if (! defined $repos)
159 elsif (! defined $rev)
165 push(@{$current_project->{email_addresses}}, $arg);
170 # If the revision number is undefined, then there were not enough
171 # command line arguments.
172 &usage("$0: too few arguments.") unless defined $rev;
174 # Check the validity of the command line arguments. Check that the
175 # revision is an integer greater than 0 and that the repository
177 unless ($rev =~ /^\d+/ and $rev > 0)
179 &usage("$0: revision number `$rev' must be an integer > 0.");
183 &usage("$0: repos directory `$repos' does not exist.");
187 &usage("$0: repos directory `$repos' is not a directory.");
190 # Check that all of the regular expressions can be compiled and
194 for (my $i=0; $i<@project_settings_list; ++$i)
196 my $match_regex = $project_settings_list[$i]->{match_regex};
198 # To help users that automatically write regular expressions
199 # that match the root directory using ^/, remove the / character
200 # because subversion paths, while they start at the root level,
201 # do not begin with a /.
202 $match_regex =~ s#^\^/#^#;
205 eval { $match_re = qr/$match_regex/ };
208 warn "$0: -m regex #$i `$match_regex' does not compile:\n$@\n";
212 $project_settings_list[$i]->{match_re} = $match_re;
217 ######################################################################
218 # Harvest data using svnlook.
220 # Change into suitable directory so that svnlook diff can create its .svnlook
221 # directory. This could be removed - it's only for compatibility with
222 # 1.0.x svnlook - from 1.1.0, svnlook will be sensible about choosing a
223 # temporary directory all by itself.
224 my $tmp_dir = ( -d $ENV{'TEMP'} ? $ENV{'TEMP'} : '/tmp' );
226 or die "$0: cannot chdir `$tmp_dir': $!\n";
228 # Get the author, date, and log from svnlook.
229 my @svnlooklines = &read_from_process($svnlook, 'info', $repos, '-r', $rev);
230 my $author = shift @svnlooklines;
231 my $date = shift @svnlooklines;
233 my @log = map { "$_\n" } @svnlooklines;
235 # Figure out what directories have changed using svnlook.
236 my @dirschanged = &read_from_process($svnlook, 'dirs-changed', $repos,
239 # Lose the trailing slash in the directory names if one exists, except
240 # in the case of '/'.
242 for (my $i=0; $i<@dirschanged; ++$i)
244 if ($dirschanged[$i] eq '/')
250 $dirschanged[$i] =~ s#^(.+)[/\\]$#$1#;
254 # Figure out what files have changed using svnlook.
255 @svnlooklines = &read_from_process($svnlook, 'changed', $repos, '-r', $rev);
257 # Parse the changed nodes.
261 foreach my $line (@svnlooklines)
266 # Split the line up into the modification code and path, ignoring
267 # property modifications.
268 if ($line =~ /^(.). (.*)$/)
288 # Get the diff from svnlook.
289 my @no_diff_deleted = $no_diff_deleted ? ('--no-diff-deleted') : ();
290 my @no_diff_added = $no_diff_added ? ('--no-diff-added') : ();
291 my @difflines = &read_from_process($svnlook, 'diff', $repos,
292 '-r', $rev, @no_diff_deleted,
295 ######################################################################
296 # Modified directory name collapsing.
298 # Collapse the list of changed directories only if the root directory
299 # was not modified, because otherwise everything is under root and
300 # there's no point in collapsing the directories, and only if more
301 # than one directory was modified.
303 my @dirschanged_orig = @dirschanged;
304 if (!$rootchanged and @dirschanged > 1)
306 my $firstline = shift @dirschanged;
307 my @commonpieces = split('/', $firstline);
308 foreach my $line (@dirschanged)
310 my @pieces = split('/', $line);
312 while ($i < @pieces and $i < @commonpieces)
314 if ($pieces[$i] ne $commonpieces[$i])
316 splice(@commonpieces, $i, @commonpieces - $i);
322 unshift(@dirschanged, $firstline);
326 $commondir = join('/', @commonpieces);
328 foreach my $dir (@dirschanged)
330 if ($dir eq $commondir)
336 $dir =~ s#^\Q$commondir/\E##;
338 push(@new_dirschanged, $dir);
340 @dirschanged = @new_dirschanged;
343 my $dirlist = join(' ', @dirschanged);
345 ######################################################################
346 # Assembly of log message.
348 # Put together the body of the log message.
350 push(@body, "Author: $author\n");
351 push(@body, "Date: $date\n");
352 push(@body, "New Revision: $rev\n");
357 push(@body, "Added:\n");
358 push(@body, map { " $_\n" } @adds);
363 push(@body, "Removed:\n");
364 push(@body, map { " $_\n" } @dels);
369 push(@body, "Modified:\n");
370 push(@body, map { " $_\n" } @mods);
372 push(@body, "Log:\n");
375 push(@body, map { /[\r\n]+$/ ? $_ : "$_\n" } @difflines);
377 # Go through each project and see if there are any matches for this
378 # project. If so, send the log out.
379 foreach my $project (@project_settings_list)
381 my $match_re = $project->{match_re};
383 foreach my $path (@dirschanged_orig, @adds, @dels, @mods)
385 if ($path =~ $match_re)
394 my @email_addresses = @{$project->{email_addresses}};
395 my $userlist = join(' ', @email_addresses);
396 my $to = join(', ', @email_addresses);
397 my $from_address = $project->{from_address};
398 my $hostname = $project->{hostname};
399 my $log_file = $project->{log_file};
400 my $reply_to = $project->{reply_to};
401 my $subject_prefix = $project->{subject_prefix};
404 if ($commondir ne '')
406 $subject = "r$rev - in $commondir: $dirlist";
410 $subject = "r$rev - $dirlist";
412 if ($subject_prefix =~ /\w/)
414 $subject = "$subject_prefix $subject";
416 my $mail_from = $author;
418 if ($from_address =~ /\w/)
420 $mail_from = $from_address;
422 elsif ($hostname =~ /\w/)
424 $mail_from = "$mail_from\@$hostname";
428 push(@head, "To: $to\n");
429 push(@head, "From: $mail_from\n");
430 push(@head, "Subject: $subject\n");
431 push(@head, "Reply-to: $reply_to\n") if $reply_to;
433 ### Below, we set the content-type etc, but see these comments
434 ### from Greg Stein on why this is not a full solution.
436 # From: Greg Stein <gstein@lyra.org>
437 # Subject: Re: svn commit: rev 2599 - trunk/tools/cgi
438 # To: dev@subversion.tigris.org
439 # Date: Fri, 19 Jul 2002 23:42:32 -0700
441 # Well... that isn't strictly true. The contents of the files
442 # might not be UTF-8, so the "diff" portion will be hosed.
444 # If you want a truly "proper" commit message, then you'd use
445 # multipart MIME messages, with each file going into its own part,
446 # and labeled with an appropriate MIME type and charset. Of
447 # course, we haven't defined a charset property yet, but no biggy.
449 # Going with multipart will surely throw out the notion of "cut
450 # out the patch from the email and apply." But then again: the
451 # commit emailer could see that all portions are in the same
452 # charset and skip the multipart thang.
456 # Basically: adding/tweaking the content-type is nice, but don't
457 # think that is the proper solution.
458 push(@head, "Content-Type: text/plain; charset=UTF-8\n");
459 push(@head, "Content-Transfer-Encoding: 8bit\n");
463 if ($sendmail =~ /\w/ and @email_addresses)
465 # Open a pipe to sendmail.
466 my $command = "$sendmail -f$mail_from $userlist";
467 if (open(SENDMAIL, "| $command"))
469 print SENDMAIL @head, @body;
471 or warn "$0: error in closing `$command' for writing: $!\n";
475 warn "$0: cannot open `| $command' for writing: $!\n";
479 # Dump the output to logfile (if its name is not empty).
480 if ($log_file =~ /\w/)
482 if (open(LOGFILE, ">> $log_file"))
484 print LOGFILE @head, @body;
486 or warn "$0: error in closing `$log_file' for appending: $!\n";
490 warn "$0: cannot open `$log_file' for appending: $!\n";
500 die "usage: $0 REPOS REVNUM [[-m regex] [options] [email_addr ...]] ...\n",
502 " --from email_address Email address for 'From:' (overrides -h)\n",
503 " -h hostname Hostname to append to author for 'From:'\n",
504 " -l logfile Append mail contents to this log file\n",
505 " -m regex Regular expression to match committed path\n",
506 " -r email_address Email address for 'Reply-To:'\n",
507 " -s subject_prefix Subject line prefix\n",
509 "This script supports a single repository with multiple projects,\n",
510 "where each project receives email only for commits that modify that\n",
511 "project. A project is identified by using the -m command line\n",
512 "with a regular expression argument. If a commit has a path that\n",
513 "matches the regular expression, then the entire commit matches.\n",
514 "Any of the following -h, -l, -r and -s command line options and\n",
515 "following email addresses are associated with this project. The\n",
516 "next -m resets the -h, -l, -r and -s command line options and the\n",
517 "list of email addresses.\n",
519 "To support a single project conveniently, the script initializes\n",
520 "itself with an implicit -m . rule that matches any modifications\n",
521 "to the repository. Therefore, to use the script for a single\n",
522 "project repository, just use the other comand line options and\n",
523 "a list of email addresses on the command line. If you do not want\n",
524 "a project that matches the entire repository, then use a -m with a\n",
525 "regular expression before any other command line options or email\n",
529 # Return a new hash data structure for a new empty project that
530 # matches any modifications to the repository.
533 return {email_addresses => [],
539 subject_prefix => ''};
542 # Start a child process safely without using /bin/sh.
543 sub safe_read_from_pipe
547 croak "$0: safe_read_from_pipe passed no arguments.\n";
550 my $pid = open(SAFE_READ, '-|');
551 unless (defined $pid)
553 die "$0: cannot fork: $!\n";
557 open(STDERR, ">&STDOUT")
558 or die "$0: cannot dup STDOUT: $!\n";
560 or die "$0: cannot exec `@_': $!\n";
570 my $exit = $result >> 8;
571 my $signal = $result & 127;
572 my $cd = $result & 128 ? "with core dump" : "";
575 warn "$0: pipe from `@_' failed $cd: exit=$exit signal=$signal\n";
579 return ($result, @output);
587 # Use safe_read_from_pipe to start a child process safely and return
588 # the output if it succeeded or an error message followed by the output
590 sub read_from_process
594 croak "$0: read_from_process passed no arguments.\n";
596 my ($status, @output) = &safe_read_from_pipe(@_);
599 return ("$0: `@_' failed with this output:", @output);