1#! /usr/bin/env python2 2 3# Copyright (c) 2012-2014 Michael Haggerty and others 4# Derived from contrib/hooks/post-receive-email, which is 5# Copyright (c) 2007 Andy Parkins 6# and also includes contributions by other authors. 7# 8# This file is part of git-multimail. 9# 10# git-multimail is free software: you can redistribute it and/or 11# modify it under the terms of the GNU General Public License version 12# 2 as published by the Free Software Foundation. 13# 14# This program is distributed in the hope that it will be useful, but 15# WITHOUT ANY WARRANTY; without even the implied warranty of 16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 17# General Public License for more details. 18# 19# You should have received a copy of the GNU General Public License 20# along with this program. If not, see 21# <http://www.gnu.org/licenses/>. 22 23"""Generate notification emails for pushes to a git repository. 24 25This hook sends emails describing changes introduced by pushes to a 26git repository. For each reference that was changed, it emits one 27ReferenceChange email summarizing how the reference was changed, 28followed by one Revision email for each new commit that was introduced 29by the reference change. 30 31Each commit is announced in exactly one Revision email. If the same 32commit is merged into another branch in the same or a later push, then 33the ReferenceChange email will list the commit's SHA1 and its one-line 34summary, but no new Revision email will be generated. 35 36This script is designed to be used as a "post-receive" hook in a git 37repository (see githooks(5)). It can also be used as an "update" 38script, but this usage is not completely reliable and is deprecated. 39 40To help with debugging, this script accepts a --stdout option, which 41causes the emails to be written to standard output rather than sent 42using sendmail. 43 44See the accompanying README file for the complete documentation. 45 46""" 47 48import sys 49import os 50import re 51import bisect 52import socket 53import subprocess 54import shlex 55import optparse 56import smtplib 57import time 58 59try: 60from email.utils import make_msgid 61from email.utils import getaddresses 62from email.utils import formataddr 63from email.utils import formatdate 64from email.header import Header 65exceptImportError: 66# Prior to Python 2.5, the email module used different names: 67from email.Utils import make_msgid 68from email.Utils import getaddresses 69from email.Utils import formataddr 70from email.Utils import formatdate 71from email.Header import Header 72 73 74DEBUG =False 75 76ZEROS ='0'*40 77LOGBEGIN ='- Log -----------------------------------------------------------------\n' 78LOGEND ='-----------------------------------------------------------------------\n' 79 80ADDR_HEADERS =set(['from','to','cc','bcc','reply-to','sender']) 81 82# It is assumed in many places that the encoding is uniformly UTF-8, 83# so changing these constants is unsupported. But define them here 84# anyway, to make it easier to find (at least most of) the places 85# where the encoding is important. 86(ENCODING, CHARSET) = ('UTF-8','utf-8') 87 88 89REF_CREATED_SUBJECT_TEMPLATE = ( 90'%(emailprefix)s%(refname_type)s %(short_refname)screated' 91' (now%(newrev_short)s)' 92) 93REF_UPDATED_SUBJECT_TEMPLATE = ( 94'%(emailprefix)s%(refname_type)s %(short_refname)supdated' 95' (%(oldrev_short)s->%(newrev_short)s)' 96) 97REF_DELETED_SUBJECT_TEMPLATE = ( 98'%(emailprefix)s%(refname_type)s %(short_refname)sdeleted' 99' (was%(oldrev_short)s)' 100) 101 102REFCHANGE_HEADER_TEMPLATE ="""\ 103Date:%(send_date)s 104To:%(recipients)s 105Subject:%(subject)s 106MIME-Version: 1.0 107Content-Type: text/plain; charset=%(charset)s 108Content-Transfer-Encoding: 8bit 109Message-ID:%(msgid)s 110From:%(fromaddr)s 111Reply-To:%(reply_to)s 112X-Git-Host:%(fqdn)s 113X-Git-Repo:%(repo_shortname)s 114X-Git-Refname:%(refname)s 115X-Git-Reftype:%(refname_type)s 116X-Git-Oldrev:%(oldrev)s 117X-Git-Newrev:%(newrev)s 118Auto-Submitted: auto-generated 119""" 120 121REFCHANGE_INTRO_TEMPLATE ="""\ 122This is an automated email from the git hooks/post-receive script. 123 124%(pusher)spushed a change to%(refname_type)s %(short_refname)s 125in repository%(repo_shortname)s. 126 127""" 128 129 130FOOTER_TEMPLATE ="""\ 131 132--\n\ 133To stop receiving notification emails like this one, please contact 134%(administrator)s. 135""" 136 137 138REWIND_ONLY_TEMPLATE ="""\ 139This update removed existing revisions from the reference, leaving the 140reference pointing at a previous point in the repository history. 141 142 * -- * -- N%(refname)s(%(newrev_short)s) 143\\ 144 O -- O -- O (%(oldrev_short)s) 145 146Any revisions marked "omits" are not gone; other references still 147refer to them. Any revisions marked "discards" are gone forever. 148""" 149 150 151NON_FF_TEMPLATE ="""\ 152This update added new revisions after undoing existing revisions. 153That is to say, some revisions that were in the old version of the 154%(refname_type)sare not in the new version. This situation occurs 155when a user --force pushes a change and generates a repository 156containing something like this: 157 158 * -- * -- B -- O -- O -- O (%(oldrev_short)s) 159\\ 160 N -- N -- N%(refname)s(%(newrev_short)s) 161 162You should already have received notification emails for all of the O 163revisions, and so the following emails describe only the N revisions 164from the common base, B. 165 166Any revisions marked "omits" are not gone; other references still 167refer to them. Any revisions marked "discards" are gone forever. 168""" 169 170 171NO_NEW_REVISIONS_TEMPLATE ="""\ 172No new revisions were added by this update. 173""" 174 175 176DISCARDED_REVISIONS_TEMPLATE ="""\ 177This change permanently discards the following revisions: 178""" 179 180 181NO_DISCARDED_REVISIONS_TEMPLATE ="""\ 182The revisions that were on this%(refname_type)sare still contained in 183other references; therefore, this change does not discard any commits 184from the repository. 185""" 186 187 188NEW_REVISIONS_TEMPLATE ="""\ 189The%(tot)srevisions listed above as "new" are entirely new to this 190repository and will be described in separate emails. The revisions 191listed as "adds" were already present in the repository and have only 192been added to this reference. 193 194""" 195 196 197TAG_CREATED_TEMPLATE ="""\ 198 at%(newrev_short)-9s (%(newrev_type)s) 199""" 200 201 202TAG_UPDATED_TEMPLATE ="""\ 203*** WARNING: tag%(short_refname)swas modified! *** 204 205 from%(oldrev_short)-9s (%(oldrev_type)s) 206 to%(newrev_short)-9s (%(newrev_type)s) 207""" 208 209 210TAG_DELETED_TEMPLATE ="""\ 211*** WARNING: tag%(short_refname)swas deleted! *** 212 213""" 214 215 216# The template used in summary tables. It looks best if this uses the 217# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. 218BRIEF_SUMMARY_TEMPLATE ="""\ 219%(action)10s%(rev_short)-9s%(text)s 220""" 221 222 223NON_COMMIT_UPDATE_TEMPLATE ="""\ 224This is an unusual reference change because the reference did not 225refer to a commit either before or after the change. We do not know 226how to provide full information about this reference change. 227""" 228 229 230REVISION_HEADER_TEMPLATE ="""\ 231Date:%(send_date)s 232To:%(recipients)s 233Subject:%(emailprefix)s%(num)02d/%(tot)02d:%(oneline)s 234MIME-Version: 1.0 235Content-Type: text/plain; charset=%(charset)s 236Content-Transfer-Encoding: 8bit 237From:%(fromaddr)s 238Reply-To:%(reply_to)s 239In-Reply-To:%(reply_to_msgid)s 240References:%(reply_to_msgid)s 241X-Git-Host:%(fqdn)s 242X-Git-Repo:%(repo_shortname)s 243X-Git-Refname:%(refname)s 244X-Git-Reftype:%(refname_type)s 245X-Git-Rev:%(rev)s 246Auto-Submitted: auto-generated 247""" 248 249REVISION_INTRO_TEMPLATE ="""\ 250This is an automated email from the git hooks/post-receive script. 251 252%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 253in repository%(repo_shortname)s. 254 255""" 256 257 258REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 259 260 261classCommandError(Exception): 262def__init__(self, cmd, retcode): 263 self.cmd = cmd 264 self.retcode = retcode 265Exception.__init__( 266 self, 267'Command "%s" failed with retcode%s'% (' '.join(cmd), retcode,) 268) 269 270 271classConfigurationException(Exception): 272pass 273 274 275# The "git" program (this could be changed to include a full path): 276GIT_EXECUTABLE ='git' 277 278 279# How "git" should be invoked (including global arguments), as a list 280# of words. This variable is usually initialized automatically by 281# read_git_output() via choose_git_command(), but if a value is set 282# here then it will be used unconditionally. 283GIT_CMD =None 284 285 286defchoose_git_command(): 287"""Decide how to invoke git, and record the choice in GIT_CMD.""" 288 289global GIT_CMD 290 291if GIT_CMD is None: 292try: 293# Check to see whether the "-c" option is accepted (it was 294# only added in Git 1.7.2). We don't actually use the 295# output of "git --version", though if we needed more 296# specific version information this would be the place to 297# do it. 298 cmd = [GIT_EXECUTABLE,'-c','foo.bar=baz','--version'] 299read_output(cmd) 300 GIT_CMD = [GIT_EXECUTABLE,'-c','i18n.logoutputencoding=%s'% (ENCODING,)] 301except CommandError: 302 GIT_CMD = [GIT_EXECUTABLE] 303 304 305defread_git_output(args,input=None, keepends=False, **kw): 306"""Read the output of a Git command.""" 307 308if GIT_CMD is None: 309choose_git_command() 310 311returnread_output(GIT_CMD + args,input=input, keepends=keepends, **kw) 312 313 314defread_output(cmd,input=None, keepends=False, **kw): 315ifinput: 316 stdin = subprocess.PIPE 317else: 318 stdin =None 319 p = subprocess.Popen( 320 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 321) 322(out, err) = p.communicate(input) 323 retcode = p.wait() 324if retcode: 325raiseCommandError(cmd, retcode) 326if not keepends: 327 out = out.rstrip('\n\r') 328return out 329 330 331defread_git_lines(args, keepends=False, **kw): 332"""Return the lines output by Git command. 333 334 Return as single lines, with newlines stripped off.""" 335 336returnread_git_output(args, keepends=True, **kw).splitlines(keepends) 337 338 339defheader_encode(text, header_name=None): 340"""Encode and line-wrap the value of an email header field.""" 341 342try: 343ifisinstance(text,str): 344 text = text.decode(ENCODING,'replace') 345returnHeader(text, header_name=header_name).encode() 346exceptUnicodeEncodeError: 347returnHeader(text, header_name=header_name, charset=CHARSET, 348 errors='replace').encode() 349 350 351defaddr_header_encode(text, header_name=None): 352"""Encode and line-wrap the value of an email header field containing 353 email addresses.""" 354 355returnHeader( 356', '.join( 357formataddr((header_encode(name), emailaddr)) 358for name, emailaddr ingetaddresses([text]) 359), 360 header_name=header_name 361).encode() 362 363 364classConfig(object): 365def__init__(self, section, git_config=None): 366"""Represent a section of the git configuration. 367 368 If git_config is specified, it is passed to "git config" in 369 the GIT_CONFIG environment variable, meaning that "git config" 370 will read the specified path rather than the Git default 371 config paths.""" 372 373 self.section = section 374if git_config: 375 self.env = os.environ.copy() 376 self.env['GIT_CONFIG'] = git_config 377else: 378 self.env =None 379 380@staticmethod 381def_split(s): 382"""Split NUL-terminated values.""" 383 384 words = s.split('\0') 385assert words[-1] =='' 386return words[:-1] 387 388defget(self, name, default=None): 389try: 390 values = self._split(read_git_output( 391['config','--get','--null','%s.%s'% (self.section, name)], 392 env=self.env, keepends=True, 393)) 394assertlen(values) ==1 395return values[0] 396except CommandError: 397return default 398 399defget_bool(self, name, default=None): 400try: 401 value =read_git_output( 402['config','--get','--bool','%s.%s'% (self.section, name)], 403 env=self.env, 404) 405except CommandError: 406return default 407return value =='true' 408 409defget_all(self, name, default=None): 410"""Read a (possibly multivalued) setting from the configuration. 411 412 Return the result as a list of values, or default if the name 413 is unset.""" 414 415try: 416return self._split(read_git_output( 417['config','--get-all','--null','%s.%s'% (self.section, name)], 418 env=self.env, keepends=True, 419)) 420except CommandError, e: 421if e.retcode ==1: 422# "the section or key is invalid"; i.e., there is no 423# value for the specified key. 424return default 425else: 426raise 427 428defget_recipients(self, name, default=None): 429"""Read a recipients list from the configuration. 430 431 Return the result as a comma-separated list of email 432 addresses, or default if the option is unset. If the setting 433 has multiple values, concatenate them with comma separators.""" 434 435 lines = self.get_all(name, default=None) 436if lines is None: 437return default 438return', '.join(line.strip()for line in lines) 439 440defset(self, name, value): 441read_git_output( 442['config','%s.%s'% (self.section, name), value], 443 env=self.env, 444) 445 446defadd(self, name, value): 447read_git_output( 448['config','--add','%s.%s'% (self.section, name), value], 449 env=self.env, 450) 451 452defhas_key(self, name): 453return self.get_all(name, default=None)is not None 454 455defunset_all(self, name): 456try: 457read_git_output( 458['config','--unset-all','%s.%s'% (self.section, name)], 459 env=self.env, 460) 461except CommandError, e: 462if e.retcode ==5: 463# The name doesn't exist, which is what we wanted anyway... 464pass 465else: 466raise 467 468defset_recipients(self, name, value): 469 self.unset_all(name) 470for pair ingetaddresses([value]): 471 self.add(name,formataddr(pair)) 472 473 474defgenerate_summaries(*log_args): 475"""Generate a brief summary for each revision requested. 476 477 log_args are strings that will be passed directly to "git log" as 478 revision selectors. Iterate over (sha1_short, subject) for each 479 commit specified by log_args (subject is the first line of the 480 commit message as a string without EOLs).""" 481 482 cmd = [ 483'log','--abbrev','--format=%h%s', 484] +list(log_args) + ['--'] 485for line inread_git_lines(cmd): 486yieldtuple(line.split(' ',1)) 487 488 489deflimit_lines(lines, max_lines): 490for(index, line)inenumerate(lines): 491if index < max_lines: 492yield line 493 494if index >= max_lines: 495yield'...%dlines suppressed ...\n'% (index +1- max_lines,) 496 497 498deflimit_linelength(lines, max_linelength): 499for line in lines: 500# Don't forget that lines always include a trailing newline. 501iflen(line) > max_linelength +1: 502 line = line[:max_linelength -7] +' [...]\n' 503yield line 504 505 506classCommitSet(object): 507"""A (constant) set of object names. 508 509 The set should be initialized with full SHA1 object names. The 510 __contains__() method returns True iff its argument is an 511 abbreviation of any the names in the set.""" 512 513def__init__(self, names): 514 self._names =sorted(names) 515 516def__len__(self): 517returnlen(self._names) 518 519def__contains__(self, sha1_abbrev): 520"""Return True iff this set contains sha1_abbrev (which might be abbreviated).""" 521 522 i = bisect.bisect_left(self._names, sha1_abbrev) 523return i <len(self)and self._names[i].startswith(sha1_abbrev) 524 525 526classGitObject(object): 527def__init__(self, sha1,type=None): 528if sha1 == ZEROS: 529 self.sha1 = self.type= self.commit_sha1 =None 530else: 531 self.sha1 = sha1 532 self.type=typeorread_git_output(['cat-file','-t', self.sha1]) 533 534if self.type=='commit': 535 self.commit_sha1 = self.sha1 536elif self.type=='tag': 537try: 538 self.commit_sha1 =read_git_output( 539['rev-parse','--verify','%s^0'% (self.sha1,)] 540) 541except CommandError: 542# Cannot deref tag to determine commit_sha1 543 self.commit_sha1 =None 544else: 545 self.commit_sha1 =None 546 547 self.short =read_git_output(['rev-parse','--short', sha1]) 548 549defget_summary(self): 550"""Return (sha1_short, subject) for this commit.""" 551 552if not self.sha1: 553raiseValueError('Empty commit has no summary') 554 555returniter(generate_summaries('--no-walk', self.sha1)).next() 556 557def__eq__(self, other): 558returnisinstance(other, GitObject)and self.sha1 == other.sha1 559 560def__hash__(self): 561returnhash(self.sha1) 562 563def__nonzero__(self): 564returnbool(self.sha1) 565 566def__str__(self): 567return self.sha1 or ZEROS 568 569 570classChange(object): 571"""A Change that has been made to the Git repository. 572 573 Abstract class from which both Revisions and ReferenceChanges are 574 derived. A Change knows how to generate a notification email 575 describing itself.""" 576 577def__init__(self, environment): 578 self.environment = environment 579 self._values =None 580 581def_compute_values(self): 582"""Return a dictionary{keyword : expansion}for this Change. 583 584 Derived classes overload this method to add more entries to 585 the return value. This method is used internally by 586 get_values(). The return value should always be a new 587 dictionary.""" 588 589return self.environment.get_values() 590 591defget_values(self, **extra_values): 592"""Return a dictionary{keyword : expansion}for this Change. 593 594 Return a dictionary mapping keywords to the values that they 595 should be expanded to for this Change (used when interpolating 596 template strings). If any keyword arguments are supplied, add 597 those to the return value as well. The return value is always 598 a new dictionary.""" 599 600if self._values is None: 601 self._values = self._compute_values() 602 603 values = self._values.copy() 604if extra_values: 605 values.update(extra_values) 606return values 607 608defexpand(self, template, **extra_values): 609"""Expand template. 610 611 Expand the template (which should be a string) using string 612 interpolation of the values for this Change. If any keyword 613 arguments are provided, also include those in the keywords 614 available for interpolation.""" 615 616return template % self.get_values(**extra_values) 617 618defexpand_lines(self, template, **extra_values): 619"""Break template into lines and expand each line.""" 620 621 values = self.get_values(**extra_values) 622for line in template.splitlines(True): 623yield line % values 624 625defexpand_header_lines(self, template, **extra_values): 626"""Break template into lines and expand each line as an RFC 2822 header. 627 628 Encode values and split up lines that are too long. Silently 629 skip lines that contain references to unknown variables.""" 630 631 values = self.get_values(**extra_values) 632for line in template.splitlines(): 633(name, value) = line.split(':',1) 634 635try: 636 value = value % values 637exceptKeyError, e: 638if DEBUG: 639 sys.stderr.write( 640'Warning: unknown variable%rin the following line; line skipped:\n' 641'%s\n' 642% (e.args[0], line,) 643) 644else: 645if name.lower()in ADDR_HEADERS: 646 value =addr_header_encode(value, name) 647else: 648 value =header_encode(value, name) 649for splitline in('%s:%s\n'% (name, value)).splitlines(True): 650yield splitline 651 652defgenerate_email_header(self): 653"""Generate the RFC 2822 email headers for this Change, a line at a time. 654 655 The output should not include the trailing blank line.""" 656 657raiseNotImplementedError() 658 659defgenerate_email_intro(self): 660"""Generate the email intro for this Change, a line at a time. 661 662 The output will be used as the standard boilerplate at the top 663 of the email body.""" 664 665raiseNotImplementedError() 666 667defgenerate_email_body(self): 668"""Generate the main part of the email body, a line at a time. 669 670 The text in the body might be truncated after a specified 671 number of lines (see multimailhook.emailmaxlines).""" 672 673raiseNotImplementedError() 674 675defgenerate_email_footer(self): 676"""Generate the footer of the email, a line at a time. 677 678 The footer is always included, irrespective of 679 multimailhook.emailmaxlines.""" 680 681raiseNotImplementedError() 682 683defgenerate_email(self, push, body_filter=None, extra_header_values={}): 684"""Generate an email describing this change. 685 686 Iterate over the lines (including the header lines) of an 687 email describing this change. If body_filter is not None, 688 then use it to filter the lines that are intended for the 689 email body. 690 691 The extra_header_values field is received as a dict and not as 692 **kwargs, to allow passing other keyword arguments in the 693 future (e.g. passing extra values to generate_email_intro()""" 694 695for line in self.generate_email_header(**extra_header_values): 696yield line 697yield'\n' 698for line in self.generate_email_intro(): 699yield line 700 701 body = self.generate_email_body(push) 702if body_filter is not None: 703 body =body_filter(body) 704for line in body: 705yield line 706 707for line in self.generate_email_footer(): 708yield line 709 710 711classRevision(Change): 712"""A Change consisting of a single git commit.""" 713 714def__init__(self, reference_change, rev, num, tot): 715 Change.__init__(self, reference_change.environment) 716 self.reference_change = reference_change 717 self.rev = rev 718 self.change_type = self.reference_change.change_type 719 self.refname = self.reference_change.refname 720 self.num = num 721 self.tot = tot 722 self.author =read_git_output(['log','--no-walk','--format=%aN <%aE>', self.rev.sha1]) 723 self.recipients = self.environment.get_revision_recipients(self) 724 725def_compute_values(self): 726 values = Change._compute_values(self) 727 728 oneline =read_git_output( 729['log','--format=%s','--no-walk', self.rev.sha1] 730) 731 732 values['rev'] = self.rev.sha1 733 values['rev_short'] = self.rev.short 734 values['change_type'] = self.change_type 735 values['refname'] = self.refname 736 values['short_refname'] = self.reference_change.short_refname 737 values['refname_type'] = self.reference_change.refname_type 738 values['reply_to_msgid'] = self.reference_change.msgid 739 values['num'] = self.num 740 values['tot'] = self.tot 741 values['recipients'] = self.recipients 742 values['oneline'] = oneline 743 values['author'] = self.author 744 745 reply_to = self.environment.get_reply_to_commit(self) 746if reply_to: 747 values['reply_to'] = reply_to 748 749return values 750 751defgenerate_email_header(self, **extra_values): 752for line in self.expand_header_lines( 753 REVISION_HEADER_TEMPLATE, **extra_values 754): 755yield line 756 757defgenerate_email_intro(self): 758for line in self.expand_lines(REVISION_INTRO_TEMPLATE): 759yield line 760 761defgenerate_email_body(self, push): 762"""Show this revision.""" 763 764returnread_git_lines( 765['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1], 766 keepends=True, 767) 768 769defgenerate_email_footer(self): 770return self.expand_lines(REVISION_FOOTER_TEMPLATE) 771 772 773classReferenceChange(Change): 774"""A Change to a Git reference. 775 776 An abstract class representing a create, update, or delete of a 777 Git reference. Derived classes handle specific types of reference 778 (e.g., tags vs. branches). These classes generate the main 779 reference change email summarizing the reference change and 780 whether it caused any any commits to be added or removed. 781 782 ReferenceChange objects are usually created using the static 783 create() method, which has the logic to decide which derived class 784 to instantiate.""" 785 786 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$') 787 788@staticmethod 789defcreate(environment, oldrev, newrev, refname): 790"""Return a ReferenceChange object representing the change. 791 792 Return an object that represents the type of change that is being 793 made. oldrev and newrev should be SHA1s or ZEROS.""" 794 795 old =GitObject(oldrev) 796 new =GitObject(newrev) 797 rev = new or old 798 799# The revision type tells us what type the commit is, combined with 800# the location of the ref we can decide between 801# - working branch 802# - tracking branch 803# - unannotated tag 804# - annotated tag 805 m = ReferenceChange.REF_RE.match(refname) 806if m: 807 area = m.group('area') 808 short_refname = m.group('shortname') 809else: 810 area ='' 811 short_refname = refname 812 813if rev.type=='tag': 814# Annotated tag: 815 klass = AnnotatedTagChange 816elif rev.type=='commit': 817if area =='tags': 818# Non-annotated tag: 819 klass = NonAnnotatedTagChange 820elif area =='heads': 821# Branch: 822 klass = BranchChange 823elif area =='remotes': 824# Tracking branch: 825 sys.stderr.write( 826'*** Push-update of tracking branch%r\n' 827'*** - incomplete email generated.\n' 828% (refname,) 829) 830 klass = OtherReferenceChange 831else: 832# Some other reference namespace: 833 sys.stderr.write( 834'*** Push-update of strange reference%r\n' 835'*** - incomplete email generated.\n' 836% (refname,) 837) 838 klass = OtherReferenceChange 839else: 840# Anything else (is there anything else?) 841 sys.stderr.write( 842'*** Unknown type of update to%r(%s)\n' 843'*** - incomplete email generated.\n' 844% (refname, rev.type,) 845) 846 klass = OtherReferenceChange 847 848returnklass( 849 environment, 850 refname=refname, short_refname=short_refname, 851 old=old, new=new, rev=rev, 852) 853 854def__init__(self, environment, refname, short_refname, old, new, rev): 855 Change.__init__(self, environment) 856 self.change_type = { 857(False,True) :'create', 858(True,True) :'update', 859(True,False) :'delete', 860}[bool(old),bool(new)] 861 self.refname = refname 862 self.short_refname = short_refname 863 self.old = old 864 self.new = new 865 self.rev = rev 866 self.msgid =make_msgid() 867 self.diffopts = environment.diffopts 868 self.logopts = environment.logopts 869 self.commitlogopts = environment.commitlogopts 870 self.showlog = environment.refchange_showlog 871 872def_compute_values(self): 873 values = Change._compute_values(self) 874 875 values['change_type'] = self.change_type 876 values['refname_type'] = self.refname_type 877 values['refname'] = self.refname 878 values['short_refname'] = self.short_refname 879 values['msgid'] = self.msgid 880 values['recipients'] = self.recipients 881 values['oldrev'] =str(self.old) 882 values['oldrev_short'] = self.old.short 883 values['newrev'] =str(self.new) 884 values['newrev_short'] = self.new.short 885 886if self.old: 887 values['oldrev_type'] = self.old.type 888if self.new: 889 values['newrev_type'] = self.new.type 890 891 reply_to = self.environment.get_reply_to_refchange(self) 892if reply_to: 893 values['reply_to'] = reply_to 894 895return values 896 897defget_subject(self): 898 template = { 899'create': REF_CREATED_SUBJECT_TEMPLATE, 900'update': REF_UPDATED_SUBJECT_TEMPLATE, 901'delete': REF_DELETED_SUBJECT_TEMPLATE, 902}[self.change_type] 903return self.expand(template) 904 905defgenerate_email_header(self, **extra_values): 906if'subject'not in extra_values: 907 extra_values['subject'] = self.get_subject() 908 909for line in self.expand_header_lines( 910 REFCHANGE_HEADER_TEMPLATE, **extra_values 911): 912yield line 913 914defgenerate_email_intro(self): 915for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE): 916yield line 917 918defgenerate_email_body(self, push): 919"""Call the appropriate body-generation routine. 920 921 Call one of generate_create_summary() / 922 generate_update_summary() / generate_delete_summary().""" 923 924 change_summary = { 925'create': self.generate_create_summary, 926'delete': self.generate_delete_summary, 927'update': self.generate_update_summary, 928}[self.change_type](push) 929for line in change_summary: 930yield line 931 932for line in self.generate_revision_change_summary(push): 933yield line 934 935defgenerate_email_footer(self): 936return self.expand_lines(FOOTER_TEMPLATE) 937 938defgenerate_revision_change_log(self, new_commits_list): 939if self.showlog: 940yield'\n' 941yield'Detailed log of new commits:\n\n' 942for line inread_git_lines( 943['log','--no-walk'] 944+ self.logopts 945+ new_commits_list 946+ ['--'], 947 keepends=True, 948): 949yield line 950 951defgenerate_revision_change_summary(self, push): 952"""Generate a summary of the revisions added/removed by this change.""" 953 954if self.new.commit_sha1 and not self.old.commit_sha1: 955# A new reference was created. List the new revisions 956# brought by the new reference (i.e., those revisions that 957# were not in the repository before this reference 958# change). 959 sha1s =list(push.get_new_commits(self)) 960 sha1s.reverse() 961 tot =len(sha1s) 962 new_revisions = [ 963Revision(self,GitObject(sha1), num=i+1, tot=tot) 964for(i, sha1)inenumerate(sha1s) 965] 966 967if new_revisions: 968yield self.expand('This%(refname_type)sincludes the following new commits:\n') 969yield'\n' 970for r in new_revisions: 971(sha1, subject) = r.rev.get_summary() 972yield r.expand( 973 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject, 974) 975yield'\n' 976for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): 977yield line 978for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]): 979yield line 980else: 981for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): 982yield line 983 984elif self.new.commit_sha1 and self.old.commit_sha1: 985# A reference was changed to point at a different commit. 986# List the revisions that were removed and/or added *from 987# that reference* by this reference change, along with a 988# diff between the trees for its old and new values. 989 990# List of the revisions that were added to the branch by 991# this update. Note this list can include revisions that 992# have already had notification emails; we want such 993# revisions in the summary even though we will not send 994# new notification emails for them. 995 adds =list(generate_summaries( 996'--topo-order','--reverse','%s..%s' 997% (self.old.commit_sha1, self.new.commit_sha1,) 998)) 9991000# List of the revisions that were removed from the branch1001# by this update. This will be empty except for1002# non-fast-forward updates.1003 discards =list(generate_summaries(1004'%s..%s'% (self.new.commit_sha1, self.old.commit_sha1,)1005))10061007if adds:1008 new_commits_list = push.get_new_commits(self)1009else:1010 new_commits_list = []1011 new_commits =CommitSet(new_commits_list)10121013if discards:1014 discarded_commits =CommitSet(push.get_discarded_commits(self))1015else:1016 discarded_commits =CommitSet([])10171018if discards and adds:1019for(sha1, subject)in discards:1020if sha1 in discarded_commits:1021 action ='discards'1022else:1023 action ='omits'1024yield self.expand(1025 BRIEF_SUMMARY_TEMPLATE, action=action,1026 rev_short=sha1, text=subject,1027)1028for(sha1, subject)in adds:1029if sha1 in new_commits:1030 action ='new'1031else:1032 action ='adds'1033yield self.expand(1034 BRIEF_SUMMARY_TEMPLATE, action=action,1035 rev_short=sha1, text=subject,1036)1037yield'\n'1038for line in self.expand_lines(NON_FF_TEMPLATE):1039yield line10401041elif discards:1042for(sha1, subject)in discards:1043if sha1 in discarded_commits:1044 action ='discards'1045else:1046 action ='omits'1047yield self.expand(1048 BRIEF_SUMMARY_TEMPLATE, action=action,1049 rev_short=sha1, text=subject,1050)1051yield'\n'1052for line in self.expand_lines(REWIND_ONLY_TEMPLATE):1053yield line10541055elif adds:1056(sha1, subject) = self.old.get_summary()1057yield self.expand(1058 BRIEF_SUMMARY_TEMPLATE, action='from',1059 rev_short=sha1, text=subject,1060)1061for(sha1, subject)in adds:1062if sha1 in new_commits:1063 action ='new'1064else:1065 action ='adds'1066yield self.expand(1067 BRIEF_SUMMARY_TEMPLATE, action=action,1068 rev_short=sha1, text=subject,1069)10701071yield'\n'10721073if new_commits:1074for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):1075yield line1076for line in self.generate_revision_change_log(new_commits_list):1077yield line1078else:1079for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1080yield line10811082# The diffstat is shown from the old revision to the new1083# revision. This is to show the truth of what happened in1084# this change. There's no point showing the stat from the1085# base to the new revision because the base is effectively a1086# random revision at this point - the user will be interested1087# in what this revision changed - including the undoing of1088# previous revisions in the case of non-fast-forward updates.1089yield'\n'1090yield'Summary of changes:\n'1091for line inread_git_lines(1092['diff-tree']1093+ self.diffopts1094+ ['%s..%s'% (self.old.commit_sha1, self.new.commit_sha1,)],1095 keepends=True,1096):1097yield line10981099elif self.old.commit_sha1 and not self.new.commit_sha1:1100# A reference was deleted. List the revisions that were1101# removed from the repository by this reference change.11021103 sha1s =list(push.get_discarded_commits(self))1104 tot =len(sha1s)1105 discarded_revisions = [1106Revision(self,GitObject(sha1), num=i+1, tot=tot)1107for(i, sha1)inenumerate(sha1s)1108]11091110if discarded_revisions:1111for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):1112yield line1113yield'\n'1114for r in discarded_revisions:1115(sha1, subject) = r.rev.get_summary()1116yield r.expand(1117 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,1118)1119else:1120for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):1121yield line11221123elif not self.old.commit_sha1 and not self.new.commit_sha1:1124for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):1125yield line11261127defgenerate_create_summary(self, push):1128"""Called for the creation of a reference."""11291130# This is a new reference and so oldrev is not valid1131(sha1, subject) = self.new.get_summary()1132yield self.expand(1133 BRIEF_SUMMARY_TEMPLATE, action='at',1134 rev_short=sha1, text=subject,1135)1136yield'\n'11371138defgenerate_update_summary(self, push):1139"""Called for the change of a pre-existing branch."""11401141returniter([])11421143defgenerate_delete_summary(self, push):1144"""Called for the deletion of any type of reference."""11451146(sha1, subject) = self.old.get_summary()1147yield self.expand(1148 BRIEF_SUMMARY_TEMPLATE, action='was',1149 rev_short=sha1, text=subject,1150)1151yield'\n'115211531154classBranchChange(ReferenceChange):1155 refname_type ='branch'11561157def__init__(self, environment, refname, short_refname, old, new, rev):1158 ReferenceChange.__init__(1159 self, environment,1160 refname=refname, short_refname=short_refname,1161 old=old, new=new, rev=rev,1162)1163 self.recipients = environment.get_refchange_recipients(self)116411651166classAnnotatedTagChange(ReferenceChange):1167 refname_type ='annotated tag'11681169def__init__(self, environment, refname, short_refname, old, new, rev):1170 ReferenceChange.__init__(1171 self, environment,1172 refname=refname, short_refname=short_refname,1173 old=old, new=new, rev=rev,1174)1175 self.recipients = environment.get_announce_recipients(self)1176 self.show_shortlog = environment.announce_show_shortlog11771178 ANNOTATED_TAG_FORMAT = (1179'%(*objectname)\n'1180'%(*objecttype)\n'1181'%(taggername)\n'1182'%(taggerdate)'1183)11841185defdescribe_tag(self, push):1186"""Describe the new value of an annotated tag."""11871188# Use git for-each-ref to pull out the individual fields from1189# the tag1190[tagobject, tagtype, tagger, tagged] =read_git_lines(1191['for-each-ref','--format=%s'% (self.ANNOTATED_TAG_FORMAT,), self.refname],1192)11931194yield self.expand(1195 BRIEF_SUMMARY_TEMPLATE, action='tagging',1196 rev_short=tagobject, text='(%s)'% (tagtype,),1197)1198if tagtype =='commit':1199# If the tagged object is a commit, then we assume this is a1200# release, and so we calculate which tag this tag is1201# replacing1202try:1203 prevtag =read_git_output(['describe','--abbrev=0','%s^'% (self.new,)])1204except CommandError:1205 prevtag =None1206if prevtag:1207yield' replaces%s\n'% (prevtag,)1208else:1209 prevtag =None1210yield' length%sbytes\n'% (read_git_output(['cat-file','-s', tagobject]),)12111212yield' tagged by%s\n'% (tagger,)1213yield' on%s\n'% (tagged,)1214yield'\n'12151216# Show the content of the tag message; this might contain a1217# change log or release notes so is worth displaying.1218yield LOGBEGIN1219 contents =list(read_git_lines(['cat-file','tag', self.new.sha1], keepends=True))1220 contents = contents[contents.index('\n') +1:]1221if contents and contents[-1][-1:] !='\n':1222 contents.append('\n')1223for line in contents:1224yield line12251226if self.show_shortlog and tagtype =='commit':1227# Only commit tags make sense to have rev-list operations1228# performed on them1229yield'\n'1230if prevtag:1231# Show changes since the previous release1232 revlist =read_git_output(1233['rev-list','--pretty=short','%s..%s'% (prevtag, self.new,)],1234 keepends=True,1235)1236else:1237# No previous tag, show all the changes since time1238# began1239 revlist =read_git_output(1240['rev-list','--pretty=short','%s'% (self.new,)],1241 keepends=True,1242)1243for line inread_git_lines(['shortlog'],input=revlist, keepends=True):1244yield line12451246yield LOGEND1247yield'\n'12481249defgenerate_create_summary(self, push):1250"""Called for the creation of an annotated tag."""12511252for line in self.expand_lines(TAG_CREATED_TEMPLATE):1253yield line12541255for line in self.describe_tag(push):1256yield line12571258defgenerate_update_summary(self, push):1259"""Called for the update of an annotated tag.12601261 This is probably a rare event and may not even be allowed."""12621263for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1264yield line12651266for line in self.describe_tag(push):1267yield line12681269defgenerate_delete_summary(self, push):1270"""Called when a non-annotated reference is updated."""12711272for line in self.expand_lines(TAG_DELETED_TEMPLATE):1273yield line12741275yield self.expand(' tag was%(oldrev_short)s\n')1276yield'\n'127712781279classNonAnnotatedTagChange(ReferenceChange):1280 refname_type ='tag'12811282def__init__(self, environment, refname, short_refname, old, new, rev):1283 ReferenceChange.__init__(1284 self, environment,1285 refname=refname, short_refname=short_refname,1286 old=old, new=new, rev=rev,1287)1288 self.recipients = environment.get_refchange_recipients(self)12891290defgenerate_create_summary(self, push):1291"""Called for the creation of an annotated tag."""12921293for line in self.expand_lines(TAG_CREATED_TEMPLATE):1294yield line12951296defgenerate_update_summary(self, push):1297"""Called when a non-annotated reference is updated."""12981299for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1300yield line13011302defgenerate_delete_summary(self, push):1303"""Called when a non-annotated reference is updated."""13041305for line in self.expand_lines(TAG_DELETED_TEMPLATE):1306yield line13071308for line in ReferenceChange.generate_delete_summary(self, push):1309yield line131013111312classOtherReferenceChange(ReferenceChange):1313 refname_type ='reference'13141315def__init__(self, environment, refname, short_refname, old, new, rev):1316# We use the full refname as short_refname, because otherwise1317# the full name of the reference would not be obvious from the1318# text of the email.1319 ReferenceChange.__init__(1320 self, environment,1321 refname=refname, short_refname=refname,1322 old=old, new=new, rev=rev,1323)1324 self.recipients = environment.get_refchange_recipients(self)132513261327classMailer(object):1328"""An object that can send emails."""13291330defsend(self, lines, to_addrs):1331"""Send an email consisting of lines.13321333 lines must be an iterable over the lines constituting the1334 header and body of the email. to_addrs is a list of recipient1335 addresses (can be needed even if lines already contains a1336 "To:" field). It can be either a string (comma-separated list1337 of email addresses) or a Python list of individual email1338 addresses.13391340 """13411342raiseNotImplementedError()134313441345classSendMailer(Mailer):1346"""Send emails using 'sendmail -oi -t'."""13471348 SENDMAIL_CANDIDATES = [1349'/usr/sbin/sendmail',1350'/usr/lib/sendmail',1351]13521353@staticmethod1354deffind_sendmail():1355for path in SendMailer.SENDMAIL_CANDIDATES:1356if os.access(path, os.X_OK):1357return path1358else:1359raiseConfigurationException(1360'No sendmail executable found. '1361'Try setting multimailhook.sendmailCommand.'1362)13631364def__init__(self, command=None, envelopesender=None):1365"""Construct a SendMailer instance.13661367 command should be the command and arguments used to invoke1368 sendmail, as a list of strings. If an envelopesender is1369 provided, it will also be passed to the command, via '-f1370 envelopesender'."""13711372if command:1373 self.command = command[:]1374else:1375 self.command = [self.find_sendmail(),'-oi','-t']13761377if envelopesender:1378 self.command.extend(['-f', envelopesender])13791380defsend(self, lines, to_addrs):1381try:1382 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)1383exceptOSError, e:1384 sys.stderr.write(1385'*** Cannot execute command:%s\n'%' '.join(self.command)1386+'***%s\n'%str(e)1387+'*** Try setting multimailhook.mailer to "smtp"\n'1388'*** to send emails without using the sendmail command.\n'1389)1390 sys.exit(1)1391try:1392 p.stdin.writelines(lines)1393except:1394 sys.stderr.write(1395'*** Error while generating commit email\n'1396'*** - mail sending aborted.\n'1397)1398 p.terminate()1399raise1400else:1401 p.stdin.close()1402 retcode = p.wait()1403if retcode:1404raiseCommandError(self.command, retcode)140514061407classSMTPMailer(Mailer):1408"""Send emails using Python's smtplib."""14091410def__init__(self, envelopesender, smtpserver):1411if not envelopesender:1412 sys.stderr.write(1413'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'1414'please set either multimailhook.envelopeSender or user.email\n'1415)1416 sys.exit(1)1417 self.envelopesender = envelopesender1418 self.smtpserver = smtpserver1419try:1420 self.smtp = smtplib.SMTP(self.smtpserver)1421exceptException, e:1422 sys.stderr.write('*** Error establishing SMTP connection to%s***\n'% self.smtpserver)1423 sys.stderr.write('***%s\n'%str(e))1424 sys.exit(1)14251426def__del__(self):1427 self.smtp.quit()14281429defsend(self, lines, to_addrs):1430try:1431 msg =''.join(lines)1432# turn comma-separated list into Python list if needed.1433ifisinstance(to_addrs, basestring):1434 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]1435 self.smtp.sendmail(self.envelopesender, to_addrs, msg)1436exceptException, e:1437 sys.stderr.write('*** Error sending email***\n')1438 sys.stderr.write('***%s\n'%str(e))1439 self.smtp.quit()1440 sys.exit(1)144114421443classOutputMailer(Mailer):1444"""Write emails to an output stream, bracketed by lines of '=' characters.14451446 This is intended for debugging purposes."""14471448 SEPARATOR ='='*75+'\n'14491450def__init__(self, f):1451 self.f = f14521453defsend(self, lines, to_addrs):1454 self.f.write(self.SEPARATOR)1455 self.f.writelines(lines)1456 self.f.write(self.SEPARATOR)145714581459defget_git_dir():1460"""Determine GIT_DIR.14611462 Determine GIT_DIR either from the GIT_DIR environment variable or1463 from the working directory, using Git's usual rules."""14641465try:1466returnread_git_output(['rev-parse','--git-dir'])1467except CommandError:1468 sys.stderr.write('fatal: git_multimail: not in a git directory\n')1469 sys.exit(1)147014711472classEnvironment(object):1473"""Describes the environment in which the push is occurring.14741475 An Environment object encapsulates information about the local1476 environment. For example, it knows how to determine:14771478 * the name of the repository to which the push occurred14791480 * what user did the push14811482 * what users want to be informed about various types of changes.14831484 An Environment object is expected to have the following methods:14851486 get_repo_shortname()14871488 Return a short name for the repository, for display1489 purposes.14901491 get_repo_path()14921493 Return the absolute path to the Git repository.14941495 get_emailprefix()14961497 Return a string that will be prefixed to every email's1498 subject.14991500 get_pusher()15011502 Return the username of the person who pushed the changes.1503 This value is used in the email body to indicate who1504 pushed the change.15051506 get_pusher_email() (may return None)15071508 Return the email address of the person who pushed the1509 changes. The value should be a single RFC 2822 email1510 address as a string; e.g., "Joe User <user@example.com>"1511 if available, otherwise "user@example.com". If set, the1512 value is used as the Reply-To address for refchange1513 emails. If it is impossible to determine the pusher's1514 email, this attribute should be set to None (in which case1515 no Reply-To header will be output).15161517 get_sender()15181519 Return the address to be used as the 'From' email address1520 in the email envelope.15211522 get_fromaddr()15231524 Return the 'From' email address used in the email 'From:'1525 headers. (May be a full RFC 2822 email address like 'Joe1526 User <user@example.com>'.)15271528 get_administrator()15291530 Return the name and/or email of the repository1531 administrator. This value is used in the footer as the1532 person to whom requests to be removed from the1533 notification list should be sent. Ideally, it should1534 include a valid email address.15351536 get_reply_to_refchange()1537 get_reply_to_commit()15381539 Return the address to use in the email "Reply-To" header,1540 as a string. These can be an RFC 2822 email address, or1541 None to omit the "Reply-To" header.1542 get_reply_to_refchange() is used for refchange emails;1543 get_reply_to_commit() is used for individual commit1544 emails.15451546 They should also define the following attributes:15471548 announce_show_shortlog (bool)15491550 True iff announce emails should include a shortlog.15511552 refchange_showlog (bool)15531554 True iff refchanges emails should include a detailed log.15551556 diffopts (list of strings)15571558 The options that should be passed to 'git diff' for the1559 summary email. The value should be a list of strings1560 representing words to be passed to the command.15611562 logopts (list of strings)15631564 Analogous to diffopts, but contains options passed to1565 'git log' when generating the detailed log for a set of1566 commits (see refchange_showlog)15671568 commitlogopts (list of strings)15691570 The options that should be passed to 'git log' for each1571 commit mail. The value should be a list of strings1572 representing words to be passed to the command.15731574 """15751576 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')15771578def__init__(self, osenv=None):1579 self.osenv = osenv or os.environ1580 self.announce_show_shortlog =False1581 self.maxcommitemails =5001582 self.diffopts = ['--stat','--summary','--find-copies-harder']1583 self.logopts = []1584 self.refchange_showlog =False1585 self.commitlogopts = ['-C','--stat','-p','--cc']15861587 self.COMPUTED_KEYS = [1588'administrator',1589'charset',1590'emailprefix',1591'fromaddr',1592'pusher',1593'pusher_email',1594'repo_path',1595'repo_shortname',1596'sender',1597]15981599 self._values =None16001601defget_repo_shortname(self):1602"""Use the last part of the repo path, with ".git" stripped off if present."""16031604 basename = os.path.basename(os.path.abspath(self.get_repo_path()))1605 m = self.REPO_NAME_RE.match(basename)1606if m:1607return m.group('name')1608else:1609return basename16101611defget_pusher(self):1612raiseNotImplementedError()16131614defget_pusher_email(self):1615return None16161617defget_administrator(self):1618return'the administrator of this repository'16191620defget_emailprefix(self):1621return''16221623defget_repo_path(self):1624ifread_git_output(['rev-parse','--is-bare-repository']) =='true':1625 path =get_git_dir()1626else:1627 path =read_git_output(['rev-parse','--show-toplevel'])1628return os.path.abspath(path)16291630defget_charset(self):1631return CHARSET16321633defget_values(self):1634"""Return a dictionary{keyword : expansion}for this Environment.16351636 This method is called by Change._compute_values(). The keys1637 in the returned dictionary are available to be used in any of1638 the templates. The dictionary is created by calling1639 self.get_NAME() for each of the attributes named in1640 COMPUTED_KEYS and recording those that do not return None.1641 The return value is always a new dictionary."""16421643if self._values is None:1644 values = {}16451646for key in self.COMPUTED_KEYS:1647 value =getattr(self,'get_%s'% (key,))()1648if value is not None:1649 values[key] = value16501651 self._values = values16521653return self._values.copy()16541655defget_refchange_recipients(self, refchange):1656"""Return the recipients for notifications about refchange.16571658 Return the list of email addresses to which notifications1659 about the specified ReferenceChange should be sent."""16601661raiseNotImplementedError()16621663defget_announce_recipients(self, annotated_tag_change):1664"""Return the recipients for notifications about annotated_tag_change.16651666 Return the list of email addresses to which notifications1667 about the specified AnnotatedTagChange should be sent."""16681669raiseNotImplementedError()16701671defget_reply_to_refchange(self, refchange):1672return self.get_pusher_email()16731674defget_revision_recipients(self, revision):1675"""Return the recipients for messages about revision.16761677 Return the list of email addresses to which notifications1678 about the specified Revision should be sent. This method1679 could be overridden, for example, to take into account the1680 contents of the revision when deciding whom to notify about1681 it. For example, there could be a scheme for users to express1682 interest in particular files or subdirectories, and only1683 receive notification emails for revisions that affecting those1684 files."""16851686raiseNotImplementedError()16871688defget_reply_to_commit(self, revision):1689return revision.author16901691deffilter_body(self, lines):1692"""Filter the lines intended for an email body.16931694 lines is an iterable over the lines that would go into the1695 email body. Filter it (e.g., limit the number of lines, the1696 line length, character set, etc.), returning another iterable.1697 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin1698 for classes implementing this functionality."""16991700return lines170117021703classConfigEnvironmentMixin(Environment):1704"""A mixin that sets self.config to its constructor's config argument.17051706 This class's constructor consumes the "config" argument.17071708 Mixins that need to inspect the config should inherit from this1709 class (1) to make sure that "config" is still in the constructor1710 arguments with its own constructor runs and/or (2) to be sure that1711 self.config is set after construction."""17121713def__init__(self, config, **kw):1714super(ConfigEnvironmentMixin, self).__init__(**kw)1715 self.config = config171617171718classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):1719"""An Environment that reads most of its information from "git config"."""17201721def__init__(self, config, **kw):1722super(ConfigOptionsEnvironmentMixin, self).__init__(1723 config=config, **kw1724)17251726 self.announce_show_shortlog = config.get_bool(1727'announceshortlog', default=self.announce_show_shortlog1728)17291730 self.refchange_showlog = config.get_bool(1731'refchangeshowlog', default=self.refchange_showlog1732)17331734 maxcommitemails = config.get('maxcommitemails')1735if maxcommitemails is not None:1736try:1737 self.maxcommitemails =int(maxcommitemails)1738exceptValueError:1739 sys.stderr.write(1740'*** Malformed value for multimailhook.maxCommitEmails:%s\n'% maxcommitemails1741+'*** Expected a number. Ignoring.\n'1742)17431744 diffopts = config.get('diffopts')1745if diffopts is not None:1746 self.diffopts = shlex.split(diffopts)17471748 logopts = config.get('logopts')1749if logopts is not None:1750 self.logopts = shlex.split(logopts)17511752 commitlogopts = config.get('commitlogopts')1753if commitlogopts is not None:1754 self.commitlogopts = shlex.split(commitlogopts)17551756 reply_to = config.get('replyTo')1757 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)1758if(1759 self.__reply_to_refchange is not None1760and self.__reply_to_refchange.lower() =='author'1761):1762raiseConfigurationException(1763'"author" is not an allowed setting for replyToRefchange'1764)1765 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)17661767defget_administrator(self):1768return(1769 self.config.get('administrator')1770or self.get_sender()1771orsuper(ConfigOptionsEnvironmentMixin, self).get_administrator()1772)17731774defget_repo_shortname(self):1775return(1776 self.config.get('reponame')1777orsuper(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()1778)17791780defget_emailprefix(self):1781 emailprefix = self.config.get('emailprefix')1782if emailprefix and emailprefix.strip():1783return emailprefix.strip() +' '1784else:1785return'[%s] '% (self.get_repo_shortname(),)17861787defget_sender(self):1788return self.config.get('envelopesender')17891790defget_fromaddr(self):1791 fromaddr = self.config.get('from')1792if fromaddr:1793return fromaddr1794else:1795 config =Config('user')1796 fromname = config.get('name', default='')1797 fromemail = config.get('email', default='')1798if fromemail:1799returnformataddr([fromname, fromemail])1800else:1801return self.get_sender()18021803defget_reply_to_refchange(self, refchange):1804if self.__reply_to_refchange is None:1805returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)1806elif self.__reply_to_refchange.lower() =='pusher':1807return self.get_pusher_email()1808elif self.__reply_to_refchange.lower() =='none':1809return None1810else:1811return self.__reply_to_refchange18121813defget_reply_to_commit(self, revision):1814if self.__reply_to_commit is None:1815returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)1816elif self.__reply_to_commit.lower() =='author':1817return revision.get_author()1818elif self.__reply_to_commit.lower() =='pusher':1819return self.get_pusher_email()1820elif self.__reply_to_commit.lower() =='none':1821return None1822else:1823return self.__reply_to_commit182418251826classFilterLinesEnvironmentMixin(Environment):1827"""Handle encoding and maximum line length of body lines.18281829 emailmaxlinelength (int or None)18301831 The maximum length of any single line in the email body.1832 Longer lines are truncated at that length with ' [...]'1833 appended.18341835 strict_utf8 (bool)18361837 If this field is set to True, then the email body text is1838 expected to be UTF-8. Any invalid characters are1839 converted to U+FFFD, the Unicode replacement character1840 (encoded as UTF-8, of course).18411842 """18431844def__init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):1845super(FilterLinesEnvironmentMixin, self).__init__(**kw)1846 self.__strict_utf8= strict_utf81847 self.__emailmaxlinelength = emailmaxlinelength18481849deffilter_body(self, lines):1850 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)1851if self.__strict_utf8:1852 lines = (line.decode(ENCODING,'replace')for line in lines)1853# Limit the line length in Unicode-space to avoid1854# splitting characters:1855if self.__emailmaxlinelength:1856 lines =limit_linelength(lines, self.__emailmaxlinelength)1857 lines = (line.encode(ENCODING,'replace')for line in lines)1858elif self.__emailmaxlinelength:1859 lines =limit_linelength(lines, self.__emailmaxlinelength)18601861return lines186218631864classConfigFilterLinesEnvironmentMixin(1865 ConfigEnvironmentMixin,1866 FilterLinesEnvironmentMixin,1867):1868"""Handle encoding and maximum line length based on config."""18691870def__init__(self, config, **kw):1871 strict_utf8 = config.get_bool('emailstrictutf8', default=None)1872if strict_utf8 is not None:1873 kw['strict_utf8'] = strict_utf818741875 emailmaxlinelength = config.get('emailmaxlinelength')1876if emailmaxlinelength is not None:1877 kw['emailmaxlinelength'] =int(emailmaxlinelength)18781879super(ConfigFilterLinesEnvironmentMixin, self).__init__(1880 config=config, **kw1881)188218831884classMaxlinesEnvironmentMixin(Environment):1885"""Limit the email body to a specified number of lines."""18861887def__init__(self, emailmaxlines, **kw):1888super(MaxlinesEnvironmentMixin, self).__init__(**kw)1889 self.__emailmaxlines = emailmaxlines18901891deffilter_body(self, lines):1892 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)1893if self.__emailmaxlines:1894 lines =limit_lines(lines, self.__emailmaxlines)1895return lines189618971898classConfigMaxlinesEnvironmentMixin(1899 ConfigEnvironmentMixin,1900 MaxlinesEnvironmentMixin,1901):1902"""Limit the email body to the number of lines specified in config."""19031904def__init__(self, config, **kw):1905 emailmaxlines =int(config.get('emailmaxlines', default='0'))1906super(ConfigMaxlinesEnvironmentMixin, self).__init__(1907 config=config,1908 emailmaxlines=emailmaxlines,1909**kw1910)191119121913classFQDNEnvironmentMixin(Environment):1914"""A mixin that sets the host's FQDN to its constructor argument."""19151916def__init__(self, fqdn, **kw):1917super(FQDNEnvironmentMixin, self).__init__(**kw)1918 self.COMPUTED_KEYS += ['fqdn']1919 self.__fqdn = fqdn19201921defget_fqdn(self):1922"""Return the fully-qualified domain name for this host.19231924 Return None if it is unavailable or unwanted."""19251926return self.__fqdn192719281929classConfigFQDNEnvironmentMixin(1930 ConfigEnvironmentMixin,1931 FQDNEnvironmentMixin,1932):1933"""Read the FQDN from the config."""19341935def__init__(self, config, **kw):1936 fqdn = config.get('fqdn')1937super(ConfigFQDNEnvironmentMixin, self).__init__(1938 config=config,1939 fqdn=fqdn,1940**kw1941)194219431944classComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):1945"""Get the FQDN by calling socket.getfqdn()."""19461947def__init__(self, **kw):1948super(ComputeFQDNEnvironmentMixin, self).__init__(1949 fqdn=socket.getfqdn(),1950**kw1951)195219531954classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):1955"""Deduce pusher_email from pusher by appending an emaildomain."""19561957def__init__(self, **kw):1958super(PusherDomainEnvironmentMixin, self).__init__(**kw)1959 self.__emaildomain = self.config.get('emaildomain')19601961defget_pusher_email(self):1962if self.__emaildomain:1963# Derive the pusher's full email address in the default way:1964return'%s@%s'% (self.get_pusher(), self.__emaildomain)1965else:1966returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()196719681969classStaticRecipientsEnvironmentMixin(Environment):1970"""Set recipients statically based on constructor parameters."""19711972def__init__(1973 self,1974 refchange_recipients, announce_recipients, revision_recipients,1975**kw1976):1977super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)19781979# The recipients for various types of notification emails, as1980# RFC 2822 email addresses separated by commas (or the empty1981# string if no recipients are configured). Although there is1982# a mechanism to choose the recipient lists based on on the1983# actual *contents* of the change being reported, we only1984# choose based on the *type* of the change. Therefore we can1985# compute them once and for all:1986if not(refchange_recipients1987or announce_recipients1988or revision_recipients):1989raiseConfigurationException('No email recipients configured!')1990 self.__refchange_recipients = refchange_recipients1991 self.__announce_recipients = announce_recipients1992 self.__revision_recipients = revision_recipients19931994defget_refchange_recipients(self, refchange):1995return self.__refchange_recipients19961997defget_announce_recipients(self, annotated_tag_change):1998return self.__announce_recipients19992000defget_revision_recipients(self, revision):2001return self.__revision_recipients200220032004classConfigRecipientsEnvironmentMixin(2005 ConfigEnvironmentMixin,2006 StaticRecipientsEnvironmentMixin2007):2008"""Determine recipients statically based on config."""20092010def__init__(self, config, **kw):2011super(ConfigRecipientsEnvironmentMixin, self).__init__(2012 config=config,2013 refchange_recipients=self._get_recipients(2014 config,'refchangelist','mailinglist',2015),2016 announce_recipients=self._get_recipients(2017 config,'announcelist','refchangelist','mailinglist',2018),2019 revision_recipients=self._get_recipients(2020 config,'commitlist','mailinglist',2021),2022**kw2023)20242025def_get_recipients(self, config, *names):2026"""Return the recipients for a particular type of message.20272028 Return the list of email addresses to which a particular type2029 of notification email should be sent, by looking at the config2030 value for "multimailhook.$name" for each of names. Use the2031 value from the first name that is configured. The return2032 value is a (possibly empty) string containing RFC 2822 email2033 addresses separated by commas. If no configuration could be2034 found, raise a ConfigurationException."""20352036for name in names:2037 retval = config.get_recipients(name)2038if retval is not None:2039return retval2040else:2041return''204220432044classProjectdescEnvironmentMixin(Environment):2045"""Make a "projectdesc" value available for templates.20462047 By default, it is set to the first line of $GIT_DIR/description2048 (if that file is present and appears to be set meaningfully)."""20492050def__init__(self, **kw):2051super(ProjectdescEnvironmentMixin, self).__init__(**kw)2052 self.COMPUTED_KEYS += ['projectdesc']20532054defget_projectdesc(self):2055"""Return a one-line descripition of the project."""20562057 git_dir =get_git_dir()2058try:2059 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()2060if projectdesc and not projectdesc.startswith('Unnamed repository'):2061return projectdesc2062exceptIOError:2063pass20642065return'UNNAMED PROJECT'206620672068classGenericEnvironmentMixin(Environment):2069defget_pusher(self):2070return self.osenv.get('USER','unknown user')207120722073classGenericEnvironment(2074 ProjectdescEnvironmentMixin,2075 ConfigMaxlinesEnvironmentMixin,2076 ComputeFQDNEnvironmentMixin,2077 ConfigFilterLinesEnvironmentMixin,2078 ConfigRecipientsEnvironmentMixin,2079 PusherDomainEnvironmentMixin,2080 ConfigOptionsEnvironmentMixin,2081 GenericEnvironmentMixin,2082 Environment,2083):2084pass208520862087classGitoliteEnvironmentMixin(Environment):2088defget_repo_shortname(self):2089# The gitolite environment variable $GL_REPO is a pretty good2090# repo_shortname (though it's probably not as good as a value2091# the user might have explicitly put in his config).2092return(2093 self.osenv.get('GL_REPO',None)2094orsuper(GitoliteEnvironmentMixin, self).get_repo_shortname()2095)20962097defget_pusher(self):2098return self.osenv.get('GL_USER','unknown user')209921002101classIncrementalDateTime(object):2102"""Simple wrapper to give incremental date/times.21032104 Each call will result in a date/time a second later than the2105 previous call. This can be used to falsify email headers, to2106 increase the likelihood that email clients sort the emails2107 correctly."""21082109def__init__(self):2110 self.time = time.time()21112112defnext(self):2113 formatted =formatdate(self.time,True)2114 self.time +=12115return formatted211621172118classGitoliteEnvironment(2119 ProjectdescEnvironmentMixin,2120 ConfigMaxlinesEnvironmentMixin,2121 ComputeFQDNEnvironmentMixin,2122 ConfigFilterLinesEnvironmentMixin,2123 ConfigRecipientsEnvironmentMixin,2124 PusherDomainEnvironmentMixin,2125 ConfigOptionsEnvironmentMixin,2126 GitoliteEnvironmentMixin,2127 Environment,2128):2129pass213021312132classPush(object):2133"""Represent an entire push (i.e., a group of ReferenceChanges).21342135 It is easy to figure out what commits were added to a *branch* by2136 a Reference change:21372138 git rev-list change.old..change.new21392140 or removed from a *branch*:21412142 git rev-list change.new..change.old21432144 But it is not quite so trivial to determine which entirely new2145 commits were added to the *repository* by a push and which old2146 commits were discarded by a push. A big part of the job of this2147 class is to figure out these things, and to make sure that new2148 commits are only detailed once even if they were added to multiple2149 references.21502151 The first step is to determine the "other" references--those2152 unaffected by the current push. They are computed by2153 Push._compute_other_ref_sha1s() by listing all references then2154 removing any affected by this push.21552156 The commits contained in the repository before this push were21572158 git rev-list other1 other2 other3 ... change1.old change2.old ...21592160 Where "changeN.old" is the old value of one of the references2161 affected by this push.21622163 The commits contained in the repository after this push are21642165 git rev-list other1 other2 other3 ... change1.new change2.new ...21662167 The commits added by this push are the difference between these2168 two sets, which can be written21692170 git rev-list \2171 ^other1 ^other2 ... \2172 ^change1.old ^change2.old ... \2173 change1.new change2.new ...21742175 The commits removed by this push can be computed by21762177 git rev-list \2178 ^other1 ^other2 ... \2179 ^change1.new ^change2.new ... \2180 change1.old change2.old ...21812182 The last point is that it is possible that other pushes are2183 occurring simultaneously to this one, so reference values can2184 change at any time. It is impossible to eliminate all race2185 conditions, but we reduce the window of time during which problems2186 can occur by translating reference names to SHA1s as soon as2187 possible and working with SHA1s thereafter (because SHA1s are2188 immutable)."""21892190# A map {(changeclass, changetype) : integer} specifying the order2191# that reference changes will be processed if multiple reference2192# changes are included in a single push. The order is significant2193# mostly because new commit notifications are threaded together2194# with the first reference change that includes the commit. The2195# following order thus causes commits to be grouped with branch2196# changes (as opposed to tag changes) if possible.2197 SORT_ORDER =dict(2198(value, i)for(i, value)inenumerate([2199(BranchChange,'update'),2200(BranchChange,'create'),2201(AnnotatedTagChange,'update'),2202(AnnotatedTagChange,'create'),2203(NonAnnotatedTagChange,'update'),2204(NonAnnotatedTagChange,'create'),2205(BranchChange,'delete'),2206(AnnotatedTagChange,'delete'),2207(NonAnnotatedTagChange,'delete'),2208(OtherReferenceChange,'update'),2209(OtherReferenceChange,'create'),2210(OtherReferenceChange,'delete'),2211])2212)22132214def__init__(self, changes):2215 self.changes =sorted(changes, key=self._sort_key)22162217# The SHA-1s of commits referred to by references unaffected2218# by this push:2219 other_ref_sha1s = self._compute_other_ref_sha1s()22202221 self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(2222 other_ref_sha1s.union(2223 change.old.sha12224for change in self.changes2225if change.old.typein['commit','tag']2226)2227)2228 self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(2229 other_ref_sha1s.union(2230 change.new.sha12231for change in self.changes2232if change.new.typein['commit','tag']2233)2234)22352236@classmethod2237def_sort_key(klass, change):2238return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)22392240def_compute_other_ref_sha1s(self):2241"""Return the GitObjects referred to by references unaffected by this push."""22422243# The refnames being changed by this push:2244 updated_refs =set(2245 change.refname2246for change in self.changes2247)22482249# The SHA-1s of commits referred to by all references in this2250# repository *except* updated_refs:2251 sha1s =set()2252 fmt = (2253'%(objectname) %(objecttype) %(refname)\n'2254'%(*objectname) %(*objecttype)%(refname)'2255)2256for line inread_git_lines(['for-each-ref','--format=%s'% (fmt,)]):2257(sha1,type, name) = line.split(' ',2)2258if sha1 andtype=='commit'and name not in updated_refs:2259 sha1s.add(sha1)22602261return sha1s22622263def_compute_rev_exclusion_spec(self, sha1s):2264"""Return an exclusion specification for 'git rev-list'.22652266 git_objects is an iterable over GitObject instances. Return a2267 string that can be passed to the standard input of 'git2268 rev-list --stdin' to exclude all of the commits referred to by2269 git_objects."""22702271return''.join(2272['^%s\n'% (sha1,)for sha1 insorted(sha1s)]2273)22742275defget_new_commits(self, reference_change=None):2276"""Return a list of commits added by this push.22772278 Return a list of the object names of commits that were added2279 by the part of this push represented by reference_change. If2280 reference_change is None, then return a list of *all* commits2281 added by this push."""22822283if not reference_change:2284 new_revs =sorted(2285 change.new.sha12286for change in self.changes2287if change.new2288)2289elif not reference_change.new.commit_sha1:2290return[]2291else:2292 new_revs = [reference_change.new.commit_sha1]22932294 cmd = ['rev-list','--stdin'] + new_revs2295returnread_git_lines(cmd,input=self._old_rev_exclusion_spec)22962297defget_discarded_commits(self, reference_change):2298"""Return a list of commits discarded by this push.22992300 Return a list of the object names of commits that were2301 entirely discarded from the repository by the part of this2302 push represented by reference_change."""23032304if not reference_change.old.commit_sha1:2305return[]2306else:2307 old_revs = [reference_change.old.commit_sha1]23082309 cmd = ['rev-list','--stdin'] + old_revs2310returnread_git_lines(cmd,input=self._new_rev_exclusion_spec)23112312defsend_emails(self, mailer, body_filter=None):2313"""Use send all of the notification emails needed for this push.23142315 Use send all of the notification emails (including reference2316 change emails and commit emails) needed for this push. Send2317 the emails using mailer. If body_filter is not None, then use2318 it to filter the lines that are intended for the email2319 body."""23202321# The sha1s of commits that were introduced by this push.2322# They will be removed from this set as they are processed, to2323# guarantee that one (and only one) email is generated for2324# each new commit.2325 unhandled_sha1s =set(self.get_new_commits())2326 send_date =IncrementalDateTime()2327for change in self.changes:2328# Check if we've got anyone to send to2329if not change.recipients:2330 sys.stderr.write(2331'*** no recipients configured so no email will be sent\n'2332'*** for%rupdate%s->%s\n'2333% (change.refname, change.old.sha1, change.new.sha1,)2334)2335else:2336 sys.stderr.write('Sending notification emails to:%s\n'% (change.recipients,))2337 extra_values = {'send_date' : send_date.next()}2338 mailer.send(2339 change.generate_email(self, body_filter, extra_values),2340 change.recipients,2341)23422343 sha1s = []2344for sha1 inreversed(list(self.get_new_commits(change))):2345if sha1 in unhandled_sha1s:2346 sha1s.append(sha1)2347 unhandled_sha1s.remove(sha1)23482349 max_emails = change.environment.maxcommitemails2350if max_emails andlen(sha1s) > max_emails:2351 sys.stderr.write(2352'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s)2353+'*** Try setting multimailhook.maxCommitEmails to a greater value\n'2354+'*** Currently, multimailhook.maxCommitEmails=%d\n'% max_emails2355)2356return23572358for(num, sha1)inenumerate(sha1s):2359 rev =Revision(change,GitObject(sha1), num=num+1, tot=len(sha1s))2360if rev.recipients:2361 extra_values = {'send_date' : send_date.next()}2362 mailer.send(2363 rev.generate_email(self, body_filter, extra_values),2364 rev.recipients,2365)23662367# Consistency check:2368if unhandled_sha1s:2369 sys.stderr.write(2370'ERROR: No emails were sent for the following new commits:\n'2371'%s\n'2372% ('\n'.join(sorted(unhandled_sha1s)),)2373)237423752376defrun_as_post_receive_hook(environment, mailer):2377 changes = []2378for line in sys.stdin:2379(oldrev, newrev, refname) = line.strip().split(' ',2)2380 changes.append(2381 ReferenceChange.create(environment, oldrev, newrev, refname)2382)2383 push =Push(changes)2384 push.send_emails(mailer, body_filter=environment.filter_body)238523862387defrun_as_update_hook(environment, mailer, refname, oldrev, newrev):2388 changes = [2389 ReferenceChange.create(2390 environment,2391read_git_output(['rev-parse','--verify', oldrev]),2392read_git_output(['rev-parse','--verify', newrev]),2393 refname,2394),2395]2396 push =Push(changes)2397 push.send_emails(mailer, body_filter=environment.filter_body)239823992400defchoose_mailer(config, environment):2401 mailer = config.get('mailer', default='sendmail')24022403if mailer =='smtp':2404 smtpserver = config.get('smtpserver', default='localhost')2405 mailer =SMTPMailer(2406 envelopesender=(environment.get_sender()or environment.get_fromaddr()),2407 smtpserver=smtpserver,2408)2409elif mailer =='sendmail':2410 command = config.get('sendmailcommand')2411if command:2412 command = shlex.split(command)2413 mailer =SendMailer(command=command, envelopesender=environment.get_sender())2414else:2415 sys.stderr.write(2416'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer2417+'please use one of "smtp" or "sendmail".\n'2418)2419 sys.exit(1)2420return mailer242124222423KNOWN_ENVIRONMENTS = {2424'generic': GenericEnvironmentMixin,2425'gitolite': GitoliteEnvironmentMixin,2426}242724282429defchoose_environment(config, osenv=None, env=None, recipients=None):2430if not osenv:2431 osenv = os.environ24322433 environment_mixins = [2434 ProjectdescEnvironmentMixin,2435 ConfigMaxlinesEnvironmentMixin,2436 ComputeFQDNEnvironmentMixin,2437 ConfigFilterLinesEnvironmentMixin,2438 PusherDomainEnvironmentMixin,2439 ConfigOptionsEnvironmentMixin,2440]2441 environment_kw = {2442'osenv': osenv,2443'config': config,2444}24452446if not env:2447 env = config.get('environment')24482449if not env:2450if'GL_USER'in osenv and'GL_REPO'in osenv:2451 env ='gitolite'2452else:2453 env ='generic'24542455 environment_mixins.append(KNOWN_ENVIRONMENTS[env])24562457if recipients:2458 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)2459 environment_kw['refchange_recipients'] = recipients2460 environment_kw['announce_recipients'] = recipients2461 environment_kw['revision_recipients'] = recipients2462else:2463 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)24642465 environment_klass =type(2466'EffectiveEnvironment',2467tuple(environment_mixins) + (Environment,),2468{},2469)2470returnenvironment_klass(**environment_kw)247124722473defmain(args):2474 parser = optparse.OptionParser(2475 description=__doc__,2476 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',2477)24782479 parser.add_option(2480'--environment','--env', action='store',type='choice',2481 choices=['generic','gitolite'], default=None,2482help=(2483'Choose type of environment is in use. Default is taken from '2484'multimailhook.environment if set; otherwise "generic".'2485),2486)2487 parser.add_option(2488'--stdout', action='store_true', default=False,2489help='Output emails to stdout rather than sending them.',2490)2491 parser.add_option(2492'--recipients', action='store', default=None,2493help='Set list of email recipients for all types of emails.',2494)2495 parser.add_option(2496'--show-env', action='store_true', default=False,2497help=(2498'Write to stderr the values determined for the environment '2499'(intended for debugging purposes).'2500),2501)25022503(options, args) = parser.parse_args(args)25042505 config =Config('multimailhook')25062507try:2508 environment =choose_environment(2509 config, osenv=os.environ,2510 env=options.environment,2511 recipients=options.recipients,2512)25132514if options.show_env:2515 sys.stderr.write('Environment values:\n')2516for(k,v)insorted(environment.get_values().items()):2517 sys.stderr.write('%s:%r\n'% (k,v))2518 sys.stderr.write('\n')25192520if options.stdout:2521 mailer =OutputMailer(sys.stdout)2522else:2523 mailer =choose_mailer(config, environment)25242525# Dual mode: if arguments were specified on the command line, run2526# like an update hook; otherwise, run as a post-receive hook.2527if args:2528iflen(args) !=3:2529 parser.error('Need zero or three non-option arguments')2530(refname, oldrev, newrev) = args2531run_as_update_hook(environment, mailer, refname, oldrev, newrev)2532else:2533run_as_post_receive_hook(environment, mailer)2534except ConfigurationException, e:2535 sys.exit(str(e))253625372538if __name__ =='__main__':2539main(sys.argv[1:])