1#! /usr/bin/env python 2 3__version__ ='1.3.0' 4 5# Copyright (c) 2015 Matthieu Moy and others 6# Copyright (c) 2012-2014 Michael Haggerty and others 7# Derived from contrib/hooks/post-receive-email, which is 8# Copyright (c) 2007 Andy Parkins 9# and also includes contributions by other authors. 10# 11# This file is part of git-multimail. 12# 13# git-multimail is free software: you can redistribute it and/or 14# modify it under the terms of the GNU General Public License version 15# 2 as published by the Free Software Foundation. 16# 17# This program is distributed in the hope that it will be useful, but 18# WITHOUT ANY WARRANTY; without even the implied warranty of 19# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 20# General Public License for more details. 21# 22# You should have received a copy of the GNU General Public License 23# along with this program. If not, see 24# <http://www.gnu.org/licenses/>. 25 26"""Generate notification emails for pushes to a git repository. 27 28This hook sends emails describing changes introduced by pushes to a 29git repository. For each reference that was changed, it emits one 30ReferenceChange email summarizing how the reference was changed, 31followed by one Revision email for each new commit that was introduced 32by the reference change. 33 34Each commit is announced in exactly one Revision email. If the same 35commit is merged into another branch in the same or a later push, then 36the ReferenceChange email will list the commit's SHA1 and its one-line 37summary, but no new Revision email will be generated. 38 39This script is designed to be used as a "post-receive" hook in a git 40repository (see githooks(5)). It can also be used as an "update" 41script, but this usage is not completely reliable and is deprecated. 42 43To help with debugging, this script accepts a --stdout option, which 44causes the emails to be written to standard output rather than sent 45using sendmail. 46 47See the accompanying README file for the complete documentation. 48 49""" 50 51import sys 52import os 53import re 54import bisect 55import socket 56import subprocess 57import shlex 58import optparse 59import smtplib 60try: 61import ssl 62exceptImportError: 63# Python < 2.6 do not have ssl, but that's OK if we don't use it. 64pass 65import time 66import cgi 67 68PYTHON3 = sys.version_info >= (3,0) 69 70if sys.version_info <= (2,5): 71defall(iterable): 72for element in iterable: 73if not element: 74return False 75return True 76 77 78defis_ascii(s): 79returnall(ord(c) <128andord(c) >0for c in s) 80 81 82if PYTHON3: 83defis_string(s): 84returnisinstance(s,str) 85 86defstr_to_bytes(s): 87return s.encode(ENCODING) 88 89defbytes_to_str(s): 90return s.decode(ENCODING) 91 92unicode=str 93 94defwrite_str(f, msg): 95# Try outputing with the default encoding. If it fails, 96# try UTF-8. 97try: 98 f.buffer.write(msg.encode(sys.getdefaultencoding())) 99exceptUnicodeEncodeError: 100 f.buffer.write(msg.encode(ENCODING)) 101else: 102defis_string(s): 103try: 104returnisinstance(s, basestring) 105exceptNameError:# Silence Pyflakes warning 106raise 107 108defstr_to_bytes(s): 109return s 110 111defbytes_to_str(s): 112return s 113 114defwrite_str(f, msg): 115 f.write(msg) 116 117defnext(it): 118return it.next() 119 120 121try: 122from email.charset import Charset 123from email.utils import make_msgid 124from email.utils import getaddresses 125from email.utils import formataddr 126from email.utils import formatdate 127from email.header import Header 128exceptImportError: 129# Prior to Python 2.5, the email module used different names: 130from email.Charset import Charset 131from email.Utils import make_msgid 132from email.Utils import getaddresses 133from email.Utils import formataddr 134from email.Utils import formatdate 135from email.Header import Header 136 137 138DEBUG =False 139 140ZEROS ='0'*40 141LOGBEGIN ='- Log -----------------------------------------------------------------\n' 142LOGEND ='-----------------------------------------------------------------------\n' 143 144ADDR_HEADERS =set(['from','to','cc','bcc','reply-to','sender']) 145 146# It is assumed in many places that the encoding is uniformly UTF-8, 147# so changing these constants is unsupported. But define them here 148# anyway, to make it easier to find (at least most of) the places 149# where the encoding is important. 150(ENCODING, CHARSET) = ('UTF-8','utf-8') 151 152 153REF_CREATED_SUBJECT_TEMPLATE = ( 154'%(emailprefix)s%(refname_type)s %(short_refname)screated' 155' (now%(newrev_short)s)' 156) 157REF_UPDATED_SUBJECT_TEMPLATE = ( 158'%(emailprefix)s%(refname_type)s %(short_refname)supdated' 159' (%(oldrev_short)s->%(newrev_short)s)' 160) 161REF_DELETED_SUBJECT_TEMPLATE = ( 162'%(emailprefix)s%(refname_type)s %(short_refname)sdeleted' 163' (was%(oldrev_short)s)' 164) 165 166COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( 167'%(emailprefix)s%(refname_type)s %(short_refname)supdated:%(oneline)s' 168) 169 170REFCHANGE_HEADER_TEMPLATE ="""\ 171Date:%(send_date)s 172To:%(recipients)s 173Subject:%(subject)s 174MIME-Version: 1.0 175Content-Type: text/%(contenttype)s; charset=%(charset)s 176Content-Transfer-Encoding: 8bit 177Message-ID:%(msgid)s 178From:%(fromaddr)s 179Reply-To:%(reply_to)s 180X-Git-Host:%(fqdn)s 181X-Git-Repo:%(repo_shortname)s 182X-Git-Refname:%(refname)s 183X-Git-Reftype:%(refname_type)s 184X-Git-Oldrev:%(oldrev)s 185X-Git-Newrev:%(newrev)s 186X-Git-NotificationType: ref_changed 187X-Git-Multimail-Version:%(multimail_version)s 188Auto-Submitted: auto-generated 189""" 190 191REFCHANGE_INTRO_TEMPLATE ="""\ 192This is an automated email from the git hooks/post-receive script. 193 194%(pusher)spushed a change to%(refname_type)s %(short_refname)s 195in repository%(repo_shortname)s. 196 197""" 198 199 200FOOTER_TEMPLATE ="""\ 201 202--\n\ 203To stop receiving notification emails like this one, please contact 204%(administrator)s. 205""" 206 207 208REWIND_ONLY_TEMPLATE ="""\ 209This update removed existing revisions from the reference, leaving the 210reference pointing at a previous point in the repository history. 211 212 * -- * -- N%(refname)s(%(newrev_short)s) 213\\ 214 O -- O -- O (%(oldrev_short)s) 215 216Any revisions marked "omits" are not gone; other references still 217refer to them. Any revisions marked "discards" are gone forever. 218""" 219 220 221NON_FF_TEMPLATE ="""\ 222This update added new revisions after undoing existing revisions. 223That is to say, some revisions that were in the old version of the 224%(refname_type)sare not in the new version. This situation occurs 225when a user --force pushes a change and generates a repository 226containing something like this: 227 228 * -- * -- B -- O -- O -- O (%(oldrev_short)s) 229\\ 230 N -- N -- N%(refname)s(%(newrev_short)s) 231 232You should already have received notification emails for all of the O 233revisions, and so the following emails describe only the N revisions 234from the common base, B. 235 236Any revisions marked "omits" are not gone; other references still 237refer to them. Any revisions marked "discards" are gone forever. 238""" 239 240 241NO_NEW_REVISIONS_TEMPLATE ="""\ 242No new revisions were added by this update. 243""" 244 245 246DISCARDED_REVISIONS_TEMPLATE ="""\ 247This change permanently discards the following revisions: 248""" 249 250 251NO_DISCARDED_REVISIONS_TEMPLATE ="""\ 252The revisions that were on this%(refname_type)sare still contained in 253other references; therefore, this change does not discard any commits 254from the repository. 255""" 256 257 258NEW_REVISIONS_TEMPLATE ="""\ 259The%(tot)srevisions listed above as "new" are entirely new to this 260repository and will be described in separate emails. The revisions 261listed as "adds" were already present in the repository and have only 262been added to this reference. 263 264""" 265 266 267TAG_CREATED_TEMPLATE ="""\ 268 at%(newrev_short)-9s (%(newrev_type)s) 269""" 270 271 272TAG_UPDATED_TEMPLATE ="""\ 273*** WARNING: tag%(short_refname)swas modified! *** 274 275 from%(oldrev_short)-9s (%(oldrev_type)s) 276 to%(newrev_short)-9s (%(newrev_type)s) 277""" 278 279 280TAG_DELETED_TEMPLATE ="""\ 281*** WARNING: tag%(short_refname)swas deleted! *** 282 283""" 284 285 286# The template used in summary tables. It looks best if this uses the 287# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. 288BRIEF_SUMMARY_TEMPLATE ="""\ 289%(action)10s%(rev_short)-9s%(text)s 290""" 291 292 293NON_COMMIT_UPDATE_TEMPLATE ="""\ 294This is an unusual reference change because the reference did not 295refer to a commit either before or after the change. We do not know 296how to provide full information about this reference change. 297""" 298 299 300REVISION_HEADER_TEMPLATE ="""\ 301Date:%(send_date)s 302To:%(recipients)s 303Cc:%(cc_recipients)s 304Subject:%(emailprefix)s%(num)02d/%(tot)02d:%(oneline)s 305MIME-Version: 1.0 306Content-Type: text/%(contenttype)s; charset=%(charset)s 307Content-Transfer-Encoding: 8bit 308From:%(fromaddr)s 309Reply-To:%(reply_to)s 310In-Reply-To:%(reply_to_msgid)s 311References:%(reply_to_msgid)s 312X-Git-Host:%(fqdn)s 313X-Git-Repo:%(repo_shortname)s 314X-Git-Refname:%(refname)s 315X-Git-Reftype:%(refname_type)s 316X-Git-Rev:%(rev)s 317X-Git-NotificationType: diff 318X-Git-Multimail-Version:%(multimail_version)s 319Auto-Submitted: auto-generated 320""" 321 322REVISION_INTRO_TEMPLATE ="""\ 323This is an automated email from the git hooks/post-receive script. 324 325%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 326in repository%(repo_shortname)s. 327 328""" 329 330LINK_TEXT_TEMPLATE ="""\ 331View the commit online: 332%(browse_url)s 333 334""" 335 336LINK_HTML_TEMPLATE ="""\ 337<p><a href="%(browse_url)s">View the commit online</a>.</p> 338""" 339 340 341REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 342 343 344# Combined, meaning refchange+revision email (for single-commit additions) 345COMBINED_HEADER_TEMPLATE ="""\ 346Date:%(send_date)s 347To:%(recipients)s 348Subject:%(subject)s 349MIME-Version: 1.0 350Content-Type: text/%(contenttype)s; charset=%(charset)s 351Content-Transfer-Encoding: 8bit 352Message-ID:%(msgid)s 353From:%(fromaddr)s 354Reply-To:%(reply_to)s 355X-Git-Host:%(fqdn)s 356X-Git-Repo:%(repo_shortname)s 357X-Git-Refname:%(refname)s 358X-Git-Reftype:%(refname_type)s 359X-Git-Oldrev:%(oldrev)s 360X-Git-Newrev:%(newrev)s 361X-Git-Rev:%(rev)s 362X-Git-NotificationType: ref_changed_plus_diff 363X-Git-Multimail-Version:%(multimail_version)s 364Auto-Submitted: auto-generated 365""" 366 367COMBINED_INTRO_TEMPLATE ="""\ 368This is an automated email from the git hooks/post-receive script. 369 370%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 371in repository%(repo_shortname)s. 372 373""" 374 375COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE 376 377 378classCommandError(Exception): 379def__init__(self, cmd, retcode): 380 self.cmd = cmd 381 self.retcode = retcode 382Exception.__init__( 383 self, 384'Command "%s" failed with retcode%s'% (' '.join(cmd), retcode,) 385) 386 387 388classConfigurationException(Exception): 389pass 390 391 392# The "git" program (this could be changed to include a full path): 393GIT_EXECUTABLE ='git' 394 395 396# How "git" should be invoked (including global arguments), as a list 397# of words. This variable is usually initialized automatically by 398# read_git_output() via choose_git_command(), but if a value is set 399# here then it will be used unconditionally. 400GIT_CMD =None 401 402 403defchoose_git_command(): 404"""Decide how to invoke git, and record the choice in GIT_CMD.""" 405 406global GIT_CMD 407 408if GIT_CMD is None: 409try: 410# Check to see whether the "-c" option is accepted (it was 411# only added in Git 1.7.2). We don't actually use the 412# output of "git --version", though if we needed more 413# specific version information this would be the place to 414# do it. 415 cmd = [GIT_EXECUTABLE,'-c','foo.bar=baz','--version'] 416read_output(cmd) 417 GIT_CMD = [GIT_EXECUTABLE,'-c','i18n.logoutputencoding=%s'% (ENCODING,)] 418except CommandError: 419 GIT_CMD = [GIT_EXECUTABLE] 420 421 422defread_git_output(args,input=None, keepends=False, **kw): 423"""Read the output of a Git command.""" 424 425if GIT_CMD is None: 426choose_git_command() 427 428returnread_output(GIT_CMD + args,input=input, keepends=keepends, **kw) 429 430 431defread_output(cmd,input=None, keepends=False, **kw): 432ifinput: 433 stdin = subprocess.PIPE 434input=str_to_bytes(input) 435else: 436 stdin =None 437 p = subprocess.Popen( 438 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 439) 440(out, err) = p.communicate(input) 441 out =bytes_to_str(out) 442 retcode = p.wait() 443if retcode: 444raiseCommandError(cmd, retcode) 445if not keepends: 446 out = out.rstrip('\n\r') 447return out 448 449 450defread_git_lines(args, keepends=False, **kw): 451"""Return the lines output by Git command. 452 453 Return as single lines, with newlines stripped off.""" 454 455returnread_git_output(args, keepends=True, **kw).splitlines(keepends) 456 457 458defgit_rev_list_ish(cmd, spec, args=None, **kw): 459"""Common functionality for invoking a 'git rev-list'-like command. 460 461 Parameters: 462 * cmd is the Git command to run, e.g., 'rev-list' or 'log'. 463 * spec is a list of revision arguments to pass to the named 464 command. If None, this function returns an empty list. 465 * args is a list of extra arguments passed to the named command. 466 * All other keyword arguments (if any) are passed to the 467 underlying read_git_lines() function. 468 469 Return the output of the Git command in the form of a list, one 470 entry per output line. 471 """ 472if spec is None: 473return[] 474if args is None: 475 args = [] 476 args = [cmd,'--stdin'] + args 477 spec_stdin =''.join(s +'\n'for s in spec) 478returnread_git_lines(args,input=spec_stdin, **kw) 479 480 481defgit_rev_list(spec, **kw): 482"""Run 'git rev-list' with the given list of revision arguments. 483 484 See git_rev_list_ish() for parameter and return value 485 documentation. 486 """ 487returngit_rev_list_ish('rev-list', spec, **kw) 488 489 490defgit_log(spec, **kw): 491"""Run 'git log' with the given list of revision arguments. 492 493 See git_rev_list_ish() for parameter and return value 494 documentation. 495 """ 496returngit_rev_list_ish('log', spec, **kw) 497 498 499defheader_encode(text, header_name=None): 500"""Encode and line-wrap the value of an email header field.""" 501 502# Convert to unicode, if required. 503if notisinstance(text,unicode): 504 text =unicode(text,'utf-8') 505 506ifis_ascii(text): 507 charset ='ascii' 508else: 509 charset ='utf-8' 510 511returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 512 513 514defaddr_header_encode(text, header_name=None): 515"""Encode and line-wrap the value of an email header field containing 516 email addresses.""" 517 518# Convert to unicode, if required. 519if notisinstance(text,unicode): 520 text =unicode(text,'utf-8') 521 522 text =', '.join( 523formataddr((header_encode(name), emailaddr)) 524for name, emailaddr ingetaddresses([text]) 525) 526 527ifis_ascii(text): 528 charset ='ascii' 529else: 530 charset ='utf-8' 531 532returnHeader(text, header_name=header_name, charset=Charset(charset)).encode() 533 534 535classConfig(object): 536def__init__(self, section, git_config=None): 537"""Represent a section of the git configuration. 538 539 If git_config is specified, it is passed to "git config" in 540 the GIT_CONFIG environment variable, meaning that "git config" 541 will read the specified path rather than the Git default 542 config paths.""" 543 544 self.section = section 545if git_config: 546 self.env = os.environ.copy() 547 self.env['GIT_CONFIG'] = git_config 548else: 549 self.env =None 550 551@staticmethod 552def_split(s): 553"""Split NUL-terminated values.""" 554 555 words = s.split('\0') 556assert words[-1] =='' 557return words[:-1] 558 559@staticmethod 560defadd_config_parameters(c): 561"""Add configuration parameters to Git. 562 563 c is either an str or a list of str, each element being of the 564 form 'var=val' or 'var', with the same syntax and meaning as 565 the argument of 'git -c var=val'. 566 """ 567ifisinstance(c,str): 568 c = (c,) 569 parameters = os.environ.get('GIT_CONFIG_PARAMETERS','') 570if parameters: 571 parameters +=' ' 572# git expects GIT_CONFIG_PARAMETERS to be of the form 573# "'name1=value1' 'name2=value2' 'name3=value3'" 574# including everything inside the double quotes (but not the double 575# quotes themselves). Spacing is critical. Also, if a value contains 576# a literal single quote that quote must be represented using the 577# four character sequence: '\'' 578 parameters +=' '.join("'"+ x.replace("'","'\\''") +"'"for x in c) 579 os.environ['GIT_CONFIG_PARAMETERS'] = parameters 580 581defget(self, name, default=None): 582try: 583 values = self._split(read_git_output( 584['config','--get','--null','%s.%s'% (self.section, name)], 585 env=self.env, keepends=True, 586)) 587assertlen(values) ==1 588return values[0] 589except CommandError: 590return default 591 592defget_bool(self, name, default=None): 593try: 594 value =read_git_output( 595['config','--get','--bool','%s.%s'% (self.section, name)], 596 env=self.env, 597) 598except CommandError: 599return default 600return value =='true' 601 602defget_all(self, name, default=None): 603"""Read a (possibly multivalued) setting from the configuration. 604 605 Return the result as a list of values, or default if the name 606 is unset.""" 607 608try: 609return self._split(read_git_output( 610['config','--get-all','--null','%s.%s'% (self.section, name)], 611 env=self.env, keepends=True, 612)) 613except CommandError: 614 t, e, traceback = sys.exc_info() 615if e.retcode ==1: 616# "the section or key is invalid"; i.e., there is no 617# value for the specified key. 618return default 619else: 620raise 621 622defset(self, name, value): 623read_git_output( 624['config','%s.%s'% (self.section, name), value], 625 env=self.env, 626) 627 628defadd(self, name, value): 629read_git_output( 630['config','--add','%s.%s'% (self.section, name), value], 631 env=self.env, 632) 633 634def__contains__(self, name): 635return self.get_all(name, default=None)is not None 636 637# We don't use this method anymore internally, but keep it here in 638# case somebody is calling it from their own code: 639defhas_key(self, name): 640return name in self 641 642defunset_all(self, name): 643try: 644read_git_output( 645['config','--unset-all','%s.%s'% (self.section, name)], 646 env=self.env, 647) 648except CommandError: 649 t, e, traceback = sys.exc_info() 650if e.retcode ==5: 651# The name doesn't exist, which is what we wanted anyway... 652pass 653else: 654raise 655 656defset_recipients(self, name, value): 657 self.unset_all(name) 658for pair ingetaddresses([value]): 659 self.add(name,formataddr(pair)) 660 661 662defgenerate_summaries(*log_args): 663"""Generate a brief summary for each revision requested. 664 665 log_args are strings that will be passed directly to "git log" as 666 revision selectors. Iterate over (sha1_short, subject) for each 667 commit specified by log_args (subject is the first line of the 668 commit message as a string without EOLs).""" 669 670 cmd = [ 671'log','--abbrev','--format=%h%s', 672] +list(log_args) + ['--'] 673for line inread_git_lines(cmd): 674yieldtuple(line.split(' ',1)) 675 676 677deflimit_lines(lines, max_lines): 678for(index, line)inenumerate(lines): 679if index < max_lines: 680yield line 681 682if index >= max_lines: 683yield'...%dlines suppressed ...\n'% (index +1- max_lines,) 684 685 686deflimit_linelength(lines, max_linelength): 687for line in lines: 688# Don't forget that lines always include a trailing newline. 689iflen(line) > max_linelength +1: 690 line = line[:max_linelength -7] +' [...]\n' 691yield line 692 693 694classCommitSet(object): 695"""A (constant) set of object names. 696 697 The set should be initialized with full SHA1 object names. The 698 __contains__() method returns True iff its argument is an 699 abbreviation of any the names in the set.""" 700 701def__init__(self, names): 702 self._names =sorted(names) 703 704def__len__(self): 705returnlen(self._names) 706 707def__contains__(self, sha1_abbrev): 708"""Return True iff this set contains sha1_abbrev (which might be abbreviated).""" 709 710 i = bisect.bisect_left(self._names, sha1_abbrev) 711return i <len(self)and self._names[i].startswith(sha1_abbrev) 712 713 714classGitObject(object): 715def__init__(self, sha1,type=None): 716if sha1 == ZEROS: 717 self.sha1 = self.type= self.commit_sha1 =None 718else: 719 self.sha1 = sha1 720 self.type=typeorread_git_output(['cat-file','-t', self.sha1]) 721 722if self.type=='commit': 723 self.commit_sha1 = self.sha1 724elif self.type=='tag': 725try: 726 self.commit_sha1 =read_git_output( 727['rev-parse','--verify','%s^0'% (self.sha1,)] 728) 729except CommandError: 730# Cannot deref tag to determine commit_sha1 731 self.commit_sha1 =None 732else: 733 self.commit_sha1 =None 734 735 self.short =read_git_output(['rev-parse','--short', sha1]) 736 737defget_summary(self): 738"""Return (sha1_short, subject) for this commit.""" 739 740if not self.sha1: 741raiseValueError('Empty commit has no summary') 742 743returnnext(iter(generate_summaries('--no-walk', self.sha1))) 744 745def__eq__(self, other): 746returnisinstance(other, GitObject)and self.sha1 == other.sha1 747 748def__hash__(self): 749returnhash(self.sha1) 750 751def__nonzero__(self): 752returnbool(self.sha1) 753 754def__bool__(self): 755"""Python 2 backward compatibility""" 756return self.__nonzero__() 757 758def__str__(self): 759return self.sha1 or ZEROS 760 761 762classChange(object): 763"""A Change that has been made to the Git repository. 764 765 Abstract class from which both Revisions and ReferenceChanges are 766 derived. A Change knows how to generate a notification email 767 describing itself.""" 768 769def__init__(self, environment): 770 self.environment = environment 771 self._values =None 772 self._contains_html_diff =False 773 774def_contains_diff(self): 775# We do contain a diff, should it be rendered in HTML? 776if self.environment.commit_email_format =="html": 777 self._contains_html_diff =True 778 779def_compute_values(self): 780"""Return a dictionary{keyword: expansion}for this Change. 781 782 Derived classes overload this method to add more entries to 783 the return value. This method is used internally by 784 get_values(). The return value should always be a new 785 dictionary.""" 786 787 values = self.environment.get_values() 788 fromaddr = self.environment.get_fromaddr(change=self) 789if fromaddr is not None: 790 values['fromaddr'] = fromaddr 791 values['multimail_version'] =get_version() 792return values 793 794# Aliases usable in template strings. Tuple of pairs (destination, 795# source). 796 VALUES_ALIAS = ( 797("id","newrev"), 798) 799 800defget_values(self, **extra_values): 801"""Return a dictionary{keyword: expansion}for this Change. 802 803 Return a dictionary mapping keywords to the values that they 804 should be expanded to for this Change (used when interpolating 805 template strings). If any keyword arguments are supplied, add 806 those to the return value as well. The return value is always 807 a new dictionary.""" 808 809if self._values is None: 810 self._values = self._compute_values() 811 812 values = self._values.copy() 813if extra_values: 814 values.update(extra_values) 815 816for alias, val in self.VALUES_ALIAS: 817 values[alias] = values[val] 818return values 819 820defexpand(self, template, **extra_values): 821"""Expand template. 822 823 Expand the template (which should be a string) using string 824 interpolation of the values for this Change. If any keyword 825 arguments are provided, also include those in the keywords 826 available for interpolation.""" 827 828return template % self.get_values(**extra_values) 829 830defexpand_lines(self, template, html_escape_val=False, **extra_values): 831"""Break template into lines and expand each line.""" 832 833 values = self.get_values(**extra_values) 834if html_escape_val: 835for k in values: 836ifis_string(values[k]): 837 values[k] = cgi.escape(values[k],True) 838for line in template.splitlines(True): 839yield line % values 840 841defexpand_header_lines(self, template, **extra_values): 842"""Break template into lines and expand each line as an RFC 2822 header. 843 844 Encode values and split up lines that are too long. Silently 845 skip lines that contain references to unknown variables.""" 846 847 values = self.get_values(**extra_values) 848if self._contains_html_diff: 849 self._content_type ='html' 850else: 851 self._content_type ='plain' 852 values['contenttype'] = self._content_type 853 854for line in template.splitlines(): 855(name, value) = line.split(': ',1) 856 857try: 858 value = value % values 859exceptKeyError: 860 t, e, traceback = sys.exc_info() 861if DEBUG: 862 self.environment.log_warning( 863'Warning: unknown variable%rin the following line; line skipped:\n' 864'%s\n' 865% (e.args[0], line,) 866) 867else: 868if name.lower()in ADDR_HEADERS: 869 value =addr_header_encode(value, name) 870else: 871 value =header_encode(value, name) 872for splitline in('%s:%s\n'% (name, value)).splitlines(True): 873yield splitline 874 875defgenerate_email_header(self): 876"""Generate the RFC 2822 email headers for this Change, a line at a time. 877 878 The output should not include the trailing blank line.""" 879 880raiseNotImplementedError() 881 882defgenerate_browse_link(self, base_url): 883"""Generate a link to an online repository browser.""" 884returniter(()) 885 886defgenerate_email_intro(self, html_escape_val=False): 887"""Generate the email intro for this Change, a line at a time. 888 889 The output will be used as the standard boilerplate at the top 890 of the email body.""" 891 892raiseNotImplementedError() 893 894defgenerate_email_body(self): 895"""Generate the main part of the email body, a line at a time. 896 897 The text in the body might be truncated after a specified 898 number of lines (see multimailhook.emailmaxlines).""" 899 900raiseNotImplementedError() 901 902defgenerate_email_footer(self, html_escape_val): 903"""Generate the footer of the email, a line at a time. 904 905 The footer is always included, irrespective of 906 multimailhook.emailmaxlines.""" 907 908raiseNotImplementedError() 909 910def_wrap_for_html(self, lines): 911"""Wrap the lines in HTML <pre> tag when using HTML format. 912 913 Escape special HTML characters and add <pre> and </pre> tags around 914 the given lines if we should be generating HTML as indicated by 915 self._contains_html_diff being set to true. 916 """ 917if self._contains_html_diff: 918yield"<pre style='margin:0'>\n" 919 920for line in lines: 921yield cgi.escape(line) 922 923yield'</pre>\n' 924else: 925for line in lines: 926yield line 927 928defgenerate_email(self, push, body_filter=None, extra_header_values={}): 929"""Generate an email describing this change. 930 931 Iterate over the lines (including the header lines) of an 932 email describing this change. If body_filter is not None, 933 then use it to filter the lines that are intended for the 934 email body. 935 936 The extra_header_values field is received as a dict and not as 937 **kwargs, to allow passing other keyword arguments in the 938 future (e.g. passing extra values to generate_email_intro()""" 939 940for line in self.generate_email_header(**extra_header_values): 941yield line 942yield'\n' 943 html_escape_val = (self.environment.html_in_intro and 944 self._contains_html_diff) 945 intro = self.generate_email_intro(html_escape_val) 946if not self.environment.html_in_intro: 947 intro = self._wrap_for_html(intro) 948for line in intro: 949yield line 950 951if self.environment.commitBrowseURL: 952for line in self.generate_browse_link(self.environment.commitBrowseURL): 953yield line 954 955 body = self.generate_email_body(push) 956if body_filter is not None: 957 body =body_filter(body) 958 959 diff_started =False 960if self._contains_html_diff: 961# "white-space: pre" is the default, but we need to 962# specify it again in case the message is viewed in a 963# webmail which wraps it in an element setting white-space 964# to something else (Zimbra does this and sets 965# white-space: pre-line). 966yield'<pre style="white-space: pre; background: #F8F8F8">' 967for line in body: 968if self._contains_html_diff: 969# This is very, very naive. It would be much better to really 970# parse the diff, i.e. look at how many lines do we have in 971# the hunk headers instead of blindly highlighting everything 972# that looks like it might be part of a diff. 973 bgcolor ='' 974 fgcolor ='' 975if line.startswith('--- a/'): 976 diff_started =True 977 bgcolor ='e0e0ff' 978elif line.startswith('diff ')or line.startswith('index '): 979 diff_started =True 980 fgcolor ='808080' 981elif diff_started: 982if line.startswith('+++ '): 983 bgcolor ='e0e0ff' 984elif line.startswith('@@'): 985 bgcolor ='e0e0e0' 986elif line.startswith('+'): 987 bgcolor ='e0ffe0' 988elif line.startswith('-'): 989 bgcolor ='ffe0e0' 990elif line.startswith('commit '): 991 fgcolor ='808000' 992elif line.startswith(' '): 993 fgcolor ='404040' 994 995# Chop the trailing LF, we don't want it inside <pre>. 996 line = cgi.escape(line[:-1]) 997 998if bgcolor or fgcolor: 999 style ='display:block; white-space:pre;'1000if bgcolor:1001 style +='background:#'+ bgcolor +';'1002if fgcolor:1003 style +='color:#'+ fgcolor +';'1004# Use a <span style='display:block> to color the1005# whole line. The newline must be inside the span1006# to display properly both in Firefox and in1007# text-based browser.1008 line ="<span style='%s'>%s\n</span>"% (style, line)1009else:1010 line = line +'\n'10111012yield line1013if self._contains_html_diff:1014yield'</pre>'1015 html_escape_val = (self.environment.html_in_footer and1016 self._contains_html_diff)1017 footer = self.generate_email_footer(html_escape_val)1018if not self.environment.html_in_footer:1019 footer = self._wrap_for_html(footer)1020for line in footer:1021yield line10221023defget_alt_fromaddr(self):1024return None102510261027classRevision(Change):1028"""A Change consisting of a single git commit."""10291030 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')10311032def__init__(self, reference_change, rev, num, tot):1033 Change.__init__(self, reference_change.environment)1034 self.reference_change = reference_change1035 self.rev = rev1036 self.change_type = self.reference_change.change_type1037 self.refname = self.reference_change.refname1038 self.num = num1039 self.tot = tot1040 self.author =read_git_output(['log','--no-walk','--format=%aN <%aE>', self.rev.sha1])1041 self.recipients = self.environment.get_revision_recipients(self)10421043 self.cc_recipients =''1044if self.environment.get_scancommitforcc():1045 self.cc_recipients =', '.join(to.strip()for to in self._cc_recipients())1046if self.cc_recipients:1047 self.environment.log_msg(1048'Add%sto CC for%s\n'% (self.cc_recipients, self.rev.sha1))10491050def_cc_recipients(self):1051 cc_recipients = []1052 message =read_git_output(['log','--no-walk','--format=%b', self.rev.sha1])1053 lines = message.strip().split('\n')1054for line in lines:1055 m = re.match(self.CC_RE, line)1056if m:1057 cc_recipients.append(m.group('to'))10581059return cc_recipients10601061def_compute_values(self):1062 values = Change._compute_values(self)10631064 oneline =read_git_output(1065['log','--format=%s','--no-walk', self.rev.sha1]1066)10671068 values['rev'] = self.rev.sha11069 values['rev_short'] = self.rev.short1070 values['change_type'] = self.change_type1071 values['refname'] = self.refname1072 values['newrev'] = self.rev.sha11073 values['short_refname'] = self.reference_change.short_refname1074 values['refname_type'] = self.reference_change.refname_type1075 values['reply_to_msgid'] = self.reference_change.msgid1076 values['num'] = self.num1077 values['tot'] = self.tot1078 values['recipients'] = self.recipients1079if self.cc_recipients:1080 values['cc_recipients'] = self.cc_recipients1081 values['oneline'] = oneline1082 values['author'] = self.author10831084 reply_to = self.environment.get_reply_to_commit(self)1085if reply_to:1086 values['reply_to'] = reply_to10871088return values10891090defgenerate_email_header(self, **extra_values):1091for line in self.expand_header_lines(1092 REVISION_HEADER_TEMPLATE, **extra_values1093):1094yield line10951096defgenerate_browse_link(self, base_url):1097if'%('not in base_url:1098 base_url +='%(id)s'1099 url ="".join(self.expand_lines(base_url))1100if self._content_type =='html':1101for line in self.expand_lines(LINK_HTML_TEMPLATE,1102 html_escape_val=True,1103 browse_url=url):1104yield line1105elif self._content_type =='plain':1106for line in self.expand_lines(LINK_TEXT_TEMPLATE,1107 html_escape_val=False,1108 browse_url=url):1109yield line1110else:1111raiseNotImplementedError("Content-type%sunsupported. Please report it as a bug.")11121113defgenerate_email_intro(self, html_escape_val=False):1114for line in self.expand_lines(REVISION_INTRO_TEMPLATE,1115 html_escape_val=html_escape_val):1116yield line11171118defgenerate_email_body(self, push):1119"""Show this revision."""11201121for line inread_git_lines(1122['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],1123 keepends=True,1124):1125if line.startswith('Date: ')and self.environment.date_substitute:1126yield self.environment.date_substitute + line[len('Date: '):]1127else:1128yield line11291130defgenerate_email_footer(self, html_escape_val):1131return self.expand_lines(REVISION_FOOTER_TEMPLATE,1132 html_escape_val=html_escape_val)11331134defgenerate_email(self, push, body_filter=None, extra_header_values={}):1135 self._contains_diff()1136return Change.generate_email(self, push, body_filter, extra_header_values)11371138defget_alt_fromaddr(self):1139return self.environment.from_commit114011411142classReferenceChange(Change):1143"""A Change to a Git reference.11441145 An abstract class representing a create, update, or delete of a1146 Git reference. Derived classes handle specific types of reference1147 (e.g., tags vs. branches). These classes generate the main1148 reference change email summarizing the reference change and1149 whether it caused any any commits to be added or removed.11501151 ReferenceChange objects are usually created using the static1152 create() method, which has the logic to decide which derived class1153 to instantiate."""11541155 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')11561157@staticmethod1158defcreate(environment, oldrev, newrev, refname):1159"""Return a ReferenceChange object representing the change.11601161 Return an object that represents the type of change that is being1162 made. oldrev and newrev should be SHA1s or ZEROS."""11631164 old =GitObject(oldrev)1165 new =GitObject(newrev)1166 rev = new or old11671168# The revision type tells us what type the commit is, combined with1169# the location of the ref we can decide between1170# - working branch1171# - tracking branch1172# - unannotated tag1173# - annotated tag1174 m = ReferenceChange.REF_RE.match(refname)1175if m:1176 area = m.group('area')1177 short_refname = m.group('shortname')1178else:1179 area =''1180 short_refname = refname11811182if rev.type=='tag':1183# Annotated tag:1184 klass = AnnotatedTagChange1185elif rev.type=='commit':1186if area =='tags':1187# Non-annotated tag:1188 klass = NonAnnotatedTagChange1189elif area =='heads':1190# Branch:1191 klass = BranchChange1192elif area =='remotes':1193# Tracking branch:1194 environment.log_warning(1195'*** Push-update of tracking branch%r\n'1196'*** - incomplete email generated.\n'1197% (refname,)1198)1199 klass = OtherReferenceChange1200else:1201# Some other reference namespace:1202 environment.log_warning(1203'*** Push-update of strange reference%r\n'1204'*** - incomplete email generated.\n'1205% (refname,)1206)1207 klass = OtherReferenceChange1208else:1209# Anything else (is there anything else?)1210 environment.log_warning(1211'*** Unknown type of update to%r(%s)\n'1212'*** - incomplete email generated.\n'1213% (refname, rev.type,)1214)1215 klass = OtherReferenceChange12161217returnklass(1218 environment,1219 refname=refname, short_refname=short_refname,1220 old=old, new=new, rev=rev,1221)12221223def__init__(self, environment, refname, short_refname, old, new, rev):1224 Change.__init__(self, environment)1225 self.change_type = {1226(False,True):'create',1227(True,True):'update',1228(True,False):'delete',1229}[bool(old),bool(new)]1230 self.refname = refname1231 self.short_refname = short_refname1232 self.old = old1233 self.new = new1234 self.rev = rev1235 self.msgid =make_msgid()1236 self.diffopts = environment.diffopts1237 self.graphopts = environment.graphopts1238 self.logopts = environment.logopts1239 self.commitlogopts = environment.commitlogopts1240 self.showgraph = environment.refchange_showgraph1241 self.showlog = environment.refchange_showlog12421243 self.header_template = REFCHANGE_HEADER_TEMPLATE1244 self.intro_template = REFCHANGE_INTRO_TEMPLATE1245 self.footer_template = FOOTER_TEMPLATE12461247def_compute_values(self):1248 values = Change._compute_values(self)12491250 values['change_type'] = self.change_type1251 values['refname_type'] = self.refname_type1252 values['refname'] = self.refname1253 values['short_refname'] = self.short_refname1254 values['msgid'] = self.msgid1255 values['recipients'] = self.recipients1256 values['oldrev'] =str(self.old)1257 values['oldrev_short'] = self.old.short1258 values['newrev'] =str(self.new)1259 values['newrev_short'] = self.new.short12601261if self.old:1262 values['oldrev_type'] = self.old.type1263if self.new:1264 values['newrev_type'] = self.new.type12651266 reply_to = self.environment.get_reply_to_refchange(self)1267if reply_to:1268 values['reply_to'] = reply_to12691270return values12711272defsend_single_combined_email(self, known_added_sha1s):1273"""Determine if a combined refchange/revision email should be sent12741275 If there is only a single new (non-merge) commit added by a1276 change, it is useful to combine the ReferenceChange and1277 Revision emails into one. In such a case, return the single1278 revision; otherwise, return None.12791280 This method is overridden in BranchChange."""12811282return None12831284defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1285"""Generate an email describing this change AND specified revision.12861287 Iterate over the lines (including the header lines) of an1288 email describing this change. If body_filter is not None,1289 then use it to filter the lines that are intended for the1290 email body.12911292 The extra_header_values field is received as a dict and not as1293 **kwargs, to allow passing other keyword arguments in the1294 future (e.g. passing extra values to generate_email_intro()12951296 This method is overridden in BranchChange."""12971298raiseNotImplementedError12991300defget_subject(self):1301 template = {1302'create': REF_CREATED_SUBJECT_TEMPLATE,1303'update': REF_UPDATED_SUBJECT_TEMPLATE,1304'delete': REF_DELETED_SUBJECT_TEMPLATE,1305}[self.change_type]1306return self.expand(template)13071308defgenerate_email_header(self, **extra_values):1309if'subject'not in extra_values:1310 extra_values['subject'] = self.get_subject()13111312for line in self.expand_header_lines(1313 self.header_template, **extra_values1314):1315yield line13161317defgenerate_email_intro(self, html_escape_val=False):1318for line in self.expand_lines(self.intro_template,1319 html_escape_val=html_escape_val):1320yield line13211322defgenerate_email_body(self, push):1323"""Call the appropriate body-generation routine.13241325 Call one of generate_create_summary() /1326 generate_update_summary() / generate_delete_summary()."""13271328 change_summary = {1329'create': self.generate_create_summary,1330'delete': self.generate_delete_summary,1331'update': self.generate_update_summary,1332}[self.change_type](push)1333for line in change_summary:1334yield line13351336for line in self.generate_revision_change_summary(push):1337yield line13381339defgenerate_email_footer(self, html_escape_val):1340return self.expand_lines(self.footer_template,1341 html_escape_val=html_escape_val)13421343defgenerate_revision_change_graph(self, push):1344if self.showgraph:1345 args = ['--graph'] + self.graphopts1346for newold in('new','old'):1347 has_newold =False1348 spec = push.get_commits_spec(newold, self)1349for line ingit_log(spec, args=args, keepends=True):1350if not has_newold:1351 has_newold =True1352yield'\n'1353yield'Graph of%scommits:\n\n'% (1354 {'new': 'new', 'old': 'discarded'}[newold],)1355yield' '+ line1356if has_newold:1357yield'\n'13581359defgenerate_revision_change_log(self, new_commits_list):1360if self.showlog:1361yield'\n'1362yield'Detailed log of new commits:\n\n'1363for line inread_git_lines(1364['log','--no-walk'] +1365 self.logopts +1366 new_commits_list +1367['--'],1368 keepends=True,1369):1370yield line13711372defgenerate_new_revision_summary(self, tot, new_commits_list, push):1373for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):1374yield line1375for line in self.generate_revision_change_graph(push):1376yield line1377for line in self.generate_revision_change_log(new_commits_list):1378yield line13791380defgenerate_revision_change_summary(self, push):1381"""Generate a summary of the revisions added/removed by this change."""13821383if self.new.commit_sha1 and not self.old.commit_sha1:1384# A new reference was created. List the new revisions1385# brought by the new reference (i.e., those revisions that1386# were not in the repository before this reference1387# change).1388 sha1s =list(push.get_new_commits(self))1389 sha1s.reverse()1390 tot =len(sha1s)1391 new_revisions = [1392Revision(self,GitObject(sha1), num=i +1, tot=tot)1393for(i, sha1)inenumerate(sha1s)1394]13951396if new_revisions:1397yield self.expand('This%(refname_type)sincludes the following new commits:\n')1398yield'\n'1399for r in new_revisions:1400(sha1, subject) = r.rev.get_summary()1401yield r.expand(1402 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,1403)1404yield'\n'1405for line in self.generate_new_revision_summary(1406 tot, [r.rev.sha1 for r in new_revisions], push):1407yield line1408else:1409for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1410yield line14111412elif self.new.commit_sha1 and self.old.commit_sha1:1413# A reference was changed to point at a different commit.1414# List the revisions that were removed and/or added *from1415# that reference* by this reference change, along with a1416# diff between the trees for its old and new values.14171418# List of the revisions that were added to the branch by1419# this update. Note this list can include revisions that1420# have already had notification emails; we want such1421# revisions in the summary even though we will not send1422# new notification emails for them.1423 adds =list(generate_summaries(1424'--topo-order','--reverse','%s..%s'1425% (self.old.commit_sha1, self.new.commit_sha1,)1426))14271428# List of the revisions that were removed from the branch1429# by this update. This will be empty except for1430# non-fast-forward updates.1431 discards =list(generate_summaries(1432'%s..%s'% (self.new.commit_sha1, self.old.commit_sha1,)1433))14341435if adds:1436 new_commits_list = push.get_new_commits(self)1437else:1438 new_commits_list = []1439 new_commits =CommitSet(new_commits_list)14401441if discards:1442 discarded_commits =CommitSet(push.get_discarded_commits(self))1443else:1444 discarded_commits =CommitSet([])14451446if discards and adds:1447for(sha1, subject)in discards:1448if sha1 in discarded_commits:1449 action ='discards'1450else:1451 action ='omits'1452yield self.expand(1453 BRIEF_SUMMARY_TEMPLATE, action=action,1454 rev_short=sha1, text=subject,1455)1456for(sha1, subject)in adds:1457if sha1 in new_commits:1458 action ='new'1459else:1460 action ='adds'1461yield self.expand(1462 BRIEF_SUMMARY_TEMPLATE, action=action,1463 rev_short=sha1, text=subject,1464)1465yield'\n'1466for line in self.expand_lines(NON_FF_TEMPLATE):1467yield line14681469elif discards:1470for(sha1, subject)in discards:1471if sha1 in discarded_commits:1472 action ='discards'1473else:1474 action ='omits'1475yield self.expand(1476 BRIEF_SUMMARY_TEMPLATE, action=action,1477 rev_short=sha1, text=subject,1478)1479yield'\n'1480for line in self.expand_lines(REWIND_ONLY_TEMPLATE):1481yield line14821483elif adds:1484(sha1, subject) = self.old.get_summary()1485yield self.expand(1486 BRIEF_SUMMARY_TEMPLATE, action='from',1487 rev_short=sha1, text=subject,1488)1489for(sha1, subject)in adds:1490if sha1 in new_commits:1491 action ='new'1492else:1493 action ='adds'1494yield self.expand(1495 BRIEF_SUMMARY_TEMPLATE, action=action,1496 rev_short=sha1, text=subject,1497)14981499yield'\n'15001501if new_commits:1502for line in self.generate_new_revision_summary(1503len(new_commits), new_commits_list, push):1504yield line1505else:1506for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1507yield line1508for line in self.generate_revision_change_graph(push):1509yield line15101511# The diffstat is shown from the old revision to the new1512# revision. This is to show the truth of what happened in1513# this change. There's no point showing the stat from the1514# base to the new revision because the base is effectively a1515# random revision at this point - the user will be interested1516# in what this revision changed - including the undoing of1517# previous revisions in the case of non-fast-forward updates.1518yield'\n'1519yield'Summary of changes:\n'1520for line inread_git_lines(1521['diff-tree'] +1522 self.diffopts +1523['%s..%s'% (self.old.commit_sha1, self.new.commit_sha1,)],1524 keepends=True,1525):1526yield line15271528elif self.old.commit_sha1 and not self.new.commit_sha1:1529# A reference was deleted. List the revisions that were1530# removed from the repository by this reference change.15311532 sha1s =list(push.get_discarded_commits(self))1533 tot =len(sha1s)1534 discarded_revisions = [1535Revision(self,GitObject(sha1), num=i +1, tot=tot)1536for(i, sha1)inenumerate(sha1s)1537]15381539if discarded_revisions:1540for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):1541yield line1542yield'\n'1543for r in discarded_revisions:1544(sha1, subject) = r.rev.get_summary()1545yield r.expand(1546 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,1547)1548for line in self.generate_revision_change_graph(push):1549yield line1550else:1551for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):1552yield line15531554elif not self.old.commit_sha1 and not self.new.commit_sha1:1555for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):1556yield line15571558defgenerate_create_summary(self, push):1559"""Called for the creation of a reference."""15601561# This is a new reference and so oldrev is not valid1562(sha1, subject) = self.new.get_summary()1563yield self.expand(1564 BRIEF_SUMMARY_TEMPLATE, action='at',1565 rev_short=sha1, text=subject,1566)1567yield'\n'15681569defgenerate_update_summary(self, push):1570"""Called for the change of a pre-existing branch."""15711572returniter([])15731574defgenerate_delete_summary(self, push):1575"""Called for the deletion of any type of reference."""15761577(sha1, subject) = self.old.get_summary()1578yield self.expand(1579 BRIEF_SUMMARY_TEMPLATE, action='was',1580 rev_short=sha1, text=subject,1581)1582yield'\n'15831584defget_alt_fromaddr(self):1585return self.environment.from_refchange158615871588classBranchChange(ReferenceChange):1589 refname_type ='branch'15901591def__init__(self, environment, refname, short_refname, old, new, rev):1592 ReferenceChange.__init__(1593 self, environment,1594 refname=refname, short_refname=short_refname,1595 old=old, new=new, rev=rev,1596)1597 self.recipients = environment.get_refchange_recipients(self)1598 self._single_revision =None15991600defsend_single_combined_email(self, known_added_sha1s):1601if not self.environment.combine_when_single_commit:1602return None16031604# In the sadly-all-too-frequent usecase of people pushing only1605# one of their commits at a time to a repository, users feel1606# the reference change summary emails are noise rather than1607# important signal. This is because, in this particular1608# usecase, there is a reference change summary email for each1609# new commit, and all these summaries do is point out that1610# there is one new commit (which can readily be inferred by1611# the existence of the individual revision email that is also1612# sent). In such cases, our users prefer there to be a combined1613# reference change summary/new revision email.1614#1615# So, if the change is an update and it doesn't discard any1616# commits, and it adds exactly one non-merge commit (gerrit1617# forces a workflow where every commit is individually merged1618# and the git-multimail hook fired off for just this one1619# change), then we send a combined refchange/revision email.1620try:1621# If this change is a reference update that doesn't discard1622# any commits...1623if self.change_type !='update':1624return None16251626ifread_git_lines(1627['merge-base', self.old.sha1, self.new.sha1]1628) != [self.old.sha1]:1629return None16301631# Check if this update introduced exactly one non-merge1632# commit:16331634defsplit_line(line):1635"""Split line into (sha1, [parent,...])."""16361637 words = line.split()1638return(words[0], words[1:])16391640# Get the new commits introduced by the push as a list of1641# (sha1, [parent,...])1642 new_commits = [1643split_line(line)1644for line inread_git_lines(1645[1646'log','-3','--format=%H %P',1647'%s..%s'% (self.old.sha1, self.new.sha1),1648]1649)1650]16511652if not new_commits:1653return None16541655# If the newest commit is a merge, save it for a later check1656# but otherwise ignore it1657 merge =None1658 tot =len(new_commits)1659iflen(new_commits[0][1]) >1:1660 merge = new_commits[0][0]1661del new_commits[0]16621663# Our primary check: we can't combine if more than one commit1664# is introduced. We also currently only combine if the new1665# commit is a non-merge commit, though it may make sense to1666# combine if it is a merge as well.1667if not(1668len(new_commits) ==1and1669len(new_commits[0][1]) ==1and1670 new_commits[0][0]in known_added_sha1s1671):1672return None16731674# We do not want to combine revision and refchange emails if1675# those go to separate locations.1676 rev =Revision(self,GitObject(new_commits[0][0]),1, tot)1677if rev.recipients != self.recipients:1678return None16791680# We ignored the newest commit if it was just a merge of the one1681# commit being introduced. But we don't want to ignore that1682# merge commit it it involved conflict resolutions. Check that.1683if merge and merge !=read_git_output(['diff-tree','--cc', merge]):1684return None16851686# We can combine the refchange and one new revision emails1687# into one. Return the Revision that a combined email should1688# be sent about.1689return rev1690except CommandError:1691# Cannot determine number of commits in old..new or new..old;1692# don't combine reference/revision emails:1693return None16941695defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1696 values = revision.get_values()1697if extra_header_values:1698 values.update(extra_header_values)1699if'subject'not in extra_header_values:1700 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)17011702 self._single_revision = revision1703 self._contains_diff()1704 self.header_template = COMBINED_HEADER_TEMPLATE1705 self.intro_template = COMBINED_INTRO_TEMPLATE1706 self.footer_template = COMBINED_FOOTER_TEMPLATE1707for line in self.generate_email(push, body_filter, values):1708yield line17091710defgenerate_email_body(self, push):1711'''Call the appropriate body generation routine.17121713 If this is a combined refchange/revision email, the special logic1714 for handling this combined email comes from this function. For1715 other cases, we just use the normal handling.'''17161717# If self._single_revision isn't set; don't override1718if not self._single_revision:1719for line insuper(BranchChange, self).generate_email_body(push):1720yield line1721return17221723# This is a combined refchange/revision email; we first provide1724# some info from the refchange portion, and then call the revision1725# generate_email_body function to handle the revision portion.1726 adds =list(generate_summaries(1727'--topo-order','--reverse','%s..%s'1728% (self.old.commit_sha1, self.new.commit_sha1,)1729))17301731yield self.expand("The following commit(s) were added to%(refname)sby this push:\n")1732for(sha1, subject)in adds:1733yield self.expand(1734 BRIEF_SUMMARY_TEMPLATE, action='new',1735 rev_short=sha1, text=subject,1736)17371738yield self._single_revision.rev.short +" is described below\n"1739yield'\n'17401741for line in self._single_revision.generate_email_body(push):1742yield line174317441745classAnnotatedTagChange(ReferenceChange):1746 refname_type ='annotated tag'17471748def__init__(self, environment, refname, short_refname, old, new, rev):1749 ReferenceChange.__init__(1750 self, environment,1751 refname=refname, short_refname=short_refname,1752 old=old, new=new, rev=rev,1753)1754 self.recipients = environment.get_announce_recipients(self)1755 self.show_shortlog = environment.announce_show_shortlog17561757 ANNOTATED_TAG_FORMAT = (1758'%(*objectname)\n'1759'%(*objecttype)\n'1760'%(taggername)\n'1761'%(taggerdate)'1762)17631764defdescribe_tag(self, push):1765"""Describe the new value of an annotated tag."""17661767# Use git for-each-ref to pull out the individual fields from1768# the tag1769[tagobject, tagtype, tagger, tagged] =read_git_lines(1770['for-each-ref','--format=%s'% (self.ANNOTATED_TAG_FORMAT,), self.refname],1771)17721773yield self.expand(1774 BRIEF_SUMMARY_TEMPLATE, action='tagging',1775 rev_short=tagobject, text='(%s)'% (tagtype,),1776)1777if tagtype =='commit':1778# If the tagged object is a commit, then we assume this is a1779# release, and so we calculate which tag this tag is1780# replacing1781try:1782 prevtag =read_git_output(['describe','--abbrev=0','%s^'% (self.new,)])1783except CommandError:1784 prevtag =None1785if prevtag:1786yield' replaces%s\n'% (prevtag,)1787else:1788 prevtag =None1789yield' length%sbytes\n'% (read_git_output(['cat-file','-s', tagobject]),)17901791yield' tagged by%s\n'% (tagger,)1792yield' on%s\n'% (tagged,)1793yield'\n'17941795# Show the content of the tag message; this might contain a1796# change log or release notes so is worth displaying.1797yield LOGBEGIN1798 contents =list(read_git_lines(['cat-file','tag', self.new.sha1], keepends=True))1799 contents = contents[contents.index('\n') +1:]1800if contents and contents[-1][-1:] !='\n':1801 contents.append('\n')1802for line in contents:1803yield line18041805if self.show_shortlog and tagtype =='commit':1806# Only commit tags make sense to have rev-list operations1807# performed on them1808yield'\n'1809if prevtag:1810# Show changes since the previous release1811 revlist =read_git_output(1812['rev-list','--pretty=short','%s..%s'% (prevtag, self.new,)],1813 keepends=True,1814)1815else:1816# No previous tag, show all the changes since time1817# began1818 revlist =read_git_output(1819['rev-list','--pretty=short','%s'% (self.new,)],1820 keepends=True,1821)1822for line inread_git_lines(['shortlog'],input=revlist, keepends=True):1823yield line18241825yield LOGEND1826yield'\n'18271828defgenerate_create_summary(self, push):1829"""Called for the creation of an annotated tag."""18301831for line in self.expand_lines(TAG_CREATED_TEMPLATE):1832yield line18331834for line in self.describe_tag(push):1835yield line18361837defgenerate_update_summary(self, push):1838"""Called for the update of an annotated tag.18391840 This is probably a rare event and may not even be allowed."""18411842for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1843yield line18441845for line in self.describe_tag(push):1846yield line18471848defgenerate_delete_summary(self, push):1849"""Called when a non-annotated reference is updated."""18501851for line in self.expand_lines(TAG_DELETED_TEMPLATE):1852yield line18531854yield self.expand(' tag was%(oldrev_short)s\n')1855yield'\n'185618571858classNonAnnotatedTagChange(ReferenceChange):1859 refname_type ='tag'18601861def__init__(self, environment, refname, short_refname, old, new, rev):1862 ReferenceChange.__init__(1863 self, environment,1864 refname=refname, short_refname=short_refname,1865 old=old, new=new, rev=rev,1866)1867 self.recipients = environment.get_refchange_recipients(self)18681869defgenerate_create_summary(self, push):1870"""Called for the creation of an annotated tag."""18711872for line in self.expand_lines(TAG_CREATED_TEMPLATE):1873yield line18741875defgenerate_update_summary(self, push):1876"""Called when a non-annotated reference is updated."""18771878for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1879yield line18801881defgenerate_delete_summary(self, push):1882"""Called when a non-annotated reference is updated."""18831884for line in self.expand_lines(TAG_DELETED_TEMPLATE):1885yield line18861887for line in ReferenceChange.generate_delete_summary(self, push):1888yield line188918901891classOtherReferenceChange(ReferenceChange):1892 refname_type ='reference'18931894def__init__(self, environment, refname, short_refname, old, new, rev):1895# We use the full refname as short_refname, because otherwise1896# the full name of the reference would not be obvious from the1897# text of the email.1898 ReferenceChange.__init__(1899 self, environment,1900 refname=refname, short_refname=refname,1901 old=old, new=new, rev=rev,1902)1903 self.recipients = environment.get_refchange_recipients(self)190419051906classMailer(object):1907"""An object that can send emails."""19081909defsend(self, lines, to_addrs):1910"""Send an email consisting of lines.19111912 lines must be an iterable over the lines constituting the1913 header and body of the email. to_addrs is a list of recipient1914 addresses (can be needed even if lines already contains a1915 "To:" field). It can be either a string (comma-separated list1916 of email addresses) or a Python list of individual email1917 addresses.19181919 """19201921raiseNotImplementedError()192219231924classSendMailer(Mailer):1925"""Send emails using 'sendmail -oi -t'."""19261927 SENDMAIL_CANDIDATES = [1928'/usr/sbin/sendmail',1929'/usr/lib/sendmail',1930]19311932@staticmethod1933deffind_sendmail():1934for path in SendMailer.SENDMAIL_CANDIDATES:1935if os.access(path, os.X_OK):1936return path1937else:1938raiseConfigurationException(1939'No sendmail executable found. '1940'Try setting multimailhook.sendmailCommand.'1941)19421943def__init__(self, command=None, envelopesender=None):1944"""Construct a SendMailer instance.19451946 command should be the command and arguments used to invoke1947 sendmail, as a list of strings. If an envelopesender is1948 provided, it will also be passed to the command, via '-f1949 envelopesender'."""19501951if command:1952 self.command = command[:]1953else:1954 self.command = [self.find_sendmail(),'-oi','-t']19551956if envelopesender:1957 self.command.extend(['-f', envelopesender])19581959defsend(self, lines, to_addrs):1960try:1961 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)1962exceptOSError:1963 sys.stderr.write(1964'*** Cannot execute command:%s\n'%' '.join(self.command) +1965'***%s\n'% sys.exc_info()[1] +1966'*** Try setting multimailhook.mailer to "smtp"\n'+1967'*** to send emails without using the sendmail command.\n'1968)1969 sys.exit(1)1970try:1971 lines = (str_to_bytes(line)for line in lines)1972 p.stdin.writelines(lines)1973exceptException:1974 sys.stderr.write(1975'*** Error while generating commit email\n'1976'*** - mail sending aborted.\n'1977)1978try:1979# subprocess.terminate() is not available in Python 2.41980 p.terminate()1981exceptAttributeError:1982pass1983raise1984else:1985 p.stdin.close()1986 retcode = p.wait()1987if retcode:1988raiseCommandError(self.command, retcode)198919901991classSMTPMailer(Mailer):1992"""Send emails using Python's smtplib."""19931994def__init__(self, envelopesender, smtpserver,1995 smtpservertimeout=10.0, smtpserverdebuglevel=0,1996 smtpencryption='none',1997 smtpuser='', smtppass='',1998 smtpcacerts=''1999):2000if not envelopesender:2001 sys.stderr.write(2002'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'2003'please set either multimailhook.envelopeSender or user.email\n'2004)2005 sys.exit(1)2006if smtpencryption =='ssl'and not(smtpuser and smtppass):2007raiseConfigurationException(2008'Cannot use SMTPMailer with security option ssl '2009'without options username and password.'2010)2011 self.envelopesender = envelopesender2012 self.smtpserver = smtpserver2013 self.smtpservertimeout = smtpservertimeout2014 self.smtpserverdebuglevel = smtpserverdebuglevel2015 self.security = smtpencryption2016 self.username = smtpuser2017 self.password = smtppass2018 self.smtpcacerts = smtpcacerts2019try:2020defcall(klass, server, timeout):2021try:2022returnklass(server, timeout=timeout)2023exceptTypeError:2024# Old Python versions do not have timeout= argument.2025returnklass(server)2026if self.security =='none':2027 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)2028elif self.security =='ssl':2029if self.smtpcacerts:2030raise smtplib.SMTPException(2031"Checking certificate is not supported for ssl, prefer starttls"2032)2033 self.smtp =call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)2034elif self.security =='tls':2035if'ssl'not in sys.modules:2036 sys.stderr.write(2037'*** Your Python version does not have the ssl library installed\n'2038'*** smtpEncryption=tls is not available.\n'2039'*** Either upgrade Python to 2.6 or later\n'2040' or use git_multimail.py version 1.2.\n')2041if':'not in self.smtpserver:2042 self.smtpserver +=':587'# default port for TLS2043 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)2044# start: ehlo + starttls2045# equivalent to2046# self.smtp.ehlo()2047# self.smtp.starttls()2048# with acces to the ssl layer2049 self.smtp.ehlo()2050if not self.smtp.has_extn("starttls"):2051raise smtplib.SMTPException("STARTTLS extension not supported by server")2052 resp, reply = self.smtp.docmd("STARTTLS")2053if resp !=220:2054raise smtplib.SMTPException("Wrong answer to the STARTTLS command")2055if self.smtpcacerts:2056 self.smtp.sock = ssl.wrap_socket(2057 self.smtp.sock,2058 ca_certs=self.smtpcacerts,2059 cert_reqs=ssl.CERT_REQUIRED2060)2061else:2062 self.smtp.sock = ssl.wrap_socket(2063 self.smtp.sock,2064 cert_reqs=ssl.CERT_NONE2065)2066 sys.stderr.write(2067'*** Warning, the server certificat is not verified (smtp) ***\n'2068'*** set the option smtpCACerts ***\n'2069)2070if nothasattr(self.smtp.sock,"read"):2071# using httplib.FakeSocket with Python 2.5.x or earlier2072 self.smtp.sock.read = self.smtp.sock.recv2073 self.smtp.file= smtplib.SSLFakeFile(self.smtp.sock)2074 self.smtp.helo_resp =None2075 self.smtp.ehlo_resp =None2076 self.smtp.esmtp_features = {}2077 self.smtp.does_esmtp =02078# end: ehlo + starttls2079 self.smtp.ehlo()2080else:2081 sys.stdout.write('*** Error: Control reached an invalid option. ***')2082 sys.exit(1)2083if self.smtpserverdebuglevel >0:2084 sys.stdout.write(2085"*** Setting debug on for SMTP server connection (%s) ***\n"2086% self.smtpserverdebuglevel)2087 self.smtp.set_debuglevel(self.smtpserverdebuglevel)2088exceptException:2089 sys.stderr.write(2090'*** Error establishing SMTP connection to%s***\n'2091% self.smtpserver)2092 sys.stderr.write('***%s\n'% sys.exc_info()[1])2093 sys.exit(1)20942095def__del__(self):2096ifhasattr(self,'smtp'):2097 self.smtp.quit()2098del self.smtp20992100defsend(self, lines, to_addrs):2101try:2102if self.username or self.password:2103 self.smtp.login(self.username, self.password)2104 msg =''.join(lines)2105# turn comma-separated list into Python list if needed.2106ifis_string(to_addrs):2107 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]2108 self.smtp.sendmail(self.envelopesender, to_addrs, msg)2109except smtplib.SMTPResponseException:2110 sys.stderr.write('*** Error sending email ***\n')2111 err = sys.exc_info()[1]2112 sys.stderr.write('*** Error%d:%s\n'% (err.smtp_code,2113bytes_to_str(err.smtp_error)))2114try:2115 smtp = self.smtp2116# delete the field before quit() so that in case of2117# error, self.smtp is deleted anyway.2118del self.smtp2119 smtp.quit()2120except:2121 sys.stderr.write('*** Error closing the SMTP connection ***\n')2122 sys.stderr.write('*** Exiting anyway ... ***\n')2123 sys.stderr.write('***%s\n'% sys.exc_info()[1])2124 sys.exit(1)212521262127classOutputMailer(Mailer):2128"""Write emails to an output stream, bracketed by lines of '=' characters.21292130 This is intended for debugging purposes."""21312132 SEPARATOR ='='*75+'\n'21332134def__init__(self, f):2135 self.f = f21362137defsend(self, lines, to_addrs):2138write_str(self.f, self.SEPARATOR)2139for line in lines:2140write_str(self.f, line)2141write_str(self.f, self.SEPARATOR)214221432144defget_git_dir():2145"""Determine GIT_DIR.21462147 Determine GIT_DIR either from the GIT_DIR environment variable or2148 from the working directory, using Git's usual rules."""21492150try:2151returnread_git_output(['rev-parse','--git-dir'])2152except CommandError:2153 sys.stderr.write('fatal: git_multimail: not in a git directory\n')2154 sys.exit(1)215521562157classEnvironment(object):2158"""Describes the environment in which the push is occurring.21592160 An Environment object encapsulates information about the local2161 environment. For example, it knows how to determine:21622163 * the name of the repository to which the push occurred21642165 * what user did the push21662167 * what users want to be informed about various types of changes.21682169 An Environment object is expected to have the following methods:21702171 get_repo_shortname()21722173 Return a short name for the repository, for display2174 purposes.21752176 get_repo_path()21772178 Return the absolute path to the Git repository.21792180 get_emailprefix()21812182 Return a string that will be prefixed to every email's2183 subject.21842185 get_pusher()21862187 Return the username of the person who pushed the changes.2188 This value is used in the email body to indicate who2189 pushed the change.21902191 get_pusher_email() (may return None)21922193 Return the email address of the person who pushed the2194 changes. The value should be a single RFC 2822 email2195 address as a string; e.g., "Joe User <user@example.com>"2196 if available, otherwise "user@example.com". If set, the2197 value is used as the Reply-To address for refchange2198 emails. If it is impossible to determine the pusher's2199 email, this attribute should be set to None (in which case2200 no Reply-To header will be output).22012202 get_sender()22032204 Return the address to be used as the 'From' email address2205 in the email envelope.22062207 get_fromaddr(change=None)22082209 Return the 'From' email address used in the email 'From:'2210 headers. If the change is known when this function is2211 called, it is passed in as the 'change' parameter. (May2212 be a full RFC 2822 email address like 'Joe User2213 <user@example.com>'.)22142215 get_administrator()22162217 Return the name and/or email of the repository2218 administrator. This value is used in the footer as the2219 person to whom requests to be removed from the2220 notification list should be sent. Ideally, it should2221 include a valid email address.22222223 get_reply_to_refchange()2224 get_reply_to_commit()22252226 Return the address to use in the email "Reply-To" header,2227 as a string. These can be an RFC 2822 email address, or2228 None to omit the "Reply-To" header.2229 get_reply_to_refchange() is used for refchange emails;2230 get_reply_to_commit() is used for individual commit2231 emails.22322233 get_ref_filter_regex()22342235 Return a tuple -- a compiled regex, and a boolean indicating2236 whether the regex picks refs to include (if False, the regex2237 matches on refs to exclude).22382239 get_default_ref_ignore_regex()22402241 Return a regex that should be ignored for both what emails2242 to send and when computing what commits are considered new2243 to the repository. Default is "^refs/notes/".22442245 They should also define the following attributes:22462247 announce_show_shortlog (bool)22482249 True iff announce emails should include a shortlog.22502251 commit_email_format (string)22522253 If "html", generate commit emails in HTML instead of plain text2254 used by default.22552256 html_in_intro (bool)2257 html_in_footer (bool)22582259 When generating HTML emails, the introduction (respectively,2260 the footer) will be HTML-escaped iff html_in_intro (respectively,2261 the footer) is true. When false, only the values used to expand2262 the template are escaped.22632264 refchange_showgraph (bool)22652266 True iff refchanges emails should include a detailed graph.22672268 refchange_showlog (bool)22692270 True iff refchanges emails should include a detailed log.22712272 diffopts (list of strings)22732274 The options that should be passed to 'git diff' for the2275 summary email. The value should be a list of strings2276 representing words to be passed to the command.22772278 graphopts (list of strings)22792280 Analogous to diffopts, but contains options passed to2281 'git log --graph' when generating the detailed graph for2282 a set of commits (see refchange_showgraph)22832284 logopts (list of strings)22852286 Analogous to diffopts, but contains options passed to2287 'git log' when generating the detailed log for a set of2288 commits (see refchange_showlog)22892290 commitlogopts (list of strings)22912292 The options that should be passed to 'git log' for each2293 commit mail. The value should be a list of strings2294 representing words to be passed to the command.22952296 date_substitute (string)22972298 String to be used in substitution for 'Date:' at start of2299 line in the output of 'git log'.23002301 quiet (bool)2302 On success do not write to stderr23032304 stdout (bool)2305 Write email to stdout rather than emailing. Useful for debugging23062307 combine_when_single_commit (bool)23082309 True if a combined email should be produced when a single2310 new commit is pushed to a branch, False otherwise.23112312 from_refchange, from_commit (strings)23132314 Addresses to use for the From: field for refchange emails2315 and commit emails respectively. Set from2316 multimailhook.fromRefchange and multimailhook.fromCommit2317 by ConfigEnvironmentMixin.23182319 """23202321 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')23222323def__init__(self, osenv=None):2324 self.osenv = osenv or os.environ2325 self.announce_show_shortlog =False2326 self.commit_email_format ="text"2327 self.html_in_intro =False2328 self.html_in_footer =False2329 self.commitBrowseURL =None2330 self.maxcommitemails =5002331 self.diffopts = ['--stat','--summary','--find-copies-harder']2332 self.graphopts = ['--oneline','--decorate']2333 self.logopts = []2334 self.refchange_showgraph =False2335 self.refchange_showlog =False2336 self.commitlogopts = ['-C','--stat','-p','--cc']2337 self.date_substitute ='AuthorDate: '2338 self.quiet =False2339 self.stdout =False2340 self.combine_when_single_commit =True23412342 self.COMPUTED_KEYS = [2343'administrator',2344'charset',2345'emailprefix',2346'pusher',2347'pusher_email',2348'repo_path',2349'repo_shortname',2350'sender',2351]23522353 self._values =None23542355defget_repo_shortname(self):2356"""Use the last part of the repo path, with ".git" stripped off if present."""23572358 basename = os.path.basename(os.path.abspath(self.get_repo_path()))2359 m = self.REPO_NAME_RE.match(basename)2360if m:2361return m.group('name')2362else:2363return basename23642365defget_pusher(self):2366raiseNotImplementedError()23672368defget_pusher_email(self):2369return None23702371defget_fromaddr(self, change=None):2372 config =Config('user')2373 fromname = config.get('name', default='')2374 fromemail = config.get('email', default='')2375if fromemail:2376returnformataddr([fromname, fromemail])2377return self.get_sender()23782379defget_administrator(self):2380return'the administrator of this repository'23812382defget_emailprefix(self):2383return''23842385defget_repo_path(self):2386ifread_git_output(['rev-parse','--is-bare-repository']) =='true':2387 path =get_git_dir()2388else:2389 path =read_git_output(['rev-parse','--show-toplevel'])2390return os.path.abspath(path)23912392defget_charset(self):2393return CHARSET23942395defget_values(self):2396"""Return a dictionary{keyword: expansion}for this Environment.23972398 This method is called by Change._compute_values(). The keys2399 in the returned dictionary are available to be used in any of2400 the templates. The dictionary is created by calling2401 self.get_NAME() for each of the attributes named in2402 COMPUTED_KEYS and recording those that do not return None.2403 The return value is always a new dictionary."""24042405if self._values is None:2406 values = {'': ''} # %()s expands to the empty string.24072408for key in self.COMPUTED_KEYS:2409 value =getattr(self,'get_%s'% (key,))()2410if value is not None:2411 values[key] = value24122413 self._values = values24142415return self._values.copy()24162417defget_refchange_recipients(self, refchange):2418"""Return the recipients for notifications about refchange.24192420 Return the list of email addresses to which notifications2421 about the specified ReferenceChange should be sent."""24222423raiseNotImplementedError()24242425defget_announce_recipients(self, annotated_tag_change):2426"""Return the recipients for notifications about annotated_tag_change.24272428 Return the list of email addresses to which notifications2429 about the specified AnnotatedTagChange should be sent."""24302431raiseNotImplementedError()24322433defget_reply_to_refchange(self, refchange):2434return self.get_pusher_email()24352436defget_revision_recipients(self, revision):2437"""Return the recipients for messages about revision.24382439 Return the list of email addresses to which notifications2440 about the specified Revision should be sent. This method2441 could be overridden, for example, to take into account the2442 contents of the revision when deciding whom to notify about2443 it. For example, there could be a scheme for users to express2444 interest in particular files or subdirectories, and only2445 receive notification emails for revisions that affecting those2446 files."""24472448raiseNotImplementedError()24492450defget_reply_to_commit(self, revision):2451return revision.author24522453defget_default_ref_ignore_regex(self):2454# The commit messages of git notes are essentially meaningless2455# and "filenames" in git notes commits are an implementational2456# detail that might surprise users at first. As such, we2457# would need a completely different method for handling emails2458# of git notes in order for them to be of benefit for users,2459# which we simply do not have right now.2460return"^refs/notes/"24612462deffilter_body(self, lines):2463"""Filter the lines intended for an email body.24642465 lines is an iterable over the lines that would go into the2466 email body. Filter it (e.g., limit the number of lines, the2467 line length, character set, etc.), returning another iterable.2468 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin2469 for classes implementing this functionality."""24702471return lines24722473deflog_msg(self, msg):2474"""Write the string msg on a log file or on stderr.24752476 Sends the text to stderr by default, override to change the behavior."""2477write_str(sys.stderr, msg)24782479deflog_warning(self, msg):2480"""Write the string msg on a log file or on stderr.24812482 Sends the text to stderr by default, override to change the behavior."""2483write_str(sys.stderr, msg)24842485deflog_error(self, msg):2486"""Write the string msg on a log file or on stderr.24872488 Sends the text to stderr by default, override to change the behavior."""2489write_str(sys.stderr, msg)249024912492classConfigEnvironmentMixin(Environment):2493"""A mixin that sets self.config to its constructor's config argument.24942495 This class's constructor consumes the "config" argument.24962497 Mixins that need to inspect the config should inherit from this2498 class (1) to make sure that "config" is still in the constructor2499 arguments with its own constructor runs and/or (2) to be sure that2500 self.config is set after construction."""25012502def__init__(self, config, **kw):2503super(ConfigEnvironmentMixin, self).__init__(**kw)2504 self.config = config250525062507classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):2508"""An Environment that reads most of its information from "git config"."""25092510@staticmethod2511defforbid_field_values(name, value, forbidden):2512for forbidden_val in forbidden:2513if value is not None and value.lower() == forbidden:2514raiseConfigurationException(2515'"%s" is not an allowed setting for%s'% (value, name)2516)25172518def__init__(self, config, **kw):2519super(ConfigOptionsEnvironmentMixin, self).__init__(2520 config=config, **kw2521)25222523for var, cfg in(2524('announce_show_shortlog','announceshortlog'),2525('refchange_showgraph','refchangeShowGraph'),2526('refchange_showlog','refchangeshowlog'),2527('quiet','quiet'),2528('stdout','stdout'),2529):2530 val = config.get_bool(cfg)2531if val is not None:2532setattr(self, var, val)25332534 commit_email_format = config.get('commitEmailFormat')2535if commit_email_format is not None:2536if commit_email_format !="html"and commit_email_format !="text":2537 self.log_warning(2538'*** Unknown value for multimailhook.commitEmailFormat:%s\n'%2539 commit_email_format +2540'*** Expected either "text" or "html". Ignoring.\n'2541)2542else:2543 self.commit_email_format = commit_email_format25442545 html_in_intro = config.get_bool('htmlInIntro')2546if html_in_intro is not None:2547 self.html_in_intro = html_in_intro25482549 html_in_footer = config.get_bool('htmlInFooter')2550if html_in_footer is not None:2551 self.html_in_footer = html_in_footer25522553 self.commitBrowseURL = config.get('commitBrowseURL')25542555 maxcommitemails = config.get('maxcommitemails')2556if maxcommitemails is not None:2557try:2558 self.maxcommitemails =int(maxcommitemails)2559exceptValueError:2560 self.log_warning(2561'*** Malformed value for multimailhook.maxCommitEmails:%s\n'2562% maxcommitemails +2563'*** Expected a number. Ignoring.\n'2564)25652566 diffopts = config.get('diffopts')2567if diffopts is not None:2568 self.diffopts = shlex.split(diffopts)25692570 graphopts = config.get('graphOpts')2571if graphopts is not None:2572 self.graphopts = shlex.split(graphopts)25732574 logopts = config.get('logopts')2575if logopts is not None:2576 self.logopts = shlex.split(logopts)25772578 commitlogopts = config.get('commitlogopts')2579if commitlogopts is not None:2580 self.commitlogopts = shlex.split(commitlogopts)25812582 date_substitute = config.get('dateSubstitute')2583if date_substitute =='none':2584 self.date_substitute =None2585elif date_substitute is not None:2586 self.date_substitute = date_substitute25872588 reply_to = config.get('replyTo')2589 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)2590 self.forbid_field_values('replyToRefchange',2591 self.__reply_to_refchange,2592['author'])2593 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)25942595 self.from_refchange = config.get('fromRefchange')2596 self.forbid_field_values('fromRefchange',2597 self.from_refchange,2598['author','none'])2599 self.from_commit = config.get('fromCommit')2600 self.forbid_field_values('fromCommit',2601 self.from_commit,2602['none'])26032604 combine = config.get_bool('combineWhenSingleCommit')2605if combine is not None:2606 self.combine_when_single_commit = combine26072608defget_administrator(self):2609return(2610 self.config.get('administrator')or2611 self.get_sender()or2612super(ConfigOptionsEnvironmentMixin, self).get_administrator()2613)26142615defget_repo_shortname(self):2616return(2617 self.config.get('reponame')or2618super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()2619)26202621defget_emailprefix(self):2622 emailprefix = self.config.get('emailprefix')2623if emailprefix is not None:2624 emailprefix = emailprefix.strip()2625if emailprefix:2626return emailprefix +' '2627else:2628return''2629else:2630return'[%s] '% (self.get_repo_shortname(),)26312632defget_sender(self):2633return self.config.get('envelopesender')26342635defprocess_addr(self, addr, change):2636if addr.lower() =='author':2637ifhasattr(change,'author'):2638return change.author2639else:2640return None2641elif addr.lower() =='pusher':2642return self.get_pusher_email()2643elif addr.lower() =='none':2644return None2645else:2646return addr26472648defget_fromaddr(self, change=None):2649 fromaddr = self.config.get('from')2650if change:2651 alt_fromaddr = change.get_alt_fromaddr()2652if alt_fromaddr:2653 fromaddr = alt_fromaddr2654if fromaddr:2655 fromaddr = self.process_addr(fromaddr, change)2656if fromaddr:2657return fromaddr2658returnsuper(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)26592660defget_reply_to_refchange(self, refchange):2661if self.__reply_to_refchange is None:2662returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)2663else:2664return self.process_addr(self.__reply_to_refchange, refchange)26652666defget_reply_to_commit(self, revision):2667if self.__reply_to_commit is None:2668returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)2669else:2670return self.process_addr(self.__reply_to_commit, revision)26712672defget_scancommitforcc(self):2673return self.config.get('scancommitforcc')267426752676classFilterLinesEnvironmentMixin(Environment):2677"""Handle encoding and maximum line length of body lines.26782679 emailmaxlinelength (int or None)26802681 The maximum length of any single line in the email body.2682 Longer lines are truncated at that length with ' [...]'2683 appended.26842685 strict_utf8 (bool)26862687 If this field is set to True, then the email body text is2688 expected to be UTF-8. Any invalid characters are2689 converted to U+FFFD, the Unicode replacement character2690 (encoded as UTF-8, of course).26912692 """26932694def__init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):2695super(FilterLinesEnvironmentMixin, self).__init__(**kw)2696 self.__strict_utf8= strict_utf82697 self.__emailmaxlinelength = emailmaxlinelength26982699deffilter_body(self, lines):2700 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)2701if self.__strict_utf8:2702if not PYTHON3:2703 lines = (line.decode(ENCODING,'replace')for line in lines)2704# Limit the line length in Unicode-space to avoid2705# splitting characters:2706if self.__emailmaxlinelength:2707 lines =limit_linelength(lines, self.__emailmaxlinelength)2708if not PYTHON3:2709 lines = (line.encode(ENCODING,'replace')for line in lines)2710elif self.__emailmaxlinelength:2711 lines =limit_linelength(lines, self.__emailmaxlinelength)27122713return lines271427152716classConfigFilterLinesEnvironmentMixin(2717 ConfigEnvironmentMixin,2718 FilterLinesEnvironmentMixin,2719):2720"""Handle encoding and maximum line length based on config."""27212722def__init__(self, config, **kw):2723 strict_utf8 = config.get_bool('emailstrictutf8', default=None)2724if strict_utf8 is not None:2725 kw['strict_utf8'] = strict_utf827262727 emailmaxlinelength = config.get('emailmaxlinelength')2728if emailmaxlinelength is not None:2729 kw['emailmaxlinelength'] =int(emailmaxlinelength)27302731super(ConfigFilterLinesEnvironmentMixin, self).__init__(2732 config=config, **kw2733)273427352736classMaxlinesEnvironmentMixin(Environment):2737"""Limit the email body to a specified number of lines."""27382739def__init__(self, emailmaxlines, **kw):2740super(MaxlinesEnvironmentMixin, self).__init__(**kw)2741 self.__emailmaxlines = emailmaxlines27422743deffilter_body(self, lines):2744 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)2745if self.__emailmaxlines:2746 lines =limit_lines(lines, self.__emailmaxlines)2747return lines274827492750classConfigMaxlinesEnvironmentMixin(2751 ConfigEnvironmentMixin,2752 MaxlinesEnvironmentMixin,2753):2754"""Limit the email body to the number of lines specified in config."""27552756def__init__(self, config, **kw):2757 emailmaxlines =int(config.get('emailmaxlines', default='0'))2758super(ConfigMaxlinesEnvironmentMixin, self).__init__(2759 config=config,2760 emailmaxlines=emailmaxlines,2761**kw2762)276327642765classFQDNEnvironmentMixin(Environment):2766"""A mixin that sets the host's FQDN to its constructor argument."""27672768def__init__(self, fqdn, **kw):2769super(FQDNEnvironmentMixin, self).__init__(**kw)2770 self.COMPUTED_KEYS += ['fqdn']2771 self.__fqdn = fqdn27722773defget_fqdn(self):2774"""Return the fully-qualified domain name for this host.27752776 Return None if it is unavailable or unwanted."""27772778return self.__fqdn277927802781classConfigFQDNEnvironmentMixin(2782 ConfigEnvironmentMixin,2783 FQDNEnvironmentMixin,2784):2785"""Read the FQDN from the config."""27862787def__init__(self, config, **kw):2788 fqdn = config.get('fqdn')2789super(ConfigFQDNEnvironmentMixin, self).__init__(2790 config=config,2791 fqdn=fqdn,2792**kw2793)279427952796classComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):2797"""Get the FQDN by calling socket.getfqdn()."""27982799def__init__(self, **kw):2800super(ComputeFQDNEnvironmentMixin, self).__init__(2801 fqdn=socket.getfqdn(),2802**kw2803)280428052806classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):2807"""Deduce pusher_email from pusher by appending an emaildomain."""28082809def__init__(self, **kw):2810super(PusherDomainEnvironmentMixin, self).__init__(**kw)2811 self.__emaildomain = self.config.get('emaildomain')28122813defget_pusher_email(self):2814if self.__emaildomain:2815# Derive the pusher's full email address in the default way:2816return'%s@%s'% (self.get_pusher(), self.__emaildomain)2817else:2818returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()281928202821classStaticRecipientsEnvironmentMixin(Environment):2822"""Set recipients statically based on constructor parameters."""28232824def__init__(2825 self,2826 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,2827**kw2828):2829super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)28302831# The recipients for various types of notification emails, as2832# RFC 2822 email addresses separated by commas (or the empty2833# string if no recipients are configured). Although there is2834# a mechanism to choose the recipient lists based on on the2835# actual *contents* of the change being reported, we only2836# choose based on the *type* of the change. Therefore we can2837# compute them once and for all:2838if not(refchange_recipients or2839 announce_recipients or2840 revision_recipients or2841 scancommitforcc):2842raiseConfigurationException('No email recipients configured!')2843 self.__refchange_recipients = refchange_recipients2844 self.__announce_recipients = announce_recipients2845 self.__revision_recipients = revision_recipients28462847defget_refchange_recipients(self, refchange):2848return self.__refchange_recipients28492850defget_announce_recipients(self, annotated_tag_change):2851return self.__announce_recipients28522853defget_revision_recipients(self, revision):2854return self.__revision_recipients285528562857classConfigRecipientsEnvironmentMixin(2858 ConfigEnvironmentMixin,2859 StaticRecipientsEnvironmentMixin2860):2861"""Determine recipients statically based on config."""28622863def__init__(self, config, **kw):2864super(ConfigRecipientsEnvironmentMixin, self).__init__(2865 config=config,2866 refchange_recipients=self._get_recipients(2867 config,'refchangelist','mailinglist',2868),2869 announce_recipients=self._get_recipients(2870 config,'announcelist','refchangelist','mailinglist',2871),2872 revision_recipients=self._get_recipients(2873 config,'commitlist','mailinglist',2874),2875 scancommitforcc=config.get('scancommitforcc'),2876**kw2877)28782879def_get_recipients(self, config, *names):2880"""Return the recipients for a particular type of message.28812882 Return the list of email addresses to which a particular type2883 of notification email should be sent, by looking at the config2884 value for "multimailhook.$name" for each of names. Use the2885 value from the first name that is configured. The return2886 value is a (possibly empty) string containing RFC 2822 email2887 addresses separated by commas. If no configuration could be2888 found, raise a ConfigurationException."""28892890for name in names:2891 lines = config.get_all(name)2892if lines is not None:2893 lines = [line.strip()for line in lines]2894# Single "none" is a special value equivalen to empty string.2895if lines == ['none']:2896 lines = ['']2897return', '.join(lines)2898else:2899return''290029012902classStaticRefFilterEnvironmentMixin(Environment):2903"""Set branch filter statically based on constructor parameters."""29042905def__init__(self, ref_filter_incl_regex, ref_filter_excl_regex,2906 ref_filter_do_send_regex, ref_filter_dont_send_regex,2907**kw):2908super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)29092910if ref_filter_incl_regex and ref_filter_excl_regex:2911raiseConfigurationException(2912"Cannot specify both a ref inclusion and exclusion regex.")2913 self.__is_inclusion_filter =bool(ref_filter_incl_regex)2914 default_exclude = self.get_default_ref_ignore_regex()2915if ref_filter_incl_regex:2916 ref_filter_regex = ref_filter_incl_regex2917elif ref_filter_excl_regex:2918 ref_filter_regex = ref_filter_excl_regex +'|'+ default_exclude2919else:2920 ref_filter_regex = default_exclude2921try:2922 self.__compiled_regex = re.compile(ref_filter_regex)2923exceptException:2924raiseConfigurationException(2925'Invalid Ref Filter Regex "%s":%s'% (ref_filter_regex, sys.exc_info()[1]))29262927if ref_filter_do_send_regex and ref_filter_dont_send_regex:2928raiseConfigurationException(2929"Cannot specify both a ref doSend and dontSend regex.")2930if ref_filter_do_send_regex or ref_filter_dont_send_regex:2931 self.__is_do_send_filter =bool(ref_filter_do_send_regex)2932if ref_filter_incl_regex:2933 ref_filter_send_regex = ref_filter_incl_regex2934elif ref_filter_excl_regex:2935 ref_filter_send_regex = ref_filter_excl_regex2936else:2937 ref_filter_send_regex ='.*'2938 self.__is_do_send_filter =True2939try:2940 self.__send_compiled_regex = re.compile(ref_filter_send_regex)2941exceptException:2942raiseConfigurationException(2943'Invalid Ref Filter Regex "%s":%s'%2944(ref_filter_send_regex, sys.exc_info()[1]))2945else:2946 self.__send_compiled_regex = self.__compiled_regex2947 self.__is_do_send_filter = self.__is_inclusion_filter29482949defget_ref_filter_regex(self, send_filter=False):2950if send_filter:2951return self.__send_compiled_regex, self.__is_do_send_filter2952else:2953return self.__compiled_regex, self.__is_inclusion_filter295429552956classConfigRefFilterEnvironmentMixin(2957 ConfigEnvironmentMixin,2958 StaticRefFilterEnvironmentMixin2959):2960"""Determine branch filtering statically based on config."""29612962def_get_regex(self, config, key):2963"""Get a list of whitespace-separated regex. The refFilter* config2964 variables are multivalued (hence the use of get_all), and we2965 allow each entry to be a whitespace-separated list (hence the2966 split on each line). The whole thing is glued into a single regex."""2967 values = config.get_all(key)2968if values is None:2969return values2970 items = []2971for line in values:2972for i in line.split():2973 items.append(i)2974if items == []:2975return None2976return'|'.join(items)29772978def__init__(self, config, **kw):2979super(ConfigRefFilterEnvironmentMixin, self).__init__(2980 config=config,2981 ref_filter_incl_regex=self._get_regex(config,'refFilterInclusionRegex'),2982 ref_filter_excl_regex=self._get_regex(config,'refFilterExclusionRegex'),2983 ref_filter_do_send_regex=self._get_regex(config,'refFilterDoSendRegex'),2984 ref_filter_dont_send_regex=self._get_regex(config,'refFilterDontSendRegex'),2985**kw2986)298729882989classProjectdescEnvironmentMixin(Environment):2990"""Make a "projectdesc" value available for templates.29912992 By default, it is set to the first line of $GIT_DIR/description2993 (if that file is present and appears to be set meaningfully)."""29942995def__init__(self, **kw):2996super(ProjectdescEnvironmentMixin, self).__init__(**kw)2997 self.COMPUTED_KEYS += ['projectdesc']29982999defget_projectdesc(self):3000"""Return a one-line descripition of the project."""30013002 git_dir =get_git_dir()3003try:3004 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()3005if projectdesc and not projectdesc.startswith('Unnamed repository'):3006return projectdesc3007exceptIOError:3008pass30093010return'UNNAMED PROJECT'301130123013classGenericEnvironmentMixin(Environment):3014defget_pusher(self):3015return self.osenv.get('USER', self.osenv.get('USERNAME','unknown user'))301630173018classGenericEnvironment(3019 ProjectdescEnvironmentMixin,3020 ConfigMaxlinesEnvironmentMixin,3021 ComputeFQDNEnvironmentMixin,3022 ConfigFilterLinesEnvironmentMixin,3023 ConfigRecipientsEnvironmentMixin,3024 ConfigRefFilterEnvironmentMixin,3025 PusherDomainEnvironmentMixin,3026 ConfigOptionsEnvironmentMixin,3027 GenericEnvironmentMixin,3028 Environment,3029):3030pass303130323033classGitoliteEnvironmentMixin(Environment):3034defget_repo_shortname(self):3035# The gitolite environment variable $GL_REPO is a pretty good3036# repo_shortname (though it's probably not as good as a value3037# the user might have explicitly put in his config).3038return(3039 self.osenv.get('GL_REPO',None)or3040super(GitoliteEnvironmentMixin, self).get_repo_shortname()3041)30423043defget_pusher(self):3044return self.osenv.get('GL_USER','unknown user')30453046defget_fromaddr(self, change=None):3047 GL_USER = self.osenv.get('GL_USER')3048if GL_USER is not None:3049# Find the path to gitolite.conf. Note that gitolite v33050# did away with the GL_ADMINDIR and GL_CONF environment3051# variables (they are now hard-coded).3052 GL_ADMINDIR = self.osenv.get(3053'GL_ADMINDIR',3054 os.path.expanduser(os.path.join('~','.gitolite')))3055 GL_CONF = self.osenv.get(3056'GL_CONF',3057 os.path.join(GL_ADMINDIR,'conf','gitolite.conf'))3058if os.path.isfile(GL_CONF):3059 f =open(GL_CONF,'rU')3060try:3061 in_user_emails_section =False3062 re_template = r'^\s*#\s*%s\s*$'3063 re_begin, re_user, re_end = (3064 re.compile(re_template % x)3065for x in(3066 r'BEGIN\s+USER\s+EMAILS',3067 re.escape(GL_USER) + r'\s+(.*)',3068 r'END\s+USER\s+EMAILS',3069))3070for l in f:3071 l = l.rstrip('\n')3072if not in_user_emails_section:3073if re_begin.match(l):3074 in_user_emails_section =True3075continue3076if re_end.match(l):3077break3078 m = re_user.match(l)3079if m:3080return m.group(1)3081finally:3082 f.close()3083returnsuper(GitoliteEnvironmentMixin, self).get_fromaddr(change)308430853086classIncrementalDateTime(object):3087"""Simple wrapper to give incremental date/times.30883089 Each call will result in a date/time a second later than the3090 previous call. This can be used to falsify email headers, to3091 increase the likelihood that email clients sort the emails3092 correctly."""30933094def__init__(self):3095 self.time = time.time()3096 self.next = self.__next__# Python 2 backward compatibility30973098def__next__(self):3099 formatted =formatdate(self.time,True)3100 self.time +=13101return formatted310231033104classGitoliteEnvironment(3105 ProjectdescEnvironmentMixin,3106 ConfigMaxlinesEnvironmentMixin,3107 ComputeFQDNEnvironmentMixin,3108 ConfigFilterLinesEnvironmentMixin,3109 ConfigRecipientsEnvironmentMixin,3110 ConfigRefFilterEnvironmentMixin,3111 PusherDomainEnvironmentMixin,3112 ConfigOptionsEnvironmentMixin,3113 GitoliteEnvironmentMixin,3114 Environment,3115):3116pass311731183119classStashEnvironmentMixin(Environment):3120def__init__(self, user=None, repo=None, **kw):3121super(StashEnvironmentMixin, self).__init__(**kw)3122 self.__user = user3123 self.__repo = repo31243125defget_repo_shortname(self):3126return self.__repo31273128defget_pusher(self):3129return re.match('(.*?)\s*<', self.__user).group(1)31303131defget_pusher_email(self):3132return self.__user31333134defget_fromaddr(self, change=None):3135return self.__user313631373138classStashEnvironment(3139 StashEnvironmentMixin,3140 ProjectdescEnvironmentMixin,3141 ConfigMaxlinesEnvironmentMixin,3142 ComputeFQDNEnvironmentMixin,3143 ConfigFilterLinesEnvironmentMixin,3144 ConfigRecipientsEnvironmentMixin,3145 ConfigRefFilterEnvironmentMixin,3146 PusherDomainEnvironmentMixin,3147 ConfigOptionsEnvironmentMixin,3148 Environment,3149):3150pass315131523153classGerritEnvironmentMixin(Environment):3154def__init__(self, project=None, submitter=None, update_method=None, **kw):3155super(GerritEnvironmentMixin, self).__init__(**kw)3156 self.__project = project3157 self.__submitter = submitter3158 self.__update_method = update_method3159"Make an 'update_method' value available for templates."3160 self.COMPUTED_KEYS += ['update_method']31613162defget_repo_shortname(self):3163return self.__project31643165defget_pusher(self):3166if self.__submitter:3167if self.__submitter.find('<') != -1:3168# Submitter has a configured email, we transformed3169# __submitter into an RFC 2822 string already.3170return re.match('(.*?)\s*<', self.__submitter).group(1)3171else:3172# Submitter has no configured email, it's just his name.3173return self.__submitter3174else:3175# If we arrive here, this means someone pushed "Submit" from3176# the gerrit web UI for the CR (or used one of the programmatic3177# APIs to do the same, such as gerrit review) and the3178# merge/push was done by the Gerrit user. It was technically3179# triggered by someone else, but sadly we have no way of3180# determining who that someone else is at this point.3181return'Gerrit'# 'unknown user'?31823183defget_pusher_email(self):3184if self.__submitter:3185return self.__submitter3186else:3187returnsuper(GerritEnvironmentMixin, self).get_pusher_email()31883189defget_fromaddr(self, change=None):3190if self.__submitter and self.__submitter.find('<') != -1:3191return self.__submitter3192else:3193returnsuper(GerritEnvironmentMixin, self).get_fromaddr(change)31943195defget_default_ref_ignore_regex(self):3196 default =super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()3197return default +'|^refs/changes/|^refs/cache-automerge/|^refs/meta/'31983199defget_revision_recipients(self, revision):3200# Merge commits created by Gerrit when users hit "Submit this patchset"3201# in the Web UI (or do equivalently with REST APIs or the gerrit review3202# command) are not something users want to see an individual email for.3203# Filter them out.3204 committer =read_git_output(['log','--no-walk','--format=%cN',3205 revision.rev.sha1])3206if committer =='Gerrit Code Review':3207return[]3208else:3209returnsuper(GerritEnvironmentMixin, self).get_revision_recipients(revision)32103211defget_update_method(self):3212return self.__update_method321332143215classGerritEnvironment(3216 GerritEnvironmentMixin,3217 ProjectdescEnvironmentMixin,3218 ConfigMaxlinesEnvironmentMixin,3219 ComputeFQDNEnvironmentMixin,3220 ConfigFilterLinesEnvironmentMixin,3221 ConfigRecipientsEnvironmentMixin,3222 ConfigRefFilterEnvironmentMixin,3223 PusherDomainEnvironmentMixin,3224 ConfigOptionsEnvironmentMixin,3225 Environment,3226):3227pass322832293230classPush(object):3231"""Represent an entire push (i.e., a group of ReferenceChanges).32323233 It is easy to figure out what commits were added to a *branch* by3234 a Reference change:32353236 git rev-list change.old..change.new32373238 or removed from a *branch*:32393240 git rev-list change.new..change.old32413242 But it is not quite so trivial to determine which entirely new3243 commits were added to the *repository* by a push and which old3244 commits were discarded by a push. A big part of the job of this3245 class is to figure out these things, and to make sure that new3246 commits are only detailed once even if they were added to multiple3247 references.32483249 The first step is to determine the "other" references--those3250 unaffected by the current push. They are computed by listing all3251 references then removing any affected by this push. The results3252 are stored in Push._other_ref_sha1s.32533254 The commits contained in the repository before this push were32553256 git rev-list other1 other2 other3 ... change1.old change2.old ...32573258 Where "changeN.old" is the old value of one of the references3259 affected by this push.32603261 The commits contained in the repository after this push are32623263 git rev-list other1 other2 other3 ... change1.new change2.new ...32643265 The commits added by this push are the difference between these3266 two sets, which can be written32673268 git rev-list \3269 ^other1 ^other2 ... \3270 ^change1.old ^change2.old ... \3271 change1.new change2.new ...32723273 The commits removed by this push can be computed by32743275 git rev-list \3276 ^other1 ^other2 ... \3277 ^change1.new ^change2.new ... \3278 change1.old change2.old ...32793280 The last point is that it is possible that other pushes are3281 occurring simultaneously to this one, so reference values can3282 change at any time. It is impossible to eliminate all race3283 conditions, but we reduce the window of time during which problems3284 can occur by translating reference names to SHA1s as soon as3285 possible and working with SHA1s thereafter (because SHA1s are3286 immutable)."""32873288# A map {(changeclass, changetype): integer} specifying the order3289# that reference changes will be processed if multiple reference3290# changes are included in a single push. The order is significant3291# mostly because new commit notifications are threaded together3292# with the first reference change that includes the commit. The3293# following order thus causes commits to be grouped with branch3294# changes (as opposed to tag changes) if possible.3295 SORT_ORDER =dict(3296(value, i)for(i, value)inenumerate([3297(BranchChange,'update'),3298(BranchChange,'create'),3299(AnnotatedTagChange,'update'),3300(AnnotatedTagChange,'create'),3301(NonAnnotatedTagChange,'update'),3302(NonAnnotatedTagChange,'create'),3303(BranchChange,'delete'),3304(AnnotatedTagChange,'delete'),3305(NonAnnotatedTagChange,'delete'),3306(OtherReferenceChange,'update'),3307(OtherReferenceChange,'create'),3308(OtherReferenceChange,'delete'),3309])3310)33113312def__init__(self, environment, changes, ignore_other_refs=False):3313 self.changes =sorted(changes, key=self._sort_key)3314 self.__other_ref_sha1s =None3315 self.__cached_commits_spec = {}3316 self.environment = environment33173318if ignore_other_refs:3319 self.__other_ref_sha1s =set()33203321@classmethod3322def_sort_key(klass, change):3323return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)33243325@property3326def_other_ref_sha1s(self):3327"""The GitObjects referred to by references unaffected by this push.3328 """3329if self.__other_ref_sha1s is None:3330# The refnames being changed by this push:3331 updated_refs =set(3332 change.refname3333for change in self.changes3334)33353336# The SHA-1s of commits referred to by all references in this3337# repository *except* updated_refs:3338 sha1s =set()3339 fmt = (3340'%(objectname) %(objecttype) %(refname)\n'3341'%(*objectname) %(*objecttype)%(refname)'3342)3343 ref_filter_regex, is_inclusion_filter = \3344 self.environment.get_ref_filter_regex()3345for line inread_git_lines(3346['for-each-ref','--format=%s'% (fmt,)]):3347(sha1,type, name) = line.split(' ',2)3348if(sha1 andtype=='commit'and3349 name not in updated_refs and3350include_ref(name, ref_filter_regex, is_inclusion_filter)):3351 sha1s.add(sha1)33523353 self.__other_ref_sha1s = sha1s33543355return self.__other_ref_sha1s33563357def_get_commits_spec_incl(self, new_or_old, reference_change=None):3358"""Get new or old SHA-1 from one or each of the changed refs.33593360 Return a list of SHA-1 commit identifier strings suitable as3361 arguments to 'git rev-list' (or 'git log' or ...). The3362 returned identifiers are either the old or new values from one3363 or all of the changed references, depending on the values of3364 new_or_old and reference_change.33653366 new_or_old is either the string 'new' or the string 'old'. If3367 'new', the returned SHA-1 identifiers are the new values from3368 each changed reference. If 'old', the SHA-1 identifiers are3369 the old values from each changed reference.33703371 If reference_change is specified and not None, only the new or3372 old reference from the specified reference is included in the3373 return value.33743375 This function returns None if there are no matching revisions3376 (e.g., because a branch was deleted and new_or_old is 'new').3377 """33783379if not reference_change:3380 incl_spec =sorted(3381getattr(change, new_or_old).sha13382for change in self.changes3383ifgetattr(change, new_or_old)3384)3385if not incl_spec:3386 incl_spec =None3387elif notgetattr(reference_change, new_or_old).commit_sha1:3388 incl_spec =None3389else:3390 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]3391return incl_spec33923393def_get_commits_spec_excl(self, new_or_old):3394"""Get exclusion revisions for determining new or discarded commits.33953396 Return a list of strings suitable as arguments to 'git3397 rev-list' (or 'git log' or ...) that will exclude all3398 commits that, depending on the value of new_or_old, were3399 either previously in the repository (useful for determining3400 which commits are new to the repository) or currently in the3401 repository (useful for determining which commits were3402 discarded from the repository).34033404 new_or_old is either the string 'new' or the string 'old'. If3405 'new', the commits to be excluded are those that were in the3406 repository before the push. If 'old', the commits to be3407 excluded are those that are currently in the repository. """34083409 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]3410 excl_revs = self._other_ref_sha1s.union(3411getattr(change, old_or_new).sha13412for change in self.changes3413ifgetattr(change, old_or_new).typein['commit','tag']3414)3415return['^'+ sha1 for sha1 insorted(excl_revs)]34163417defget_commits_spec(self, new_or_old, reference_change=None):3418"""Get rev-list arguments for added or discarded commits.34193420 Return a list of strings suitable as arguments to 'git3421 rev-list' (or 'git log' or ...) that select those commits3422 that, depending on the value of new_or_old, are either new to3423 the repository or were discarded from the repository.34243425 new_or_old is either the string 'new' or the string 'old'. If3426 'new', the returned list is used to select commits that are3427 new to the repository. If 'old', the returned value is used3428 to select the commits that have been discarded from the3429 repository.34303431 If reference_change is specified and not None, the new or3432 discarded commits are limited to those that are reachable from3433 the new or old value of the specified reference.34343435 This function returns None if there are no added (or discarded)3436 revisions.3437 """3438 key = (new_or_old, reference_change)3439if key not in self.__cached_commits_spec:3440 ret = self._get_commits_spec_incl(new_or_old, reference_change)3441if ret is not None:3442 ret.extend(self._get_commits_spec_excl(new_or_old))3443 self.__cached_commits_spec[key] = ret3444return self.__cached_commits_spec[key]34453446defget_new_commits(self, reference_change=None):3447"""Return a list of commits added by this push.34483449 Return a list of the object names of commits that were added3450 by the part of this push represented by reference_change. If3451 reference_change is None, then return a list of *all* commits3452 added by this push."""34533454 spec = self.get_commits_spec('new', reference_change)3455returngit_rev_list(spec)34563457defget_discarded_commits(self, reference_change):3458"""Return a list of commits discarded by this push.34593460 Return a list of the object names of commits that were3461 entirely discarded from the repository by the part of this3462 push represented by reference_change."""34633464 spec = self.get_commits_spec('old', reference_change)3465returngit_rev_list(spec)34663467defsend_emails(self, mailer, body_filter=None):3468"""Use send all of the notification emails needed for this push.34693470 Use send all of the notification emails (including reference3471 change emails and commit emails) needed for this push. Send3472 the emails using mailer. If body_filter is not None, then use3473 it to filter the lines that are intended for the email3474 body."""34753476# The sha1s of commits that were introduced by this push.3477# They will be removed from this set as they are processed, to3478# guarantee that one (and only one) email is generated for3479# each new commit.3480 unhandled_sha1s =set(self.get_new_commits())3481 send_date =IncrementalDateTime()3482for change in self.changes:3483 sha1s = []3484for sha1 inreversed(list(self.get_new_commits(change))):3485if sha1 in unhandled_sha1s:3486 sha1s.append(sha1)3487 unhandled_sha1s.remove(sha1)34883489# Check if we've got anyone to send to3490if not change.recipients:3491 change.environment.log_warning(3492'*** no recipients configured so no email will be sent\n'3493'*** for%rupdate%s->%s\n'3494% (change.refname, change.old.sha1, change.new.sha1,)3495)3496else:3497if not change.environment.quiet:3498 change.environment.log_msg(3499'Sending notification emails to:%s\n'% (change.recipients,))3500 extra_values = {'send_date': next(send_date)}35013502 rev = change.send_single_combined_email(sha1s)3503if rev:3504 mailer.send(3505 change.generate_combined_email(self, rev, body_filter, extra_values),3506 rev.recipients,3507)3508# This change is now fully handled; no need to handle3509# individual revisions any further.3510continue3511else:3512 mailer.send(3513 change.generate_email(self, body_filter, extra_values),3514 change.recipients,3515)35163517 max_emails = change.environment.maxcommitemails3518if max_emails andlen(sha1s) > max_emails:3519 change.environment.log_warning(3520'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s) +3521'*** Try setting multimailhook.maxCommitEmails to a greater value\n'+3522'*** Currently, multimailhook.maxCommitEmails=%d\n'% max_emails3523)3524return35253526for(num, sha1)inenumerate(sha1s):3527 rev =Revision(change,GitObject(sha1), num=num +1, tot=len(sha1s))3528if not rev.recipients and rev.cc_recipients:3529 change.environment.log_msg('*** Replacing Cc: with To:\n')3530 rev.recipients = rev.cc_recipients3531 rev.cc_recipients =None3532if rev.recipients:3533 extra_values = {'send_date': next(send_date)}3534 mailer.send(3535 rev.generate_email(self, body_filter, extra_values),3536 rev.recipients,3537)35383539# Consistency check:3540if unhandled_sha1s:3541 change.environment.log_error(3542'ERROR: No emails were sent for the following new commits:\n'3543'%s\n'3544% ('\n'.join(sorted(unhandled_sha1s)),)3545)354635473548definclude_ref(refname, ref_filter_regex, is_inclusion_filter):3549 does_match =bool(ref_filter_regex.search(refname))3550if is_inclusion_filter:3551return does_match3552else:# exclusion filter -- we include the ref if the regex doesn't match3553return not does_match355435553556defrun_as_post_receive_hook(environment, mailer):3557 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)3558 changes = []3559for line in sys.stdin:3560(oldrev, newrev, refname) = line.strip().split(' ',2)3561if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3562continue3563 changes.append(3564 ReferenceChange.create(environment, oldrev, newrev, refname)3565)3566if changes:3567 push =Push(environment, changes)3568 push.send_emails(mailer, body_filter=environment.filter_body)3569ifhasattr(mailer,'__del__'):3570 mailer.__del__()357135723573defrun_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):3574 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)3575if notinclude_ref(refname, ref_filter_regex, is_inclusion_filter):3576return3577 changes = [3578 ReferenceChange.create(3579 environment,3580read_git_output(['rev-parse','--verify', oldrev]),3581read_git_output(['rev-parse','--verify', newrev]),3582 refname,3583),3584]3585 push =Push(environment, changes, force_send)3586 push.send_emails(mailer, body_filter=environment.filter_body)3587ifhasattr(mailer,'__del__'):3588 mailer.__del__()358935903591defchoose_mailer(config, environment):3592 mailer = config.get('mailer', default='sendmail')35933594if mailer =='smtp':3595 smtpserver = config.get('smtpserver', default='localhost')3596 smtpservertimeout =float(config.get('smtpservertimeout', default=10.0))3597 smtpserverdebuglevel =int(config.get('smtpserverdebuglevel', default=0))3598 smtpencryption = config.get('smtpencryption', default='none')3599 smtpuser = config.get('smtpuser', default='')3600 smtppass = config.get('smtppass', default='')3601 smtpcacerts = config.get('smtpcacerts', default='')3602 mailer =SMTPMailer(3603 envelopesender=(environment.get_sender()or environment.get_fromaddr()),3604 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,3605 smtpserverdebuglevel=smtpserverdebuglevel,3606 smtpencryption=smtpencryption,3607 smtpuser=smtpuser,3608 smtppass=smtppass,3609 smtpcacerts=smtpcacerts3610)3611elif mailer =='sendmail':3612 command = config.get('sendmailcommand')3613if command:3614 command = shlex.split(command)3615 mailer =SendMailer(command=command, envelopesender=environment.get_sender())3616else:3617 environment.log_error(3618'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer +3619'please use one of "smtp" or "sendmail".\n'3620)3621 sys.exit(1)3622return mailer362336243625KNOWN_ENVIRONMENTS = {3626'generic': GenericEnvironmentMixin,3627'gitolite': GitoliteEnvironmentMixin,3628'stash': StashEnvironmentMixin,3629'gerrit': GerritEnvironmentMixin,3630}363136323633defchoose_environment(config, osenv=None, env=None, recipients=None,3634 hook_info=None):3635if not osenv:3636 osenv = os.environ36373638 environment_mixins = [3639 ConfigRefFilterEnvironmentMixin,3640 ProjectdescEnvironmentMixin,3641 ConfigMaxlinesEnvironmentMixin,3642 ComputeFQDNEnvironmentMixin,3643 ConfigFilterLinesEnvironmentMixin,3644 PusherDomainEnvironmentMixin,3645 ConfigOptionsEnvironmentMixin,3646]3647 environment_kw = {3648'osenv': osenv,3649'config': config,3650}36513652if not env:3653 env = config.get('environment')36543655if not env:3656if'GL_USER'in osenv and'GL_REPO'in osenv:3657 env ='gitolite'3658else:3659 env ='generic'36603661 environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])36623663if env =='stash':3664 environment_kw['user'] = hook_info['stash_user']3665 environment_kw['repo'] = hook_info['stash_repo']3666elif env =='gerrit':3667 environment_kw['project'] = hook_info['project']3668 environment_kw['submitter'] = hook_info['submitter']3669 environment_kw['update_method'] = hook_info['update_method']36703671if recipients:3672 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)3673 environment_kw['refchange_recipients'] = recipients3674 environment_kw['announce_recipients'] = recipients3675 environment_kw['revision_recipients'] = recipients3676 environment_kw['scancommitforcc'] = config.get('scancommitforcc')3677else:3678 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)36793680 environment_klass =type(3681'EffectiveEnvironment',3682tuple(environment_mixins) + (Environment,),3683{},3684)3685returnenvironment_klass(**environment_kw)368636873688defget_version():3689 oldcwd = os.getcwd()3690try:3691try:3692 os.chdir(os.path.dirname(os.path.realpath(__file__)))3693 git_version =read_git_output(['describe','--tags','HEAD'])3694if git_version == __version__:3695return git_version3696else:3697return'%s(%s)'% (__version__, git_version)3698except:3699pass3700finally:3701 os.chdir(oldcwd)3702return __version__370337043705defcompute_gerrit_options(options, args, required_gerrit_options):3706if None in required_gerrit_options:3707raiseSystemExit("Error: Specify all of --oldrev, --newrev, --refname, "3708"and --project; or none of them.")37093710if options.environment not in(None,'gerrit'):3711raiseSystemExit("Non-gerrit environments incompatible with --oldrev, "3712"--newrev, --refname, and --project")3713 options.environment ='gerrit'37143715if args:3716raiseSystemExit("Error: Positional parameters not allowed with "3717"--oldrev, --newrev, and --refname.")37183719# Gerrit oddly omits 'refs/heads/' in the refname when calling3720# ref-updated hook; put it back.3721 git_dir =get_git_dir()3722if(not os.path.exists(os.path.join(git_dir, options.refname))and3723 os.path.exists(os.path.join(git_dir,'refs','heads',3724 options.refname))):3725 options.refname ='refs/heads/'+ options.refname37263727# Convert each string option unicode for Python3.3728if PYTHON3:3729 opts = ['environment','recipients','oldrev','newrev','refname',3730'project','submitter','stash-user','stash-repo']3731for opt in opts:3732if nothasattr(options, opt):3733continue3734 obj =getattr(options, opt)3735if obj:3736 enc = obj.encode('utf-8','surrogateescape')3737 dec = enc.decode('utf-8','replace')3738setattr(options, opt, dec)37393740# New revisions can appear in a gerrit repository either due to someone3741# pushing directly (in which case options.submitter will be set), or they3742# can press "Submit this patchset" in the web UI for some CR (in which3743# case options.submitter will not be set and gerrit will not have provided3744# us the information about who pressed the button).3745#3746# Note for the nit-picky: I'm lumping in REST API calls and the ssh3747# gerrit review command in with "Submit this patchset" button, since they3748# have the same effect.3749if options.submitter:3750 update_method ='pushed'3751# The submitter argument is almost an RFC 2822 email address; change it3752# from 'User Name (email@domain)' to 'User Name <email@domain>' so it is3753 options.submitter = options.submitter.replace('(','<').replace(')','>')3754else:3755 update_method ='submitted'3756# Gerrit knew who submitted this patchset, but threw that information3757# away when it invoked this hook. However, *IF* Gerrit created a3758# merge to bring the patchset in (project 'Submit Type' is either3759# "Always Merge", or is "Merge if Necessary" and happens to be3760# necessary for this particular CR), then it will have the committer3761# of that merge be 'Gerrit Code Review' and the author will be the3762# person who requested the submission of the CR. Since this is fairly3763# likely for most gerrit installations (of a reasonable size), it's3764# worth the extra effort to try to determine the actual submitter.3765 rev_info =read_git_lines(['log','--no-walk','--merges',3766'--format=%cN%n%aN <%aE>', options.newrev])3767if rev_info and rev_info[0] =='Gerrit Code Review':3768 options.submitter = rev_info[1]37693770# We pass back refname, oldrev, newrev as args because then the3771# gerrit ref-updated hook is much like the git update hook3772return(options,3773[options.refname, options.oldrev, options.newrev],3774{'project': options.project,'submitter': options.submitter,3775'update_method': update_method})377637773778defcheck_hook_specific_args(options, args):3779# First check for stash arguments3780if(options.stash_user is None) != (options.stash_repo is None):3781raiseSystemExit("Error: Specify both of --stash-user and "3782"--stash-repo or neither.")3783if options.stash_user:3784 options.environment ='stash'3785return options, args, {'stash_user': options.stash_user,3786'stash_repo': options.stash_repo}37873788# Finally, check for gerrit specific arguments3789 required_gerrit_options = (options.oldrev, options.newrev, options.refname,3790 options.project)3791if required_gerrit_options != (None,) *4:3792returncompute_gerrit_options(options, args, required_gerrit_options)37933794# No special options in use, just return what we started with3795return options, args, {}379637973798defmain(args):3799 parser = optparse.OptionParser(3800 description=__doc__,3801 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',3802)38033804 parser.add_option(3805'--environment','--env', action='store',type='choice',3806 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,3807help=(3808'Choose type of environment is in use. Default is taken from '3809'multimailhook.environment if set; otherwise "generic".'3810),3811)3812 parser.add_option(3813'--stdout', action='store_true', default=False,3814help='Output emails to stdout rather than sending them.',3815)3816 parser.add_option(3817'--recipients', action='store', default=None,3818help='Set list of email recipients for all types of emails.',3819)3820 parser.add_option(3821'--show-env', action='store_true', default=False,3822help=(3823'Write to stderr the values determined for the environment '3824'(intended for debugging purposes).'3825),3826)3827 parser.add_option(3828'--force-send', action='store_true', default=False,3829help=(3830'Force sending refchange email when using as an update hook. '3831'This is useful to work around the unreliable new commits '3832'detection in this mode.'3833),3834)3835 parser.add_option(3836'-c', metavar="<name>=<value>", action='append',3837help=(3838'Pass a configuration parameter through to git. The value given '3839'will override values from configuration files. See the -c option '3840'of git(1) for more details. (Only works with git >= 1.7.3)'3841),3842)3843 parser.add_option(3844'--version','-v', action='store_true', default=False,3845help=(3846"Display git-multimail's version"3847),3848)3849# The following options permit this script to be run as a gerrit3850# ref-updated hook. See e.g.3851# code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt3852# We suppress help for these items, since these are specific to gerrit,3853# and we don't want users directly using them any way other than how the3854# gerrit ref-updated hook is called.3855 parser.add_option('--oldrev', action='store',help=optparse.SUPPRESS_HELP)3856 parser.add_option('--newrev', action='store',help=optparse.SUPPRESS_HELP)3857 parser.add_option('--refname', action='store',help=optparse.SUPPRESS_HELP)3858 parser.add_option('--project', action='store',help=optparse.SUPPRESS_HELP)3859 parser.add_option('--submitter', action='store',help=optparse.SUPPRESS_HELP)38603861# The following allow this to be run as a stash asynchronous post-receive3862# hook (almost identical to a git post-receive hook but triggered also for3863# merges of pull requests from the UI). We suppress help for these items,3864# since these are specific to stash.3865 parser.add_option('--stash-user', action='store',help=optparse.SUPPRESS_HELP)3866 parser.add_option('--stash-repo', action='store',help=optparse.SUPPRESS_HELP)38673868(options, args) = parser.parse_args(args)3869(options, args, hook_info) =check_hook_specific_args(options, args)38703871if options.version:3872 sys.stdout.write('git-multimail version '+get_version() +'\n')3873return38743875if options.c:3876 Config.add_config_parameters(options.c)38773878 config =Config('multimailhook')38793880try:3881 environment =choose_environment(3882 config, osenv=os.environ,3883 env=options.environment,3884 recipients=options.recipients,3885 hook_info=hook_info,3886)38873888if options.show_env:3889 sys.stderr.write('Environment values:\n')3890for(k, v)insorted(environment.get_values().items()):3891 sys.stderr.write('%s:%r\n'% (k, v))3892 sys.stderr.write('\n')38933894if options.stdout or environment.stdout:3895 mailer =OutputMailer(sys.stdout)3896else:3897 mailer =choose_mailer(config, environment)38983899# Dual mode: if arguments were specified on the command line, run3900# like an update hook; otherwise, run as a post-receive hook.3901if args:3902iflen(args) !=3:3903 parser.error('Need zero or three non-option arguments')3904(refname, oldrev, newrev) = args3905run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)3906else:3907run_as_post_receive_hook(environment, mailer)3908except ConfigurationException:3909 sys.exit(sys.exc_info()[1])3910exceptException:3911 t, e, tb = sys.exc_info()3912import traceback3913 sys.stdout.write('\n')3914 sys.stdout.write('Exception\''+ t.__name__+3915'\'raised. Please report this as a bug to\n')3916 sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')3917 sys.stdout.write('with the information below:\n\n')3918 sys.stdout.write('git-multimail version '+get_version() +'\n')3919 sys.stdout.write('Python version '+ sys.version +'\n')3920 traceback.print_exc(file=sys.stdout)3921 sys.exit(1)39223923if __name__ =='__main__':3924main(sys.argv[1:])