1#! /usr/bin/env python2 2 3# Copyright (c) 2012,2013 Michael Haggerty 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 subprocess 53import shlex 54import optparse 55import smtplib 56 57try: 58from email.utils import make_msgid 59from email.utils import getaddresses 60from email.utils import formataddr 61from email.header import Header 62exceptImportError: 63# Prior to Python 2.5, the email module used different names: 64from email.Utils import make_msgid 65from email.Utils import getaddresses 66from email.Utils import formataddr 67from email.Header import Header 68 69 70DEBUG =False 71 72ZEROS ='0'*40 73LOGBEGIN ='- Log -----------------------------------------------------------------\n' 74LOGEND ='-----------------------------------------------------------------------\n' 75 76 77# It is assumed in many places that the encoding is uniformly UTF-8, 78# so changing these constants is unsupported. But define them here 79# anyway, to make it easier to find (at least most of) the places 80# where the encoding is important. 81(ENCODING, CHARSET) = ('UTF-8','utf-8') 82 83 84REF_CREATED_SUBJECT_TEMPLATE = ( 85'%(emailprefix)s%(refname_type)s %(short_refname)screated' 86' (now%(newrev_short)s)' 87) 88REF_UPDATED_SUBJECT_TEMPLATE = ( 89'%(emailprefix)s%(refname_type)s %(short_refname)supdated' 90' (%(oldrev_short)s->%(newrev_short)s)' 91) 92REF_DELETED_SUBJECT_TEMPLATE = ( 93'%(emailprefix)s%(refname_type)s %(short_refname)sdeleted' 94' (was%(oldrev_short)s)' 95) 96 97REFCHANGE_HEADER_TEMPLATE ="""\ 98To:%(recipients)s 99Subject:%(subject)s 100MIME-Version: 1.0 101Content-Type: text/plain; charset=%(charset)s 102Content-Transfer-Encoding: 8bit 103Message-ID:%(msgid)s 104From:%(fromaddr)s 105Reply-To:%(reply_to)s 106X-Git-Repo:%(repo_shortname)s 107X-Git-Refname:%(refname)s 108X-Git-Reftype:%(refname_type)s 109X-Git-Oldrev:%(oldrev)s 110X-Git-Newrev:%(newrev)s 111Auto-Submitted: auto-generated 112""" 113 114REFCHANGE_INTRO_TEMPLATE ="""\ 115This is an automated email from the git hooks/post-receive script. 116 117%(pusher)spushed a change to%(refname_type)s %(short_refname)s 118in repository%(repo_shortname)s. 119 120""" 121 122 123FOOTER_TEMPLATE ="""\ 124 125--\n\ 126To stop receiving notification emails like this one, please contact 127%(administrator)s. 128""" 129 130 131REWIND_ONLY_TEMPLATE ="""\ 132This update removed existing revisions from the reference, leaving the 133reference pointing at a previous point in the repository history. 134 135 * -- * -- N%(refname)s(%(newrev_short)s) 136\\ 137 O -- O -- O (%(oldrev_short)s) 138 139Any revisions marked "omits" are not gone; other references still 140refer to them. Any revisions marked "discards" are gone forever. 141""" 142 143 144NON_FF_TEMPLATE ="""\ 145This update added new revisions after undoing existing revisions. 146That is to say, some revisions that were in the old version of the 147%(refname_type)sare not in the new version. This situation occurs 148when a user --force pushes a change and generates a repository 149containing something like this: 150 151 * -- * -- B -- O -- O -- O (%(oldrev_short)s) 152\\ 153 N -- N -- N%(refname)s(%(newrev_short)s) 154 155You should already have received notification emails for all of the O 156revisions, and so the following emails describe only the N revisions 157from the common base, B. 158 159Any revisions marked "omits" are not gone; other references still 160refer to them. Any revisions marked "discards" are gone forever. 161""" 162 163 164NO_NEW_REVISIONS_TEMPLATE ="""\ 165No new revisions were added by this update. 166""" 167 168 169DISCARDED_REVISIONS_TEMPLATE ="""\ 170This change permanently discards the following revisions: 171""" 172 173 174NO_DISCARDED_REVISIONS_TEMPLATE ="""\ 175The revisions that were on this%(refname_type)sare still contained in 176other references; therefore, this change does not discard any commits 177from the repository. 178""" 179 180 181NEW_REVISIONS_TEMPLATE ="""\ 182The%(tot)srevisions listed above as "new" are entirely new to this 183repository and will be described in separate emails. The revisions 184listed as "adds" were already present in the repository and have only 185been added to this reference. 186 187""" 188 189 190TAG_CREATED_TEMPLATE ="""\ 191 at%(newrev_short)-9s (%(newrev_type)s) 192""" 193 194 195TAG_UPDATED_TEMPLATE ="""\ 196*** WARNING: tag%(short_refname)swas modified! *** 197 198 from%(oldrev_short)-9s (%(oldrev_type)s) 199 to%(newrev_short)-9s (%(newrev_type)s) 200""" 201 202 203TAG_DELETED_TEMPLATE ="""\ 204*** WARNING: tag%(short_refname)swas deleted! *** 205 206""" 207 208 209# The template used in summary tables. It looks best if this uses the 210# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. 211BRIEF_SUMMARY_TEMPLATE ="""\ 212%(action)10s%(rev_short)-9s%(text)s 213""" 214 215 216NON_COMMIT_UPDATE_TEMPLATE ="""\ 217This is an unusual reference change because the reference did not 218refer to a commit either before or after the change. We do not know 219how to provide full information about this reference change. 220""" 221 222 223REVISION_HEADER_TEMPLATE ="""\ 224To:%(recipients)s 225Subject:%(emailprefix)s%(num)02d/%(tot)02d:%(oneline)s 226MIME-Version: 1.0 227Content-Type: text/plain; charset=%(charset)s 228Content-Transfer-Encoding: 8bit 229From:%(fromaddr)s 230Reply-To:%(reply_to)s 231In-Reply-To:%(reply_to_msgid)s 232References:%(reply_to_msgid)s 233X-Git-Repo:%(repo_shortname)s 234X-Git-Refname:%(refname)s 235X-Git-Reftype:%(refname_type)s 236X-Git-Rev:%(rev)s 237Auto-Submitted: auto-generated 238""" 239 240REVISION_INTRO_TEMPLATE ="""\ 241This is an automated email from the git hooks/post-receive script. 242 243%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 244in repository%(repo_shortname)s. 245 246""" 247 248 249REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 250 251 252classCommandError(Exception): 253def__init__(self, cmd, retcode): 254 self.cmd = cmd 255 self.retcode = retcode 256Exception.__init__( 257 self, 258'Command "%s" failed with retcode%s'% (' '.join(cmd), retcode,) 259) 260 261 262classConfigurationException(Exception): 263pass 264 265 266defread_git_output(args,input=None, keepends=False, **kw): 267"""Read the output of a Git command.""" 268 269returnread_output( 270['git','-c','i18n.logoutputencoding=%s'% (ENCODING,)] + args, 271input=input, keepends=keepends, **kw 272) 273 274 275defread_output(cmd,input=None, keepends=False, **kw): 276ifinput: 277 stdin = subprocess.PIPE 278else: 279 stdin =None 280 p = subprocess.Popen( 281 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 282) 283(out, err) = p.communicate(input) 284 retcode = p.wait() 285if retcode: 286raiseCommandError(cmd, retcode) 287if not keepends: 288 out = out.rstrip('\n\r') 289return out 290 291 292defread_git_lines(args, keepends=False, **kw): 293"""Return the lines output by Git command. 294 295 Return as single lines, with newlines stripped off.""" 296 297returnread_git_output(args, keepends=True, **kw).splitlines(keepends) 298 299 300classConfig(object): 301def__init__(self, section, git_config=None): 302"""Represent a section of the git configuration. 303 304 If git_config is specified, it is passed to "git config" in 305 the GIT_CONFIG environment variable, meaning that "git config" 306 will read the specified path rather than the Git default 307 config paths.""" 308 309 self.section = section 310if git_config: 311 self.env = os.environ.copy() 312 self.env['GIT_CONFIG'] = git_config 313else: 314 self.env =None 315 316@staticmethod 317def_split(s): 318"""Split NUL-terminated values.""" 319 320 words = s.split('\0') 321assert words[-1] =='' 322return words[:-1] 323 324defget(self, name, default=None): 325try: 326 values = self._split(read_git_output( 327['config','--get','--null','%s.%s'% (self.section, name)], 328 env=self.env, keepends=True, 329)) 330assertlen(values) ==1 331return values[0] 332except CommandError: 333return default 334 335defget_bool(self, name, default=None): 336try: 337 value =read_git_output( 338['config','--get','--bool','%s.%s'% (self.section, name)], 339 env=self.env, 340) 341except CommandError: 342return default 343return value =='true' 344 345defget_all(self, name, default=None): 346"""Read a (possibly multivalued) setting from the configuration. 347 348 Return the result as a list of values, or default if the name 349 is unset.""" 350 351try: 352return self._split(read_git_output( 353['config','--get-all','--null','%s.%s'% (self.section, name)], 354 env=self.env, keepends=True, 355)) 356except CommandError, e: 357if e.retcode ==1: 358# "the section or key is invalid"; i.e., there is no 359# value for the specified key. 360return default 361else: 362raise 363 364defget_recipients(self, name, default=None): 365"""Read a recipients list from the configuration. 366 367 Return the result as a comma-separated list of email 368 addresses, or default if the option is unset. If the setting 369 has multiple values, concatenate them with comma separators.""" 370 371 lines = self.get_all(name, default=None) 372if lines is None: 373return default 374return', '.join(line.strip()for line in lines) 375 376defset(self, name, value): 377read_git_output( 378['config','%s.%s'% (self.section, name), value], 379 env=self.env, 380) 381 382defadd(self, name, value): 383read_git_output( 384['config','--add','%s.%s'% (self.section, name), value], 385 env=self.env, 386) 387 388defhas_key(self, name): 389return self.get_all(name, default=None)is not None 390 391defunset_all(self, name): 392try: 393read_git_output( 394['config','--unset-all','%s.%s'% (self.section, name)], 395 env=self.env, 396) 397except CommandError, e: 398if e.retcode ==5: 399# The name doesn't exist, which is what we wanted anyway... 400pass 401else: 402raise 403 404defset_recipients(self, name, value): 405 self.unset_all(name) 406for pair ingetaddresses([value]): 407 self.add(name,formataddr(pair)) 408 409 410defgenerate_summaries(*log_args): 411"""Generate a brief summary for each revision requested. 412 413 log_args are strings that will be passed directly to "git log" as 414 revision selectors. Iterate over (sha1_short, subject) for each 415 commit specified by log_args (subject is the first line of the 416 commit message as a string without EOLs).""" 417 418 cmd = [ 419'log','--abbrev','--format=%h%s', 420] +list(log_args) + ['--'] 421for line inread_git_lines(cmd): 422yieldtuple(line.split(' ',1)) 423 424 425deflimit_lines(lines, max_lines): 426for(index, line)inenumerate(lines): 427if index < max_lines: 428yield line 429 430if index >= max_lines: 431yield'...%dlines suppressed ...\n'% (index +1- max_lines,) 432 433 434deflimit_linelength(lines, max_linelength): 435for line in lines: 436# Don't forget that lines always include a trailing newline. 437iflen(line) > max_linelength +1: 438 line = line[:max_linelength -7] +' [...]\n' 439yield line 440 441 442classCommitSet(object): 443"""A (constant) set of object names. 444 445 The set should be initialized with full SHA1 object names. The 446 __contains__() method returns True iff its argument is an 447 abbreviation of any the names in the set.""" 448 449def__init__(self, names): 450 self._names =sorted(names) 451 452def__len__(self): 453returnlen(self._names) 454 455def__contains__(self, sha1_abbrev): 456"""Return True iff this set contains sha1_abbrev (which might be abbreviated).""" 457 458 i = bisect.bisect_left(self._names, sha1_abbrev) 459return i <len(self)and self._names[i].startswith(sha1_abbrev) 460 461 462classGitObject(object): 463def__init__(self, sha1,type=None): 464if sha1 == ZEROS: 465 self.sha1 = self.type= self.commit_sha1 =None 466else: 467 self.sha1 = sha1 468 self.type=typeorread_git_output(['cat-file','-t', self.sha1]) 469 470if self.type=='commit': 471 self.commit_sha1 = self.sha1 472elif self.type=='tag': 473try: 474 self.commit_sha1 =read_git_output( 475['rev-parse','--verify','%s^0'% (self.sha1,)] 476) 477except CommandError: 478# Cannot deref tag to determine commit_sha1 479 self.commit_sha1 =None 480else: 481 self.commit_sha1 =None 482 483 self.short =read_git_output(['rev-parse','--short', sha1]) 484 485defget_summary(self): 486"""Return (sha1_short, subject) for this commit.""" 487 488if not self.sha1: 489raiseValueError('Empty commit has no summary') 490 491returniter(generate_summaries('--no-walk', self.sha1)).next() 492 493def__eq__(self, other): 494returnisinstance(other, GitObject)and self.sha1 == other.sha1 495 496def__hash__(self): 497returnhash(self.sha1) 498 499def__nonzero__(self): 500returnbool(self.sha1) 501 502def__str__(self): 503return self.sha1 or ZEROS 504 505 506classChange(object): 507"""A Change that has been made to the Git repository. 508 509 Abstract class from which both Revisions and ReferenceChanges are 510 derived. A Change knows how to generate a notification email 511 describing itself.""" 512 513def__init__(self, environment): 514 self.environment = environment 515 self._values =None 516 517def_compute_values(self): 518"""Return a dictionary{keyword : expansion}for this Change. 519 520 Derived classes overload this method to add more entries to 521 the return value. This method is used internally by 522 get_values(). The return value should always be a new 523 dictionary.""" 524 525return self.environment.get_values() 526 527defget_values(self, **extra_values): 528"""Return a dictionary{keyword : expansion}for this Change. 529 530 Return a dictionary mapping keywords to the values that they 531 should be expanded to for this Change (used when interpolating 532 template strings). If any keyword arguments are supplied, add 533 those to the return value as well. The return value is always 534 a new dictionary.""" 535 536if self._values is None: 537 self._values = self._compute_values() 538 539 values = self._values.copy() 540if extra_values: 541 values.update(extra_values) 542return values 543 544defexpand(self, template, **extra_values): 545"""Expand template. 546 547 Expand the template (which should be a string) using string 548 interpolation of the values for this Change. If any keyword 549 arguments are provided, also include those in the keywords 550 available for interpolation.""" 551 552return template % self.get_values(**extra_values) 553 554defexpand_lines(self, template, **extra_values): 555"""Break template into lines and expand each line.""" 556 557 values = self.get_values(**extra_values) 558for line in template.splitlines(True): 559yield line % values 560 561defexpand_header_lines(self, template, **extra_values): 562"""Break template into lines and expand each line as an RFC 2822 header. 563 564 Encode values and split up lines that are too long. Silently 565 skip lines that contain references to unknown variables.""" 566 567 values = self.get_values(**extra_values) 568for line in template.splitlines(): 569(name, value) = line.split(':',1) 570 571try: 572 value = value % values 573exceptKeyError, e: 574if DEBUG: 575 sys.stderr.write( 576'Warning: unknown variable%rin the following line; line skipped:\n' 577'%s\n' 578% (e.args[0], line,) 579) 580else: 581try: 582 h =Header(value, header_name=name) 583exceptUnicodeDecodeError: 584 h =Header(value, header_name=name, charset=CHARSET, errors='replace') 585for splitline in('%s:%s\n'% (name, h.encode(),)).splitlines(True): 586yield splitline 587 588defgenerate_email_header(self): 589"""Generate the RFC 2822 email headers for this Change, a line at a time. 590 591 The output should not include the trailing blank line.""" 592 593raiseNotImplementedError() 594 595defgenerate_email_intro(self): 596"""Generate the email intro for this Change, a line at a time. 597 598 The output will be used as the standard boilerplate at the top 599 of the email body.""" 600 601raiseNotImplementedError() 602 603defgenerate_email_body(self): 604"""Generate the main part of the email body, a line at a time. 605 606 The text in the body might be truncated after a specified 607 number of lines (see multimailhook.emailmaxlines).""" 608 609raiseNotImplementedError() 610 611defgenerate_email_footer(self): 612"""Generate the footer of the email, a line at a time. 613 614 The footer is always included, irrespective of 615 multimailhook.emailmaxlines.""" 616 617raiseNotImplementedError() 618 619defgenerate_email(self, push, body_filter=None): 620"""Generate an email describing this change. 621 622 Iterate over the lines (including the header lines) of an 623 email describing this change. If body_filter is not None, 624 then use it to filter the lines that are intended for the 625 email body.""" 626 627for line in self.generate_email_header(): 628yield line 629yield'\n' 630for line in self.generate_email_intro(): 631yield line 632 633 body = self.generate_email_body(push) 634if body_filter is not None: 635 body =body_filter(body) 636for line in body: 637yield line 638 639for line in self.generate_email_footer(): 640yield line 641 642 643classRevision(Change): 644"""A Change consisting of a single git commit.""" 645 646def__init__(self, reference_change, rev, num, tot): 647 Change.__init__(self, reference_change.environment) 648 self.reference_change = reference_change 649 self.rev = rev 650 self.change_type = self.reference_change.change_type 651 self.refname = self.reference_change.refname 652 self.num = num 653 self.tot = tot 654 self.author =read_git_output(['log','--no-walk','--format=%aN <%aE>', self.rev.sha1]) 655 self.recipients = self.environment.get_revision_recipients(self) 656 657def_compute_values(self): 658 values = Change._compute_values(self) 659 660 oneline =read_git_output( 661['log','--format=%s','--no-walk', self.rev.sha1] 662) 663 664 values['rev'] = self.rev.sha1 665 values['rev_short'] = self.rev.short 666 values['change_type'] = self.change_type 667 values['refname'] = self.refname 668 values['short_refname'] = self.reference_change.short_refname 669 values['refname_type'] = self.reference_change.refname_type 670 values['reply_to_msgid'] = self.reference_change.msgid 671 values['num'] = self.num 672 values['tot'] = self.tot 673 values['recipients'] = self.recipients 674 values['oneline'] = oneline 675 values['author'] = self.author 676 677 reply_to = self.environment.get_reply_to_commit(self) 678if reply_to: 679 values['reply_to'] = reply_to 680 681return values 682 683defgenerate_email_header(self): 684for line in self.expand_header_lines(REVISION_HEADER_TEMPLATE): 685yield line 686 687defgenerate_email_intro(self): 688for line in self.expand_lines(REVISION_INTRO_TEMPLATE): 689yield line 690 691defgenerate_email_body(self, push): 692"""Show this revision.""" 693 694returnread_git_lines( 695[ 696'log','-C', 697'--stat','-p','--cc', 698'-1', self.rev.sha1, 699], 700 keepends=True, 701) 702 703defgenerate_email_footer(self): 704return self.expand_lines(REVISION_FOOTER_TEMPLATE) 705 706 707classReferenceChange(Change): 708"""A Change to a Git reference. 709 710 An abstract class representing a create, update, or delete of a 711 Git reference. Derived classes handle specific types of reference 712 (e.g., tags vs. branches). These classes generate the main 713 reference change email summarizing the reference change and 714 whether it caused any any commits to be added or removed. 715 716 ReferenceChange objects are usually created using the static 717 create() method, which has the logic to decide which derived class 718 to instantiate.""" 719 720 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$') 721 722@staticmethod 723defcreate(environment, oldrev, newrev, refname): 724"""Return a ReferenceChange object representing the change. 725 726 Return an object that represents the type of change that is being 727 made. oldrev and newrev should be SHA1s or ZEROS.""" 728 729 old =GitObject(oldrev) 730 new =GitObject(newrev) 731 rev = new or old 732 733# The revision type tells us what type the commit is, combined with 734# the location of the ref we can decide between 735# - working branch 736# - tracking branch 737# - unannotated tag 738# - annotated tag 739 m = ReferenceChange.REF_RE.match(refname) 740if m: 741 area = m.group('area') 742 short_refname = m.group('shortname') 743else: 744 area ='' 745 short_refname = refname 746 747if rev.type=='tag': 748# Annotated tag: 749 klass = AnnotatedTagChange 750elif rev.type=='commit': 751if area =='tags': 752# Non-annotated tag: 753 klass = NonAnnotatedTagChange 754elif area =='heads': 755# Branch: 756 klass = BranchChange 757elif area =='remotes': 758# Tracking branch: 759 sys.stderr.write( 760'*** Push-update of tracking branch%r\n' 761'*** - incomplete email generated.\n' 762% (refname,) 763) 764 klass = OtherReferenceChange 765else: 766# Some other reference namespace: 767 sys.stderr.write( 768'*** Push-update of strange reference%r\n' 769'*** - incomplete email generated.\n' 770% (refname,) 771) 772 klass = OtherReferenceChange 773else: 774# Anything else (is there anything else?) 775 sys.stderr.write( 776'*** Unknown type of update to%r(%s)\n' 777'*** - incomplete email generated.\n' 778% (refname, rev.type,) 779) 780 klass = OtherReferenceChange 781 782returnklass( 783 environment, 784 refname=refname, short_refname=short_refname, 785 old=old, new=new, rev=rev, 786) 787 788def__init__(self, environment, refname, short_refname, old, new, rev): 789 Change.__init__(self, environment) 790 self.change_type = { 791(False,True) :'create', 792(True,True) :'update', 793(True,False) :'delete', 794}[bool(old),bool(new)] 795 self.refname = refname 796 self.short_refname = short_refname 797 self.old = old 798 self.new = new 799 self.rev = rev 800 self.msgid =make_msgid() 801 self.diffopts = environment.diffopts 802 self.logopts = environment.logopts 803 self.showlog = environment.refchange_showlog 804 805def_compute_values(self): 806 values = Change._compute_values(self) 807 808 values['change_type'] = self.change_type 809 values['refname_type'] = self.refname_type 810 values['refname'] = self.refname 811 values['short_refname'] = self.short_refname 812 values['msgid'] = self.msgid 813 values['recipients'] = self.recipients 814 values['oldrev'] =str(self.old) 815 values['oldrev_short'] = self.old.short 816 values['newrev'] =str(self.new) 817 values['newrev_short'] = self.new.short 818 819if self.old: 820 values['oldrev_type'] = self.old.type 821if self.new: 822 values['newrev_type'] = self.new.type 823 824 reply_to = self.environment.get_reply_to_refchange(self) 825if reply_to: 826 values['reply_to'] = reply_to 827 828return values 829 830defget_subject(self): 831 template = { 832'create': REF_CREATED_SUBJECT_TEMPLATE, 833'update': REF_UPDATED_SUBJECT_TEMPLATE, 834'delete': REF_DELETED_SUBJECT_TEMPLATE, 835}[self.change_type] 836return self.expand(template) 837 838defgenerate_email_header(self): 839for line in self.expand_header_lines( 840 REFCHANGE_HEADER_TEMPLATE, subject=self.get_subject(), 841): 842yield line 843 844defgenerate_email_intro(self): 845for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE): 846yield line 847 848defgenerate_email_body(self, push): 849"""Call the appropriate body-generation routine. 850 851 Call one of generate_create_summary() / 852 generate_update_summary() / generate_delete_summary().""" 853 854 change_summary = { 855'create': self.generate_create_summary, 856'delete': self.generate_delete_summary, 857'update': self.generate_update_summary, 858}[self.change_type](push) 859for line in change_summary: 860yield line 861 862for line in self.generate_revision_change_summary(push): 863yield line 864 865defgenerate_email_footer(self): 866return self.expand_lines(FOOTER_TEMPLATE) 867 868defgenerate_revision_change_log(self, new_commits_list): 869if self.showlog: 870yield'\n' 871yield'Detailed log of new commits:\n\n' 872for line inread_git_lines( 873['log','--no-walk'] 874+ self.logopts 875+ new_commits_list 876+ ['--'], 877 keepends=True, 878): 879yield line 880 881defgenerate_revision_change_summary(self, push): 882"""Generate a summary of the revisions added/removed by this change.""" 883 884if self.new.commit_sha1 and not self.old.commit_sha1: 885# A new reference was created. List the new revisions 886# brought by the new reference (i.e., those revisions that 887# were not in the repository before this reference 888# change). 889 sha1s =list(push.get_new_commits(self)) 890 sha1s.reverse() 891 tot =len(sha1s) 892 new_revisions = [ 893Revision(self,GitObject(sha1), num=i+1, tot=tot) 894for(i, sha1)inenumerate(sha1s) 895] 896 897if new_revisions: 898yield self.expand('This%(refname_type)sincludes the following new commits:\n') 899yield'\n' 900for r in new_revisions: 901(sha1, subject) = r.rev.get_summary() 902yield r.expand( 903 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject, 904) 905yield'\n' 906for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): 907yield line 908for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]): 909yield line 910else: 911for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): 912yield line 913 914elif self.new.commit_sha1 and self.old.commit_sha1: 915# A reference was changed to point at a different commit. 916# List the revisions that were removed and/or added *from 917# that reference* by this reference change, along with a 918# diff between the trees for its old and new values. 919 920# List of the revisions that were added to the branch by 921# this update. Note this list can include revisions that 922# have already had notification emails; we want such 923# revisions in the summary even though we will not send 924# new notification emails for them. 925 adds =list(generate_summaries( 926'--topo-order','--reverse','%s..%s' 927% (self.old.commit_sha1, self.new.commit_sha1,) 928)) 929 930# List of the revisions that were removed from the branch 931# by this update. This will be empty except for 932# non-fast-forward updates. 933 discards =list(generate_summaries( 934'%s..%s'% (self.new.commit_sha1, self.old.commit_sha1,) 935)) 936 937if adds: 938 new_commits_list = push.get_new_commits(self) 939else: 940 new_commits_list = [] 941 new_commits =CommitSet(new_commits_list) 942 943if discards: 944 discarded_commits =CommitSet(push.get_discarded_commits(self)) 945else: 946 discarded_commits =CommitSet([]) 947 948if discards and adds: 949for(sha1, subject)in discards: 950if sha1 in discarded_commits: 951 action ='discards' 952else: 953 action ='omits' 954yield self.expand( 955 BRIEF_SUMMARY_TEMPLATE, action=action, 956 rev_short=sha1, text=subject, 957) 958for(sha1, subject)in adds: 959if sha1 in new_commits: 960 action ='new' 961else: 962 action ='adds' 963yield self.expand( 964 BRIEF_SUMMARY_TEMPLATE, action=action, 965 rev_short=sha1, text=subject, 966) 967yield'\n' 968for line in self.expand_lines(NON_FF_TEMPLATE): 969yield line 970 971elif discards: 972for(sha1, subject)in discards: 973if sha1 in discarded_commits: 974 action ='discards' 975else: 976 action ='omits' 977yield self.expand( 978 BRIEF_SUMMARY_TEMPLATE, action=action, 979 rev_short=sha1, text=subject, 980) 981yield'\n' 982for line in self.expand_lines(REWIND_ONLY_TEMPLATE): 983yield line 984 985elif adds: 986(sha1, subject) = self.old.get_summary() 987yield self.expand( 988 BRIEF_SUMMARY_TEMPLATE, action='from', 989 rev_short=sha1, text=subject, 990) 991for(sha1, subject)in adds: 992if sha1 in new_commits: 993 action ='new' 994else: 995 action ='adds' 996yield self.expand( 997 BRIEF_SUMMARY_TEMPLATE, action=action, 998 rev_short=sha1, text=subject, 999)10001001yield'\n'10021003if new_commits:1004for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):1005yield line1006for line in self.generate_revision_change_log(new_commits_list):1007yield line1008else:1009for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1010yield line10111012# The diffstat is shown from the old revision to the new1013# revision. This is to show the truth of what happened in1014# this change. There's no point showing the stat from the1015# base to the new revision because the base is effectively a1016# random revision at this point - the user will be interested1017# in what this revision changed - including the undoing of1018# previous revisions in the case of non-fast-forward updates.1019yield'\n'1020yield'Summary of changes:\n'1021for line inread_git_lines(1022['diff-tree']1023+ self.diffopts1024+ ['%s..%s'% (self.old.commit_sha1, self.new.commit_sha1,)],1025 keepends=True,1026):1027yield line10281029elif self.old.commit_sha1 and not self.new.commit_sha1:1030# A reference was deleted. List the revisions that were1031# removed from the repository by this reference change.10321033 sha1s =list(push.get_discarded_commits(self))1034 tot =len(sha1s)1035 discarded_revisions = [1036Revision(self,GitObject(sha1), num=i+1, tot=tot)1037for(i, sha1)inenumerate(sha1s)1038]10391040if discarded_revisions:1041for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):1042yield line1043yield'\n'1044for r in discarded_revisions:1045(sha1, subject) = r.rev.get_summary()1046yield r.expand(1047 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,1048)1049else:1050for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):1051yield line10521053elif not self.old.commit_sha1 and not self.new.commit_sha1:1054for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):1055yield line10561057defgenerate_create_summary(self, push):1058"""Called for the creation of a reference."""10591060# This is a new reference and so oldrev is not valid1061(sha1, subject) = self.new.get_summary()1062yield self.expand(1063 BRIEF_SUMMARY_TEMPLATE, action='at',1064 rev_short=sha1, text=subject,1065)1066yield'\n'10671068defgenerate_update_summary(self, push):1069"""Called for the change of a pre-existing branch."""10701071returniter([])10721073defgenerate_delete_summary(self, push):1074"""Called for the deletion of any type of reference."""10751076(sha1, subject) = self.old.get_summary()1077yield self.expand(1078 BRIEF_SUMMARY_TEMPLATE, action='was',1079 rev_short=sha1, text=subject,1080)1081yield'\n'108210831084classBranchChange(ReferenceChange):1085 refname_type ='branch'10861087def__init__(self, environment, refname, short_refname, old, new, rev):1088 ReferenceChange.__init__(1089 self, environment,1090 refname=refname, short_refname=short_refname,1091 old=old, new=new, rev=rev,1092)1093 self.recipients = environment.get_refchange_recipients(self)109410951096classAnnotatedTagChange(ReferenceChange):1097 refname_type ='annotated tag'10981099def__init__(self, environment, refname, short_refname, old, new, rev):1100 ReferenceChange.__init__(1101 self, environment,1102 refname=refname, short_refname=short_refname,1103 old=old, new=new, rev=rev,1104)1105 self.recipients = environment.get_announce_recipients(self)1106 self.show_shortlog = environment.announce_show_shortlog11071108 ANNOTATED_TAG_FORMAT = (1109'%(*objectname)\n'1110'%(*objecttype)\n'1111'%(taggername)\n'1112'%(taggerdate)'1113)11141115defdescribe_tag(self, push):1116"""Describe the new value of an annotated tag."""11171118# Use git for-each-ref to pull out the individual fields from1119# the tag1120[tagobject, tagtype, tagger, tagged] =read_git_lines(1121['for-each-ref','--format=%s'% (self.ANNOTATED_TAG_FORMAT,), self.refname],1122)11231124yield self.expand(1125 BRIEF_SUMMARY_TEMPLATE, action='tagging',1126 rev_short=tagobject, text='(%s)'% (tagtype,),1127)1128if tagtype =='commit':1129# If the tagged object is a commit, then we assume this is a1130# release, and so we calculate which tag this tag is1131# replacing1132try:1133 prevtag =read_git_output(['describe','--abbrev=0','%s^'% (self.new,)])1134except CommandError:1135 prevtag =None1136if prevtag:1137yield' replaces%s\n'% (prevtag,)1138else:1139 prevtag =None1140yield' length%sbytes\n'% (read_git_output(['cat-file','-s', tagobject]),)11411142yield' tagged by%s\n'% (tagger,)1143yield' on%s\n'% (tagged,)1144yield'\n'11451146# Show the content of the tag message; this might contain a1147# change log or release notes so is worth displaying.1148yield LOGBEGIN1149 contents =list(read_git_lines(['cat-file','tag', self.new.sha1], keepends=True))1150 contents = contents[contents.index('\n') +1:]1151if contents and contents[-1][-1:] !='\n':1152 contents.append('\n')1153for line in contents:1154yield line11551156if self.show_shortlog and tagtype =='commit':1157# Only commit tags make sense to have rev-list operations1158# performed on them1159yield'\n'1160if prevtag:1161# Show changes since the previous release1162 revlist =read_git_output(1163['rev-list','--pretty=short','%s..%s'% (prevtag, self.new,)],1164 keepends=True,1165)1166else:1167# No previous tag, show all the changes since time1168# began1169 revlist =read_git_output(1170['rev-list','--pretty=short','%s'% (self.new,)],1171 keepends=True,1172)1173for line inread_git_lines(['shortlog'],input=revlist, keepends=True):1174yield line11751176yield LOGEND1177yield'\n'11781179defgenerate_create_summary(self, push):1180"""Called for the creation of an annotated tag."""11811182for line in self.expand_lines(TAG_CREATED_TEMPLATE):1183yield line11841185for line in self.describe_tag(push):1186yield line11871188defgenerate_update_summary(self, push):1189"""Called for the update of an annotated tag.11901191 This is probably a rare event and may not even be allowed."""11921193for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1194yield line11951196for line in self.describe_tag(push):1197yield line11981199defgenerate_delete_summary(self, push):1200"""Called when a non-annotated reference is updated."""12011202for line in self.expand_lines(TAG_DELETED_TEMPLATE):1203yield line12041205yield self.expand(' tag was%(oldrev_short)s\n')1206yield'\n'120712081209classNonAnnotatedTagChange(ReferenceChange):1210 refname_type ='tag'12111212def__init__(self, environment, refname, short_refname, old, new, rev):1213 ReferenceChange.__init__(1214 self, environment,1215 refname=refname, short_refname=short_refname,1216 old=old, new=new, rev=rev,1217)1218 self.recipients = environment.get_refchange_recipients(self)12191220defgenerate_create_summary(self, push):1221"""Called for the creation of an annotated tag."""12221223for line in self.expand_lines(TAG_CREATED_TEMPLATE):1224yield line12251226defgenerate_update_summary(self, push):1227"""Called when a non-annotated reference is updated."""12281229for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1230yield line12311232defgenerate_delete_summary(self, push):1233"""Called when a non-annotated reference is updated."""12341235for line in self.expand_lines(TAG_DELETED_TEMPLATE):1236yield line12371238for line in ReferenceChange.generate_delete_summary(self, push):1239yield line124012411242classOtherReferenceChange(ReferenceChange):1243 refname_type ='reference'12441245def__init__(self, environment, refname, short_refname, old, new, rev):1246# We use the full refname as short_refname, because otherwise1247# the full name of the reference would not be obvious from the1248# text of the email.1249 ReferenceChange.__init__(1250 self, environment,1251 refname=refname, short_refname=refname,1252 old=old, new=new, rev=rev,1253)1254 self.recipients = environment.get_refchange_recipients(self)125512561257classMailer(object):1258"""An object that can send emails."""12591260defsend(self, lines, to_addrs):1261"""Send an email consisting of lines.12621263 lines must be an iterable over the lines constituting the1264 header and body of the email. to_addrs is a list of recipient1265 addresses (can be needed even if lines already contains a1266 "To:" field). It can be either a string (comma-separated list1267 of email addresses) or a Python list of individual email1268 addresses.12691270 """12711272raiseNotImplementedError()127312741275classSendMailer(Mailer):1276"""Send emails using 'sendmail -t'."""12771278 SENDMAIL_CANDIDATES = [1279'/usr/sbin/sendmail',1280'/usr/lib/sendmail',1281]12821283@staticmethod1284deffind_sendmail():1285for path in SendMailer.SENDMAIL_CANDIDATES:1286if os.access(path, os.X_OK):1287return path1288else:1289raiseConfigurationException(1290'No sendmail executable found. '1291'Try setting multimailhook.sendmailCommand.'1292)12931294def__init__(self, command=None, envelopesender=None):1295"""Construct a SendMailer instance.12961297 command should be the command and arguments used to invoke1298 sendmail, as a list of strings. If an envelopesender is1299 provided, it will also be passed to the command, via '-f1300 envelopesender'."""13011302if command:1303 self.command = command[:]1304else:1305 self.command = [self.find_sendmail(),'-t']13061307if envelopesender:1308 self.command.extend(['-f', envelopesender])13091310defsend(self, lines, to_addrs):1311try:1312 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)1313exceptOSError, e:1314 sys.stderr.write(1315'*** Cannot execute command:%s\n'%' '.join(self.command)1316+'***%s\n'%str(e)1317+'*** Try setting multimailhook.mailer to "smtp"\n'1318'*** to send emails without using the sendmail command.\n'1319)1320 sys.exit(1)1321try:1322 p.stdin.writelines(lines)1323except:1324 sys.stderr.write(1325'*** Error while generating commit email\n'1326'*** - mail sending aborted.\n'1327)1328 p.terminate()1329raise1330else:1331 p.stdin.close()1332 retcode = p.wait()1333if retcode:1334raiseCommandError(self.command, retcode)133513361337classSMTPMailer(Mailer):1338"""Send emails using Python's smtplib."""13391340def__init__(self, envelopesender, smtpserver):1341if not envelopesender:1342 sys.stderr.write(1343'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'1344'please set either multimailhook.envelopeSender or user.email\n'1345)1346 sys.exit(1)1347 self.envelopesender = envelopesender1348 self.smtpserver = smtpserver1349try:1350 self.smtp = smtplib.SMTP(self.smtpserver)1351exceptException, e:1352 sys.stderr.write('*** Error establishing SMTP connection to%s***\n'% self.smtpserver)1353 sys.stderr.write('***%s\n'%str(e))1354 sys.exit(1)13551356def__del__(self):1357 self.smtp.quit()13581359defsend(self, lines, to_addrs):1360try:1361 msg =''.join(lines)1362# turn comma-separated list into Python list if needed.1363ifisinstance(to_addrs, basestring):1364 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]1365 self.smtp.sendmail(self.envelopesender, to_addrs, msg)1366exceptException, e:1367 sys.stderr.write('*** Error sending email***\n')1368 sys.stderr.write('***%s\n'%str(e))1369 self.smtp.quit()1370 sys.exit(1)137113721373classOutputMailer(Mailer):1374"""Write emails to an output stream, bracketed by lines of '=' characters.13751376 This is intended for debugging purposes."""13771378 SEPARATOR ='='*75+'\n'13791380def__init__(self, f):1381 self.f = f13821383defsend(self, lines, to_addrs):1384 self.f.write(self.SEPARATOR)1385 self.f.writelines(lines)1386 self.f.write(self.SEPARATOR)138713881389defget_git_dir():1390"""Determine GIT_DIR.13911392 Determine GIT_DIR either from the GIT_DIR environment variable or1393 from the working directory, using Git's usual rules."""13941395try:1396returnread_git_output(['rev-parse','--git-dir'])1397except CommandError:1398 sys.stderr.write('fatal: git_multimail: not in a git directory\n')1399 sys.exit(1)140014011402classEnvironment(object):1403"""Describes the environment in which the push is occurring.14041405 An Environment object encapsulates information about the local1406 environment. For example, it knows how to determine:14071408 * the name of the repository to which the push occurred14091410 * what user did the push14111412 * what users want to be informed about various types of changes.14131414 An Environment object is expected to have the following methods:14151416 get_repo_shortname()14171418 Return a short name for the repository, for display1419 purposes.14201421 get_repo_path()14221423 Return the absolute path to the Git repository.14241425 get_emailprefix()14261427 Return a string that will be prefixed to every email's1428 subject.14291430 get_pusher()14311432 Return the username of the person who pushed the changes.1433 This value is used in the email body to indicate who1434 pushed the change.14351436 get_pusher_email() (may return None)14371438 Return the email address of the person who pushed the1439 changes. The value should be a single RFC 2822 email1440 address as a string; e.g., "Joe User <user@example.com>"1441 if available, otherwise "user@example.com". If set, the1442 value is used as the Reply-To address for refchange1443 emails. If it is impossible to determine the pusher's1444 email, this attribute should be set to None (in which case1445 no Reply-To header will be output).14461447 get_sender()14481449 Return the address to be used as the 'From' email address1450 in the email envelope.14511452 get_fromaddr()14531454 Return the 'From' email address used in the email 'From:'1455 headers. (May be a full RFC 2822 email address like 'Joe1456 User <user@example.com>'.)14571458 get_administrator()14591460 Return the name and/or email of the repository1461 administrator. This value is used in the footer as the1462 person to whom requests to be removed from the1463 notification list should be sent. Ideally, it should1464 include a valid email address.14651466 get_reply_to_refchange()1467 get_reply_to_commit()14681469 Return the address to use in the email "Reply-To" header,1470 as a string. These can be an RFC 2822 email address, or1471 None to omit the "Reply-To" header.1472 get_reply_to_refchange() is used for refchange emails;1473 get_reply_to_commit() is used for individual commit1474 emails.14751476 They should also define the following attributes:14771478 announce_show_shortlog (bool)14791480 True iff announce emails should include a shortlog.14811482 refchange_showlog (bool)14831484 True iff refchanges emails should include a detailed log.14851486 diffopts (list of strings)14871488 The options that should be passed to 'git diff' for the1489 summary email. The value should be a list of strings1490 representing words to be passed to the command.14911492 logopts (list of strings)14931494 Analogous to diffopts, but contains options passed to1495 'git log' when generating the detailed log for a set of1496 commits (see refchange_showlog)14971498 """14991500 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')15011502def__init__(self, osenv=None):1503 self.osenv = osenv or os.environ1504 self.announce_show_shortlog =False1505 self.maxcommitemails =5001506 self.diffopts = ['--stat','--summary','--find-copies-harder']1507 self.logopts = []1508 self.refchange_showlog =False15091510 self.COMPUTED_KEYS = [1511'administrator',1512'charset',1513'emailprefix',1514'fromaddr',1515'pusher',1516'pusher_email',1517'repo_path',1518'repo_shortname',1519'sender',1520]15211522 self._values =None15231524defget_repo_shortname(self):1525"""Use the last part of the repo path, with ".git" stripped off if present."""15261527 basename = os.path.basename(os.path.abspath(self.get_repo_path()))1528 m = self.REPO_NAME_RE.match(basename)1529if m:1530return m.group('name')1531else:1532return basename15331534defget_pusher(self):1535raiseNotImplementedError()15361537defget_pusher_email(self):1538return None15391540defget_administrator(self):1541return'the administrator of this repository'15421543defget_emailprefix(self):1544return''15451546defget_repo_path(self):1547ifread_git_output(['rev-parse','--is-bare-repository']) =='true':1548 path =get_git_dir()1549else:1550 path =read_git_output(['rev-parse','--show-toplevel'])1551return os.path.abspath(path)15521553defget_charset(self):1554return CHARSET15551556defget_values(self):1557"""Return a dictionary{keyword : expansion}for this Environment.15581559 This method is called by Change._compute_values(). The keys1560 in the returned dictionary are available to be used in any of1561 the templates. The dictionary is created by calling1562 self.get_NAME() for each of the attributes named in1563 COMPUTED_KEYS and recording those that do not return None.1564 The return value is always a new dictionary."""15651566if self._values is None:1567 values = {}15681569for key in self.COMPUTED_KEYS:1570 value =getattr(self,'get_%s'% (key,))()1571if value is not None:1572 values[key] = value15731574 self._values = values15751576return self._values.copy()15771578defget_refchange_recipients(self, refchange):1579"""Return the recipients for notifications about refchange.15801581 Return the list of email addresses to which notifications1582 about the specified ReferenceChange should be sent."""15831584raiseNotImplementedError()15851586defget_announce_recipients(self, annotated_tag_change):1587"""Return the recipients for notifications about annotated_tag_change.15881589 Return the list of email addresses to which notifications1590 about the specified AnnotatedTagChange should be sent."""15911592raiseNotImplementedError()15931594defget_reply_to_refchange(self, refchange):1595return self.get_pusher_email()15961597defget_revision_recipients(self, revision):1598"""Return the recipients for messages about revision.15991600 Return the list of email addresses to which notifications1601 about the specified Revision should be sent. This method1602 could be overridden, for example, to take into account the1603 contents of the revision when deciding whom to notify about1604 it. For example, there could be a scheme for users to express1605 interest in particular files or subdirectories, and only1606 receive notification emails for revisions that affecting those1607 files."""16081609raiseNotImplementedError()16101611defget_reply_to_commit(self, revision):1612return revision.author16131614deffilter_body(self, lines):1615"""Filter the lines intended for an email body.16161617 lines is an iterable over the lines that would go into the1618 email body. Filter it (e.g., limit the number of lines, the1619 line length, character set, etc.), returning another iterable.1620 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin1621 for classes implementing this functionality."""16221623return lines162416251626classConfigEnvironmentMixin(Environment):1627"""A mixin that sets self.config to its constructor's config argument.16281629 This class's constructor consumes the "config" argument.16301631 Mixins that need to inspect the config should inherit from this1632 class (1) to make sure that "config" is still in the constructor1633 arguments with its own constructor runs and/or (2) to be sure that1634 self.config is set after construction."""16351636def__init__(self, config, **kw):1637super(ConfigEnvironmentMixin, self).__init__(**kw)1638 self.config = config163916401641classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):1642"""An Environment that reads most of its information from "git config"."""16431644def__init__(self, config, **kw):1645super(ConfigOptionsEnvironmentMixin, self).__init__(1646 config=config, **kw1647)16481649 self.announce_show_shortlog = config.get_bool(1650'announceshortlog', default=self.announce_show_shortlog1651)16521653 self.refchange_showlog = config.get_bool(1654'refchangeshowlog', default=self.refchange_showlog1655)16561657 maxcommitemails = config.get('maxcommitemails')1658if maxcommitemails is not None:1659try:1660 self.maxcommitemails =int(maxcommitemails)1661exceptValueError:1662 sys.stderr.write(1663'*** Malformed value for multimailhook.maxCommitEmails:%s\n'% maxcommitemails1664+'*** Expected a number. Ignoring.\n'1665)16661667 diffopts = config.get('diffopts')1668if diffopts is not None:1669 self.diffopts = shlex.split(diffopts)16701671 logopts = config.get('logopts')1672if logopts is not None:1673 self.logopts = shlex.split(logopts)16741675 reply_to = config.get('replyTo')1676 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)1677if(1678 self.__reply_to_refchange is not None1679and self.__reply_to_refchange.lower() =='author'1680):1681raiseConfigurationException(1682'"author" is not an allowed setting for replyToRefchange'1683)1684 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)16851686defget_administrator(self):1687return(1688 self.config.get('administrator')1689or self.get_sender()1690orsuper(ConfigOptionsEnvironmentMixin, self).get_administrator()1691)16921693defget_repo_shortname(self):1694return(1695 self.config.get('reponame')1696orsuper(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()1697)16981699defget_emailprefix(self):1700 emailprefix = self.config.get('emailprefix')1701if emailprefix and emailprefix.strip():1702return emailprefix.strip() +' '1703else:1704return'[%s] '% (self.get_repo_shortname(),)17051706defget_sender(self):1707return self.config.get('envelopesender')17081709defget_fromaddr(self):1710 fromaddr = self.config.get('from')1711if fromaddr:1712return fromaddr1713else:1714 config =Config('user')1715 fromname = config.get('name', default='')1716 fromemail = config.get('email', default='')1717if fromemail:1718returnformataddr([fromname, fromemail])1719else:1720return self.get_sender()17211722defget_reply_to_refchange(self, refchange):1723if self.__reply_to_refchange is None:1724returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)1725elif self.__reply_to_refchange.lower() =='pusher':1726return self.get_pusher_email()1727elif self.__reply_to_refchange.lower() =='none':1728return None1729else:1730return self.__reply_to_refchange17311732defget_reply_to_commit(self, revision):1733if self.__reply_to_commit is None:1734returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)1735elif self.__reply_to_commit.lower() =='author':1736return revision.get_author()1737elif self.__reply_to_commit.lower() =='pusher':1738return self.get_pusher_email()1739elif self.__reply_to_commit.lower() =='none':1740return None1741else:1742return self.__reply_to_commit174317441745classFilterLinesEnvironmentMixin(Environment):1746"""Handle encoding and maximum line length of body lines.17471748 emailmaxlinelength (int or None)17491750 The maximum length of any single line in the email body.1751 Longer lines are truncated at that length with ' [...]'1752 appended.17531754 strict_utf8 (bool)17551756 If this field is set to True, then the email body text is1757 expected to be UTF-8. Any invalid characters are1758 converted to U+FFFD, the Unicode replacement character1759 (encoded as UTF-8, of course).17601761 """17621763def__init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):1764super(FilterLinesEnvironmentMixin, self).__init__(**kw)1765 self.__strict_utf8= strict_utf81766 self.__emailmaxlinelength = emailmaxlinelength17671768deffilter_body(self, lines):1769 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)1770if self.__strict_utf8:1771 lines = (line.decode(ENCODING,'replace')for line in lines)1772# Limit the line length in Unicode-space to avoid1773# splitting characters:1774if self.__emailmaxlinelength:1775 lines =limit_linelength(lines, self.__emailmaxlinelength)1776 lines = (line.encode(ENCODING,'replace')for line in lines)1777elif self.__emailmaxlinelength:1778 lines =limit_linelength(lines, self.__emailmaxlinelength)17791780return lines178117821783classConfigFilterLinesEnvironmentMixin(1784 ConfigEnvironmentMixin,1785 FilterLinesEnvironmentMixin,1786):1787"""Handle encoding and maximum line length based on config."""17881789def__init__(self, config, **kw):1790 strict_utf8 = config.get_bool('emailstrictutf8', default=None)1791if strict_utf8 is not None:1792 kw['strict_utf8'] = strict_utf817931794 emailmaxlinelength = config.get('emailmaxlinelength')1795if emailmaxlinelength is not None:1796 kw['emailmaxlinelength'] =int(emailmaxlinelength)17971798super(ConfigFilterLinesEnvironmentMixin, self).__init__(1799 config=config, **kw1800)180118021803classMaxlinesEnvironmentMixin(Environment):1804"""Limit the email body to a specified number of lines."""18051806def__init__(self, emailmaxlines, **kw):1807super(MaxlinesEnvironmentMixin, self).__init__(**kw)1808 self.__emailmaxlines = emailmaxlines18091810deffilter_body(self, lines):1811 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)1812if self.__emailmaxlines:1813 lines =limit_lines(lines, self.__emailmaxlines)1814return lines181518161817classConfigMaxlinesEnvironmentMixin(1818 ConfigEnvironmentMixin,1819 MaxlinesEnvironmentMixin,1820):1821"""Limit the email body to the number of lines specified in config."""18221823def__init__(self, config, **kw):1824 emailmaxlines =int(config.get('emailmaxlines', default='0'))1825super(ConfigMaxlinesEnvironmentMixin, self).__init__(1826 config=config,1827 emailmaxlines=emailmaxlines,1828**kw1829)183018311832classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):1833"""Deduce pusher_email from pusher by appending an emaildomain."""18341835def__init__(self, **kw):1836super(PusherDomainEnvironmentMixin, self).__init__(**kw)1837 self.__emaildomain = self.config.get('emaildomain')18381839defget_pusher_email(self):1840if self.__emaildomain:1841# Derive the pusher's full email address in the default way:1842return'%s@%s'% (self.get_pusher(), self.__emaildomain)1843else:1844returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()184518461847classStaticRecipientsEnvironmentMixin(Environment):1848"""Set recipients statically based on constructor parameters."""18491850def__init__(1851 self,1852 refchange_recipients, announce_recipients, revision_recipients,1853**kw1854):1855super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)18561857# The recipients for various types of notification emails, as1858# RFC 2822 email addresses separated by commas (or the empty1859# string if no recipients are configured). Although there is1860# a mechanism to choose the recipient lists based on on the1861# actual *contents* of the change being reported, we only1862# choose based on the *type* of the change. Therefore we can1863# compute them once and for all:1864 self.__refchange_recipients = refchange_recipients1865 self.__announce_recipients = announce_recipients1866 self.__revision_recipients = revision_recipients18671868defget_refchange_recipients(self, refchange):1869return self.__refchange_recipients18701871defget_announce_recipients(self, annotated_tag_change):1872return self.__announce_recipients18731874defget_revision_recipients(self, revision):1875return self.__revision_recipients187618771878classConfigRecipientsEnvironmentMixin(1879 ConfigEnvironmentMixin,1880 StaticRecipientsEnvironmentMixin1881):1882"""Determine recipients statically based on config."""18831884def__init__(self, config, **kw):1885super(ConfigRecipientsEnvironmentMixin, self).__init__(1886 config=config,1887 refchange_recipients=self._get_recipients(1888 config,'refchangelist','mailinglist',1889),1890 announce_recipients=self._get_recipients(1891 config,'announcelist','refchangelist','mailinglist',1892),1893 revision_recipients=self._get_recipients(1894 config,'commitlist','mailinglist',1895),1896**kw1897)18981899def_get_recipients(self, config, *names):1900"""Return the recipients for a particular type of message.19011902 Return the list of email addresses to which a particular type1903 of notification email should be sent, by looking at the config1904 value for "multimailhook.$name" for each of names. Use the1905 value from the first name that is configured. The return1906 value is a (possibly empty) string containing RFC 2822 email1907 addresses separated by commas. If no configuration could be1908 found, raise a ConfigurationException."""19091910for name in names:1911 retval = config.get_recipients(name)1912if retval is not None:1913return retval1914iflen(names) ==1:1915 hint ='Please set "%s.%s"'% (config.section, name)1916else:1917 hint = (1918'Please set one of the following:\n"%s"'1919% ('"\n"'.join('%s.%s'% (config.section, name)for name in names))1920)19211922raiseConfigurationException(1923'The list of recipients for%sis not configured.\n%s'% (names[0], hint)1924)192519261927classProjectdescEnvironmentMixin(Environment):1928"""Make a "projectdesc" value available for templates.19291930 By default, it is set to the first line of $GIT_DIR/description1931 (if that file is present and appears to be set meaningfully)."""19321933def__init__(self, **kw):1934super(ProjectdescEnvironmentMixin, self).__init__(**kw)1935 self.COMPUTED_KEYS += ['projectdesc']19361937defget_projectdesc(self):1938"""Return a one-line descripition of the project."""19391940 git_dir =get_git_dir()1941try:1942 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()1943if projectdesc and not projectdesc.startswith('Unnamed repository'):1944return projectdesc1945exceptIOError:1946pass19471948return'UNNAMED PROJECT'194919501951classGenericEnvironmentMixin(Environment):1952defget_pusher(self):1953return self.osenv.get('USER','unknown user')195419551956classGenericEnvironment(1957 ProjectdescEnvironmentMixin,1958 ConfigMaxlinesEnvironmentMixin,1959 ConfigFilterLinesEnvironmentMixin,1960 ConfigRecipientsEnvironmentMixin,1961 PusherDomainEnvironmentMixin,1962 ConfigOptionsEnvironmentMixin,1963 GenericEnvironmentMixin,1964 Environment,1965):1966pass196719681969classGitoliteEnvironmentMixin(Environment):1970defget_repo_shortname(self):1971# The gitolite environment variable $GL_REPO is a pretty good1972# repo_shortname (though it's probably not as good as a value1973# the user might have explicitly put in his config).1974return(1975 self.osenv.get('GL_REPO',None)1976orsuper(GitoliteEnvironmentMixin, self).get_repo_shortname()1977)19781979defget_pusher(self):1980return self.osenv.get('GL_USER','unknown user')198119821983classGitoliteEnvironment(1984 ProjectdescEnvironmentMixin,1985 ConfigMaxlinesEnvironmentMixin,1986 ConfigFilterLinesEnvironmentMixin,1987 ConfigRecipientsEnvironmentMixin,1988 PusherDomainEnvironmentMixin,1989 ConfigOptionsEnvironmentMixin,1990 GitoliteEnvironmentMixin,1991 Environment,1992):1993pass199419951996classPush(object):1997"""Represent an entire push (i.e., a group of ReferenceChanges).19981999 It is easy to figure out what commits were added to a *branch* by2000 a Reference change:20012002 git rev-list change.old..change.new20032004 or removed from a *branch*:20052006 git rev-list change.new..change.old20072008 But it is not quite so trivial to determine which entirely new2009 commits were added to the *repository* by a push and which old2010 commits were discarded by a push. A big part of the job of this2011 class is to figure out these things, and to make sure that new2012 commits are only detailed once even if they were added to multiple2013 references.20142015 The first step is to determine the "other" references--those2016 unaffected by the current push. They are computed by2017 Push._compute_other_ref_sha1s() by listing all references then2018 removing any affected by this push.20192020 The commits contained in the repository before this push were20212022 git rev-list other1 other2 other3 ... change1.old change2.old ...20232024 Where "changeN.old" is the old value of one of the references2025 affected by this push.20262027 The commits contained in the repository after this push are20282029 git rev-list other1 other2 other3 ... change1.new change2.new ...20302031 The commits added by this push are the difference between these2032 two sets, which can be written20332034 git rev-list \2035 ^other1 ^other2 ... \2036 ^change1.old ^change2.old ... \2037 change1.new change2.new ...20382039 The commits removed by this push can be computed by20402041 git rev-list \2042 ^other1 ^other2 ... \2043 ^change1.new ^change2.new ... \2044 change1.old change2.old ...20452046 The last point is that it is possible that other pushes are2047 occurring simultaneously to this one, so reference values can2048 change at any time. It is impossible to eliminate all race2049 conditions, but we reduce the window of time during which problems2050 can occur by translating reference names to SHA1s as soon as2051 possible and working with SHA1s thereafter (because SHA1s are2052 immutable)."""20532054# A map {(changeclass, changetype) : integer} specifying the order2055# that reference changes will be processed if multiple reference2056# changes are included in a single push. The order is significant2057# mostly because new commit notifications are threaded together2058# with the first reference change that includes the commit. The2059# following order thus causes commits to be grouped with branch2060# changes (as opposed to tag changes) if possible.2061 SORT_ORDER =dict(2062(value, i)for(i, value)inenumerate([2063(BranchChange,'update'),2064(BranchChange,'create'),2065(AnnotatedTagChange,'update'),2066(AnnotatedTagChange,'create'),2067(NonAnnotatedTagChange,'update'),2068(NonAnnotatedTagChange,'create'),2069(BranchChange,'delete'),2070(AnnotatedTagChange,'delete'),2071(NonAnnotatedTagChange,'delete'),2072(OtherReferenceChange,'update'),2073(OtherReferenceChange,'create'),2074(OtherReferenceChange,'delete'),2075])2076)20772078def__init__(self, changes):2079 self.changes =sorted(changes, key=self._sort_key)20802081# The SHA-1s of commits referred to by references unaffected2082# by this push:2083 other_ref_sha1s = self._compute_other_ref_sha1s()20842085 self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(2086 other_ref_sha1s.union(2087 change.old.sha12088for change in self.changes2089if change.old.typein['commit','tag']2090)2091)2092 self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(2093 other_ref_sha1s.union(2094 change.new.sha12095for change in self.changes2096if change.new.typein['commit','tag']2097)2098)20992100@classmethod2101def_sort_key(klass, change):2102return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)21032104def_compute_other_ref_sha1s(self):2105"""Return the GitObjects referred to by references unaffected by this push."""21062107# The refnames being changed by this push:2108 updated_refs =set(2109 change.refname2110for change in self.changes2111)21122113# The SHA-1s of commits referred to by all references in this2114# repository *except* updated_refs:2115 sha1s =set()2116 fmt = (2117'%(objectname) %(objecttype) %(refname)\n'2118'%(*objectname) %(*objecttype)%(refname)'2119)2120for line inread_git_lines(['for-each-ref','--format=%s'% (fmt,)]):2121(sha1,type, name) = line.split(' ',2)2122if sha1 andtype=='commit'and name not in updated_refs:2123 sha1s.add(sha1)21242125return sha1s21262127def_compute_rev_exclusion_spec(self, sha1s):2128"""Return an exclusion specification for 'git rev-list'.21292130 git_objects is an iterable over GitObject instances. Return a2131 string that can be passed to the standard input of 'git2132 rev-list --stdin' to exclude all of the commits referred to by2133 git_objects."""21342135return''.join(2136['^%s\n'% (sha1,)for sha1 insorted(sha1s)]2137)21382139defget_new_commits(self, reference_change=None):2140"""Return a list of commits added by this push.21412142 Return a list of the object names of commits that were added2143 by the part of this push represented by reference_change. If2144 reference_change is None, then return a list of *all* commits2145 added by this push."""21462147if not reference_change:2148 new_revs =sorted(2149 change.new.sha12150for change in self.changes2151if change.new2152)2153elif not reference_change.new.commit_sha1:2154return[]2155else:2156 new_revs = [reference_change.new.commit_sha1]21572158 cmd = ['rev-list','--stdin'] + new_revs2159returnread_git_lines(cmd,input=self._old_rev_exclusion_spec)21602161defget_discarded_commits(self, reference_change):2162"""Return a list of commits discarded by this push.21632164 Return a list of the object names of commits that were2165 entirely discarded from the repository by the part of this2166 push represented by reference_change."""21672168if not reference_change.old.commit_sha1:2169return[]2170else:2171 old_revs = [reference_change.old.commit_sha1]21722173 cmd = ['rev-list','--stdin'] + old_revs2174returnread_git_lines(cmd,input=self._new_rev_exclusion_spec)21752176defsend_emails(self, mailer, body_filter=None):2177"""Use send all of the notification emails needed for this push.21782179 Use send all of the notification emails (including reference2180 change emails and commit emails) needed for this push. Send2181 the emails using mailer. If body_filter is not None, then use2182 it to filter the lines that are intended for the email2183 body."""21842185# The sha1s of commits that were introduced by this push.2186# They will be removed from this set as they are processed, to2187# guarantee that one (and only one) email is generated for2188# each new commit.2189 unhandled_sha1s =set(self.get_new_commits())2190for change in self.changes:2191# Check if we've got anyone to send to2192if not change.recipients:2193 sys.stderr.write(2194'*** no recipients configured so no email will be sent\n'2195'*** for%rupdate%s->%s\n'2196% (change.refname, change.old.sha1, change.new.sha1,)2197)2198else:2199 sys.stderr.write('Sending notification emails to:%s\n'% (change.recipients,))2200 mailer.send(change.generate_email(self, body_filter), change.recipients)22012202 sha1s = []2203for sha1 inreversed(list(self.get_new_commits(change))):2204if sha1 in unhandled_sha1s:2205 sha1s.append(sha1)2206 unhandled_sha1s.remove(sha1)22072208 max_emails = change.environment.maxcommitemails2209if max_emails andlen(sha1s) > max_emails:2210 sys.stderr.write(2211'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s)2212+'*** Try setting multimailhook.maxCommitEmails to a greater value\n'2213+'*** Currently, multimailhook.maxCommitEmails=%d\n'% max_emails2214)2215return22162217for(num, sha1)inenumerate(sha1s):2218 rev =Revision(change,GitObject(sha1), num=num+1, tot=len(sha1s))2219if rev.recipients:2220 mailer.send(rev.generate_email(self, body_filter), rev.recipients)22212222# Consistency check:2223if unhandled_sha1s:2224 sys.stderr.write(2225'ERROR: No emails were sent for the following new commits:\n'2226'%s\n'2227% ('\n'.join(sorted(unhandled_sha1s)),)2228)222922302231defrun_as_post_receive_hook(environment, mailer):2232 changes = []2233for line in sys.stdin:2234(oldrev, newrev, refname) = line.strip().split(' ',2)2235 changes.append(2236 ReferenceChange.create(environment, oldrev, newrev, refname)2237)2238 push =Push(changes)2239 push.send_emails(mailer, body_filter=environment.filter_body)224022412242defrun_as_update_hook(environment, mailer, refname, oldrev, newrev):2243 changes = [2244 ReferenceChange.create(2245 environment,2246read_git_output(['rev-parse','--verify', oldrev]),2247read_git_output(['rev-parse','--verify', newrev]),2248 refname,2249),2250]2251 push =Push(changes)2252 push.send_emails(mailer, body_filter=environment.filter_body)225322542255defchoose_mailer(config, environment):2256 mailer = config.get('mailer', default='sendmail')22572258if mailer =='smtp':2259 smtpserver = config.get('smtpserver', default='localhost')2260 mailer =SMTPMailer(2261 envelopesender=(environment.get_sender()or environment.get_fromaddr()),2262 smtpserver=smtpserver,2263)2264elif mailer =='sendmail':2265 command = config.get('sendmailcommand')2266if command:2267 command = shlex.split(command)2268 mailer =SendMailer(command=command, envelopesender=environment.get_sender())2269else:2270 sys.stderr.write(2271'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer2272+'please use one of "smtp" or "sendmail".\n'2273)2274 sys.exit(1)2275return mailer227622772278KNOWN_ENVIRONMENTS = {2279'generic': GenericEnvironmentMixin,2280'gitolite': GitoliteEnvironmentMixin,2281}228222832284defchoose_environment(config, osenv=None, env=None, recipients=None):2285if not osenv:2286 osenv = os.environ22872288 environment_mixins = [2289 ProjectdescEnvironmentMixin,2290 ConfigMaxlinesEnvironmentMixin,2291 ConfigFilterLinesEnvironmentMixin,2292 PusherDomainEnvironmentMixin,2293 ConfigOptionsEnvironmentMixin,2294]2295 environment_kw = {2296'osenv': osenv,2297'config': config,2298}22992300if not env:2301 env = config.get('environment')23022303if not env:2304if'GL_USER'in osenv and'GL_REPO'in osenv:2305 env ='gitolite'2306else:2307 env ='generic'23082309 environment_mixins.append(KNOWN_ENVIRONMENTS[env])23102311if recipients:2312 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)2313 environment_kw['refchange_recipients'] = recipients2314 environment_kw['announce_recipients'] = recipients2315 environment_kw['revision_recipients'] = recipients2316else:2317 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)23182319 environment_klass =type(2320'EffectiveEnvironment',2321tuple(environment_mixins) + (Environment,),2322{},2323)2324returnenvironment_klass(**environment_kw)232523262327defmain(args):2328 parser = optparse.OptionParser(2329 description=__doc__,2330 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',2331)23322333 parser.add_option(2334'--environment','--env', action='store',type='choice',2335 choices=['generic','gitolite'], default=None,2336help=(2337'Choose type of environment is in use. Default is taken from '2338'multimailhook.environment if set; otherwise "generic".'2339),2340)2341 parser.add_option(2342'--stdout', action='store_true', default=False,2343help='Output emails to stdout rather than sending them.',2344)2345 parser.add_option(2346'--recipients', action='store', default=None,2347help='Set list of email recipients for all types of emails.',2348)2349 parser.add_option(2350'--show-env', action='store_true', default=False,2351help=(2352'Write to stderr the values determined for the environment '2353'(intended for debugging purposes).'2354),2355)23562357(options, args) = parser.parse_args(args)23582359 config =Config('multimailhook')23602361try:2362 environment =choose_environment(2363 config, osenv=os.environ,2364 env=options.environment,2365 recipients=options.recipients,2366)23672368if options.show_env:2369 sys.stderr.write('Environment values:\n')2370for(k,v)insorted(environment.get_values().items()):2371 sys.stderr.write('%s:%r\n'% (k,v))2372 sys.stderr.write('\n')23732374if options.stdout:2375 mailer =OutputMailer(sys.stdout)2376else:2377 mailer =choose_mailer(config, environment)23782379# Dual mode: if arguments were specified on the command line, run2380# like an update hook; otherwise, run as a post-receive hook.2381if args:2382iflen(args) !=3:2383 parser.error('Need zero or three non-option arguments')2384(refname, oldrev, newrev) = args2385run_as_update_hook(environment, mailer, refname, oldrev, newrev)2386else:2387run_as_post_receive_hook(environment, mailer)2388except ConfigurationException, e:2389 sys.exit(str(e))239023912392if __name__ =='__main__':2393main(sys.argv[1:])