1#! /usr/bin/env python2 2 3# Copyright (c) 2015 Matthieu Moy and others 4# Copyright (c) 2012-2014 Michael Haggerty and others 5# Derived from contrib/hooks/post-receive-email, which is 6# Copyright (c) 2007 Andy Parkins 7# and also includes contributions by other authors. 8# 9# This file is part of git-multimail. 10# 11# git-multimail is free software: you can redistribute it and/or 12# modify it under the terms of the GNU General Public License version 13# 2 as published by the Free Software Foundation. 14# 15# This program is distributed in the hope that it will be useful, but 16# WITHOUT ANY WARRANTY; without even the implied warranty of 17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 18# General Public License for more details. 19# 20# You should have received a copy of the GNU General Public License 21# along with this program. If not, see 22# <http://www.gnu.org/licenses/>. 23 24"""Generate notification emails for pushes to a git repository. 25 26This hook sends emails describing changes introduced by pushes to a 27git repository. For each reference that was changed, it emits one 28ReferenceChange email summarizing how the reference was changed, 29followed by one Revision email for each new commit that was introduced 30by the reference change. 31 32Each commit is announced in exactly one Revision email. If the same 33commit is merged into another branch in the same or a later push, then 34the ReferenceChange email will list the commit's SHA1 and its one-line 35summary, but no new Revision email will be generated. 36 37This script is designed to be used as a "post-receive" hook in a git 38repository (see githooks(5)). It can also be used as an "update" 39script, but this usage is not completely reliable and is deprecated. 40 41To help with debugging, this script accepts a --stdout option, which 42causes the emails to be written to standard output rather than sent 43using sendmail. 44 45See the accompanying README file for the complete documentation. 46 47""" 48 49import sys 50import os 51import re 52import bisect 53import socket 54import subprocess 55import shlex 56import optparse 57import smtplib 58import time 59 60try: 61from email.utils import make_msgid 62from email.utils import getaddresses 63from email.utils import formataddr 64from email.utils import formatdate 65from email.header import Header 66exceptImportError: 67# Prior to Python 2.5, the email module used different names: 68from email.Utils import make_msgid 69from email.Utils import getaddresses 70from email.Utils import formataddr 71from email.Utils import formatdate 72from email.Header import Header 73 74 75DEBUG =False 76 77ZEROS ='0'*40 78LOGBEGIN ='- Log -----------------------------------------------------------------\n' 79LOGEND ='-----------------------------------------------------------------------\n' 80 81ADDR_HEADERS =set(['from','to','cc','bcc','reply-to','sender']) 82 83# It is assumed in many places that the encoding is uniformly UTF-8, 84# so changing these constants is unsupported. But define them here 85# anyway, to make it easier to find (at least most of) the places 86# where the encoding is important. 87(ENCODING, CHARSET) = ('UTF-8','utf-8') 88 89 90REF_CREATED_SUBJECT_TEMPLATE = ( 91'%(emailprefix)s%(refname_type)s %(short_refname)screated' 92' (now%(newrev_short)s)' 93) 94REF_UPDATED_SUBJECT_TEMPLATE = ( 95'%(emailprefix)s%(refname_type)s %(short_refname)supdated' 96' (%(oldrev_short)s->%(newrev_short)s)' 97) 98REF_DELETED_SUBJECT_TEMPLATE = ( 99'%(emailprefix)s%(refname_type)s %(short_refname)sdeleted' 100' (was%(oldrev_short)s)' 101) 102 103COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( 104'%(emailprefix)s%(refname_type)s %(short_refname)supdated:%(oneline)s' 105) 106 107REFCHANGE_HEADER_TEMPLATE ="""\ 108Date:%(send_date)s 109To:%(recipients)s 110Subject:%(subject)s 111MIME-Version: 1.0 112Content-Type: text/plain; charset=%(charset)s 113Content-Transfer-Encoding: 8bit 114Message-ID:%(msgid)s 115From:%(fromaddr)s 116Reply-To:%(reply_to)s 117X-Git-Host:%(fqdn)s 118X-Git-Repo:%(repo_shortname)s 119X-Git-Refname:%(refname)s 120X-Git-Reftype:%(refname_type)s 121X-Git-Oldrev:%(oldrev)s 122X-Git-Newrev:%(newrev)s 123Auto-Submitted: auto-generated 124""" 125 126REFCHANGE_INTRO_TEMPLATE ="""\ 127This is an automated email from the git hooks/post-receive script. 128 129%(pusher)spushed a change to%(refname_type)s %(short_refname)s 130in repository%(repo_shortname)s. 131 132""" 133 134 135FOOTER_TEMPLATE ="""\ 136 137--\n\ 138To stop receiving notification emails like this one, please contact 139%(administrator)s. 140""" 141 142 143REWIND_ONLY_TEMPLATE ="""\ 144This update removed existing revisions from the reference, leaving the 145reference pointing at a previous point in the repository history. 146 147 * -- * -- N%(refname)s(%(newrev_short)s) 148\\ 149 O -- O -- O (%(oldrev_short)s) 150 151Any revisions marked "omits" are not gone; other references still 152refer to them. Any revisions marked "discards" are gone forever. 153""" 154 155 156NON_FF_TEMPLATE ="""\ 157This update added new revisions after undoing existing revisions. 158That is to say, some revisions that were in the old version of the 159%(refname_type)sare not in the new version. This situation occurs 160when a user --force pushes a change and generates a repository 161containing something like this: 162 163 * -- * -- B -- O -- O -- O (%(oldrev_short)s) 164\\ 165 N -- N -- N%(refname)s(%(newrev_short)s) 166 167You should already have received notification emails for all of the O 168revisions, and so the following emails describe only the N revisions 169from the common base, B. 170 171Any revisions marked "omits" are not gone; other references still 172refer to them. Any revisions marked "discards" are gone forever. 173""" 174 175 176NO_NEW_REVISIONS_TEMPLATE ="""\ 177No new revisions were added by this update. 178""" 179 180 181DISCARDED_REVISIONS_TEMPLATE ="""\ 182This change permanently discards the following revisions: 183""" 184 185 186NO_DISCARDED_REVISIONS_TEMPLATE ="""\ 187The revisions that were on this%(refname_type)sare still contained in 188other references; therefore, this change does not discard any commits 189from the repository. 190""" 191 192 193NEW_REVISIONS_TEMPLATE ="""\ 194The%(tot)srevisions listed above as "new" are entirely new to this 195repository and will be described in separate emails. The revisions 196listed as "adds" were already present in the repository and have only 197been added to this reference. 198 199""" 200 201 202TAG_CREATED_TEMPLATE ="""\ 203 at%(newrev_short)-9s (%(newrev_type)s) 204""" 205 206 207TAG_UPDATED_TEMPLATE ="""\ 208*** WARNING: tag%(short_refname)swas modified! *** 209 210 from%(oldrev_short)-9s (%(oldrev_type)s) 211 to%(newrev_short)-9s (%(newrev_type)s) 212""" 213 214 215TAG_DELETED_TEMPLATE ="""\ 216*** WARNING: tag%(short_refname)swas deleted! *** 217 218""" 219 220 221# The template used in summary tables. It looks best if this uses the 222# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. 223BRIEF_SUMMARY_TEMPLATE ="""\ 224%(action)10s%(rev_short)-9s%(text)s 225""" 226 227 228NON_COMMIT_UPDATE_TEMPLATE ="""\ 229This is an unusual reference change because the reference did not 230refer to a commit either before or after the change. We do not know 231how to provide full information about this reference change. 232""" 233 234 235REVISION_HEADER_TEMPLATE ="""\ 236Date:%(send_date)s 237To:%(recipients)s 238Cc:%(cc_recipients)s 239Subject:%(emailprefix)s%(num)02d/%(tot)02d:%(oneline)s 240MIME-Version: 1.0 241Content-Type: text/plain; charset=%(charset)s 242Content-Transfer-Encoding: 8bit 243From:%(fromaddr)s 244Reply-To:%(reply_to)s 245In-Reply-To:%(reply_to_msgid)s 246References:%(reply_to_msgid)s 247X-Git-Host:%(fqdn)s 248X-Git-Repo:%(repo_shortname)s 249X-Git-Refname:%(refname)s 250X-Git-Reftype:%(refname_type)s 251X-Git-Rev:%(rev)s 252Auto-Submitted: auto-generated 253""" 254 255REVISION_INTRO_TEMPLATE ="""\ 256This is an automated email from the git hooks/post-receive script. 257 258%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 259in repository%(repo_shortname)s. 260 261""" 262 263 264REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE 265 266 267# Combined, meaning refchange+revision email (for single-commit additions) 268COMBINED_HEADER_TEMPLATE ="""\ 269Date:%(send_date)s 270To:%(recipients)s 271Subject:%(subject)s 272MIME-Version: 1.0 273Content-Type: text/plain; charset=%(charset)s 274Content-Transfer-Encoding: 8bit 275Message-ID:%(msgid)s 276From:%(fromaddr)s 277Reply-To:%(reply_to)s 278X-Git-Host:%(fqdn)s 279X-Git-Repo:%(repo_shortname)s 280X-Git-Refname:%(refname)s 281X-Git-Reftype:%(refname_type)s 282X-Git-Oldrev:%(oldrev)s 283X-Git-Newrev:%(newrev)s 284X-Git-Rev:%(rev)s 285Auto-Submitted: auto-generated 286""" 287 288COMBINED_INTRO_TEMPLATE ="""\ 289This is an automated email from the git hooks/post-receive script. 290 291%(pusher)spushed a commit to%(refname_type)s %(short_refname)s 292in repository%(repo_shortname)s. 293 294""" 295 296COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE 297 298 299classCommandError(Exception): 300def__init__(self, cmd, retcode): 301 self.cmd = cmd 302 self.retcode = retcode 303Exception.__init__( 304 self, 305'Command "%s" failed with retcode%s'% (' '.join(cmd), retcode,) 306) 307 308 309classConfigurationException(Exception): 310pass 311 312 313# The "git" program (this could be changed to include a full path): 314GIT_EXECUTABLE ='git' 315 316 317# How "git" should be invoked (including global arguments), as a list 318# of words. This variable is usually initialized automatically by 319# read_git_output() via choose_git_command(), but if a value is set 320# here then it will be used unconditionally. 321GIT_CMD =None 322 323 324defchoose_git_command(): 325"""Decide how to invoke git, and record the choice in GIT_CMD.""" 326 327global GIT_CMD 328 329if GIT_CMD is None: 330try: 331# Check to see whether the "-c" option is accepted (it was 332# only added in Git 1.7.2). We don't actually use the 333# output of "git --version", though if we needed more 334# specific version information this would be the place to 335# do it. 336 cmd = [GIT_EXECUTABLE,'-c','foo.bar=baz','--version'] 337read_output(cmd) 338 GIT_CMD = [GIT_EXECUTABLE,'-c','i18n.logoutputencoding=%s'% (ENCODING,)] 339except CommandError: 340 GIT_CMD = [GIT_EXECUTABLE] 341 342 343defread_git_output(args,input=None, keepends=False, **kw): 344"""Read the output of a Git command.""" 345 346if GIT_CMD is None: 347choose_git_command() 348 349returnread_output(GIT_CMD + args,input=input, keepends=keepends, **kw) 350 351 352defread_output(cmd,input=None, keepends=False, **kw): 353ifinput: 354 stdin = subprocess.PIPE 355else: 356 stdin =None 357 p = subprocess.Popen( 358 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw 359) 360(out, err) = p.communicate(input) 361 retcode = p.wait() 362if retcode: 363raiseCommandError(cmd, retcode) 364if not keepends: 365 out = out.rstrip('\n\r') 366return out 367 368 369defread_git_lines(args, keepends=False, **kw): 370"""Return the lines output by Git command. 371 372 Return as single lines, with newlines stripped off.""" 373 374returnread_git_output(args, keepends=True, **kw).splitlines(keepends) 375 376 377defgit_rev_list_ish(cmd, spec, args=None, **kw): 378"""Common functionality for invoking a 'git rev-list'-like command. 379 380 Parameters: 381 * cmd is the Git command to run, e.g., 'rev-list' or 'log'. 382 * spec is a list of revision arguments to pass to the named 383 command. If None, this function returns an empty list. 384 * args is a list of extra arguments passed to the named command. 385 * All other keyword arguments (if any) are passed to the 386 underlying read_git_lines() function. 387 388 Return the output of the Git command in the form of a list, one 389 entry per output line. 390 """ 391if spec is None: 392return[] 393if args is None: 394 args = [] 395 args = [cmd,'--stdin'] + args 396 spec_stdin =''.join(s +'\n'for s in spec) 397returnread_git_lines(args,input=spec_stdin, **kw) 398 399 400defgit_rev_list(spec, **kw): 401"""Run 'git rev-list' with the given list of revision arguments. 402 403 See git_rev_list_ish() for parameter and return value 404 documentation. 405 """ 406returngit_rev_list_ish('rev-list', spec, **kw) 407 408 409defgit_log(spec, **kw): 410"""Run 'git log' with the given list of revision arguments. 411 412 See git_rev_list_ish() for parameter and return value 413 documentation. 414 """ 415returngit_rev_list_ish('log', spec, **kw) 416 417 418defheader_encode(text, header_name=None): 419"""Encode and line-wrap the value of an email header field.""" 420 421try: 422ifisinstance(text,str): 423 text = text.decode(ENCODING,'replace') 424returnHeader(text, header_name=header_name).encode() 425exceptUnicodeEncodeError: 426returnHeader(text, header_name=header_name, charset=CHARSET, 427 errors='replace').encode() 428 429 430defaddr_header_encode(text, header_name=None): 431"""Encode and line-wrap the value of an email header field containing 432 email addresses.""" 433 434returnHeader( 435', '.join( 436formataddr((header_encode(name), emailaddr)) 437for name, emailaddr ingetaddresses([text]) 438), 439 header_name=header_name 440).encode() 441 442 443classConfig(object): 444def__init__(self, section, git_config=None): 445"""Represent a section of the git configuration. 446 447 If git_config is specified, it is passed to "git config" in 448 the GIT_CONFIG environment variable, meaning that "git config" 449 will read the specified path rather than the Git default 450 config paths.""" 451 452 self.section = section 453if git_config: 454 self.env = os.environ.copy() 455 self.env['GIT_CONFIG'] = git_config 456else: 457 self.env =None 458 459@staticmethod 460def_split(s): 461"""Split NUL-terminated values.""" 462 463 words = s.split('\0') 464assert words[-1] =='' 465return words[:-1] 466 467defget(self, name, default=None): 468try: 469 values = self._split(read_git_output( 470['config','--get','--null','%s.%s'% (self.section, name)], 471 env=self.env, keepends=True, 472)) 473assertlen(values) ==1 474return values[0] 475except CommandError: 476return default 477 478defget_bool(self, name, default=None): 479try: 480 value =read_git_output( 481['config','--get','--bool','%s.%s'% (self.section, name)], 482 env=self.env, 483) 484except CommandError: 485return default 486return value =='true' 487 488defget_all(self, name, default=None): 489"""Read a (possibly multivalued) setting from the configuration. 490 491 Return the result as a list of values, or default if the name 492 is unset.""" 493 494try: 495return self._split(read_git_output( 496['config','--get-all','--null','%s.%s'% (self.section, name)], 497 env=self.env, keepends=True, 498)) 499except CommandError, e: 500if e.retcode ==1: 501# "the section or key is invalid"; i.e., there is no 502# value for the specified key. 503return default 504else: 505raise 506 507defget_recipients(self, name, default=None): 508"""Read a recipients list from the configuration. 509 510 Return the result as a comma-separated list of email 511 addresses, or default if the option is unset. If the setting 512 has multiple values, concatenate them with comma separators.""" 513 514 lines = self.get_all(name, default=None) 515if lines is None: 516return default 517return', '.join(line.strip()for line in lines) 518 519defset(self, name, value): 520read_git_output( 521['config','%s.%s'% (self.section, name), value], 522 env=self.env, 523) 524 525defadd(self, name, value): 526read_git_output( 527['config','--add','%s.%s'% (self.section, name), value], 528 env=self.env, 529) 530 531def__contains__(self, name): 532return self.get_all(name, default=None)is not None 533 534# We don't use this method anymore internally, but keep it here in 535# case somebody is calling it from their own code: 536defhas_key(self, name): 537return name in self 538 539defunset_all(self, name): 540try: 541read_git_output( 542['config','--unset-all','%s.%s'% (self.section, name)], 543 env=self.env, 544) 545except CommandError, e: 546if e.retcode ==5: 547# The name doesn't exist, which is what we wanted anyway... 548pass 549else: 550raise 551 552defset_recipients(self, name, value): 553 self.unset_all(name) 554for pair ingetaddresses([value]): 555 self.add(name,formataddr(pair)) 556 557 558defgenerate_summaries(*log_args): 559"""Generate a brief summary for each revision requested. 560 561 log_args are strings that will be passed directly to "git log" as 562 revision selectors. Iterate over (sha1_short, subject) for each 563 commit specified by log_args (subject is the first line of the 564 commit message as a string without EOLs).""" 565 566 cmd = [ 567'log','--abbrev','--format=%h%s', 568] +list(log_args) + ['--'] 569for line inread_git_lines(cmd): 570yieldtuple(line.split(' ',1)) 571 572 573deflimit_lines(lines, max_lines): 574for(index, line)inenumerate(lines): 575if index < max_lines: 576yield line 577 578if index >= max_lines: 579yield'...%dlines suppressed ...\n'% (index +1- max_lines,) 580 581 582deflimit_linelength(lines, max_linelength): 583for line in lines: 584# Don't forget that lines always include a trailing newline. 585iflen(line) > max_linelength +1: 586 line = line[:max_linelength -7] +' [...]\n' 587yield line 588 589 590classCommitSet(object): 591"""A (constant) set of object names. 592 593 The set should be initialized with full SHA1 object names. The 594 __contains__() method returns True iff its argument is an 595 abbreviation of any the names in the set.""" 596 597def__init__(self, names): 598 self._names =sorted(names) 599 600def__len__(self): 601returnlen(self._names) 602 603def__contains__(self, sha1_abbrev): 604"""Return True iff this set contains sha1_abbrev (which might be abbreviated).""" 605 606 i = bisect.bisect_left(self._names, sha1_abbrev) 607return i <len(self)and self._names[i].startswith(sha1_abbrev) 608 609 610classGitObject(object): 611def__init__(self, sha1,type=None): 612if sha1 == ZEROS: 613 self.sha1 = self.type= self.commit_sha1 =None 614else: 615 self.sha1 = sha1 616 self.type=typeorread_git_output(['cat-file','-t', self.sha1]) 617 618if self.type=='commit': 619 self.commit_sha1 = self.sha1 620elif self.type=='tag': 621try: 622 self.commit_sha1 =read_git_output( 623['rev-parse','--verify','%s^0'% (self.sha1,)] 624) 625except CommandError: 626# Cannot deref tag to determine commit_sha1 627 self.commit_sha1 =None 628else: 629 self.commit_sha1 =None 630 631 self.short =read_git_output(['rev-parse','--short', sha1]) 632 633defget_summary(self): 634"""Return (sha1_short, subject) for this commit.""" 635 636if not self.sha1: 637raiseValueError('Empty commit has no summary') 638 639returniter(generate_summaries('--no-walk', self.sha1)).next() 640 641def__eq__(self, other): 642returnisinstance(other, GitObject)and self.sha1 == other.sha1 643 644def__hash__(self): 645returnhash(self.sha1) 646 647def__nonzero__(self): 648returnbool(self.sha1) 649 650def__str__(self): 651return self.sha1 or ZEROS 652 653 654classChange(object): 655"""A Change that has been made to the Git repository. 656 657 Abstract class from which both Revisions and ReferenceChanges are 658 derived. A Change knows how to generate a notification email 659 describing itself.""" 660 661def__init__(self, environment): 662 self.environment = environment 663 self._values =None 664 665def_compute_values(self): 666"""Return a dictionary{keyword: expansion}for this Change. 667 668 Derived classes overload this method to add more entries to 669 the return value. This method is used internally by 670 get_values(). The return value should always be a new 671 dictionary.""" 672 673return self.environment.get_values() 674 675defget_values(self, **extra_values): 676"""Return a dictionary{keyword: expansion}for this Change. 677 678 Return a dictionary mapping keywords to the values that they 679 should be expanded to for this Change (used when interpolating 680 template strings). If any keyword arguments are supplied, add 681 those to the return value as well. The return value is always 682 a new dictionary.""" 683 684if self._values is None: 685 self._values = self._compute_values() 686 687 values = self._values.copy() 688if extra_values: 689 values.update(extra_values) 690return values 691 692defexpand(self, template, **extra_values): 693"""Expand template. 694 695 Expand the template (which should be a string) using string 696 interpolation of the values for this Change. If any keyword 697 arguments are provided, also include those in the keywords 698 available for interpolation.""" 699 700return template % self.get_values(**extra_values) 701 702defexpand_lines(self, template, **extra_values): 703"""Break template into lines and expand each line.""" 704 705 values = self.get_values(**extra_values) 706for line in template.splitlines(True): 707yield line % values 708 709defexpand_header_lines(self, template, **extra_values): 710"""Break template into lines and expand each line as an RFC 2822 header. 711 712 Encode values and split up lines that are too long. Silently 713 skip lines that contain references to unknown variables.""" 714 715 values = self.get_values(**extra_values) 716for line in template.splitlines(): 717(name, value) = line.split(':',1) 718 719try: 720 value = value % values 721exceptKeyError, e: 722if DEBUG: 723 self.environment.log_warning( 724'Warning: unknown variable%rin the following line; line skipped:\n' 725'%s\n' 726% (e.args[0], line,) 727) 728else: 729if name.lower()in ADDR_HEADERS: 730 value =addr_header_encode(value, name) 731else: 732 value =header_encode(value, name) 733for splitline in('%s:%s\n'% (name, value)).splitlines(True): 734yield splitline 735 736defgenerate_email_header(self): 737"""Generate the RFC 2822 email headers for this Change, a line at a time. 738 739 The output should not include the trailing blank line.""" 740 741raiseNotImplementedError() 742 743defgenerate_email_intro(self): 744"""Generate the email intro for this Change, a line at a time. 745 746 The output will be used as the standard boilerplate at the top 747 of the email body.""" 748 749raiseNotImplementedError() 750 751defgenerate_email_body(self): 752"""Generate the main part of the email body, a line at a time. 753 754 The text in the body might be truncated after a specified 755 number of lines (see multimailhook.emailmaxlines).""" 756 757raiseNotImplementedError() 758 759defgenerate_email_footer(self): 760"""Generate the footer of the email, a line at a time. 761 762 The footer is always included, irrespective of 763 multimailhook.emailmaxlines.""" 764 765raiseNotImplementedError() 766 767defgenerate_email(self, push, body_filter=None, extra_header_values={}): 768"""Generate an email describing this change. 769 770 Iterate over the lines (including the header lines) of an 771 email describing this change. If body_filter is not None, 772 then use it to filter the lines that are intended for the 773 email body. 774 775 The extra_header_values field is received as a dict and not as 776 **kwargs, to allow passing other keyword arguments in the 777 future (e.g. passing extra values to generate_email_intro()""" 778 779for line in self.generate_email_header(**extra_header_values): 780yield line 781yield'\n' 782for line in self.generate_email_intro(): 783yield line 784 785 body = self.generate_email_body(push) 786if body_filter is not None: 787 body =body_filter(body) 788for line in body: 789yield line 790 791for line in self.generate_email_footer(): 792yield line 793 794 795classRevision(Change): 796"""A Change consisting of a single git commit.""" 797 798 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$') 799 800def__init__(self, reference_change, rev, num, tot): 801 Change.__init__(self, reference_change.environment) 802 self.reference_change = reference_change 803 self.rev = rev 804 self.change_type = self.reference_change.change_type 805 self.refname = self.reference_change.refname 806 self.num = num 807 self.tot = tot 808 self.author =read_git_output(['log','--no-walk','--format=%aN <%aE>', self.rev.sha1]) 809 self.recipients = self.environment.get_revision_recipients(self) 810 811 self.cc_recipients ='' 812if self.environment.get_scancommitforcc(): 813 self.cc_recipients =', '.join(to.strip()for to in self._cc_recipients()) 814if self.cc_recipients: 815 self.environment.log_msg( 816'Add%sto CC for%s\n'% (self.cc_recipients, self.rev.sha1)) 817 818def_cc_recipients(self): 819 cc_recipients = [] 820 message =read_git_output(['log','--no-walk','--format=%b', self.rev.sha1]) 821 lines = message.strip().split('\n') 822for line in lines: 823 m = re.match(self.CC_RE, line) 824if m: 825 cc_recipients.append(m.group('to')) 826 827return cc_recipients 828 829def_compute_values(self): 830 values = Change._compute_values(self) 831 832 oneline =read_git_output( 833['log','--format=%s','--no-walk', self.rev.sha1] 834) 835 836 values['rev'] = self.rev.sha1 837 values['rev_short'] = self.rev.short 838 values['change_type'] = self.change_type 839 values['refname'] = self.refname 840 values['short_refname'] = self.reference_change.short_refname 841 values['refname_type'] = self.reference_change.refname_type 842 values['reply_to_msgid'] = self.reference_change.msgid 843 values['num'] = self.num 844 values['tot'] = self.tot 845 values['recipients'] = self.recipients 846if self.cc_recipients: 847 values['cc_recipients'] = self.cc_recipients 848 values['oneline'] = oneline 849 values['author'] = self.author 850 851 reply_to = self.environment.get_reply_to_commit(self) 852if reply_to: 853 values['reply_to'] = reply_to 854 855return values 856 857defgenerate_email_header(self, **extra_values): 858for line in self.expand_header_lines( 859 REVISION_HEADER_TEMPLATE, **extra_values 860): 861yield line 862 863defgenerate_email_intro(self): 864for line in self.expand_lines(REVISION_INTRO_TEMPLATE): 865yield line 866 867defgenerate_email_body(self, push): 868"""Show this revision.""" 869 870returnread_git_lines( 871['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1], 872 keepends=True, 873) 874 875defgenerate_email_footer(self): 876return self.expand_lines(REVISION_FOOTER_TEMPLATE) 877 878 879classReferenceChange(Change): 880"""A Change to a Git reference. 881 882 An abstract class representing a create, update, or delete of a 883 Git reference. Derived classes handle specific types of reference 884 (e.g., tags vs. branches). These classes generate the main 885 reference change email summarizing the reference change and 886 whether it caused any any commits to be added or removed. 887 888 ReferenceChange objects are usually created using the static 889 create() method, which has the logic to decide which derived class 890 to instantiate.""" 891 892 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$') 893 894@staticmethod 895defcreate(environment, oldrev, newrev, refname): 896"""Return a ReferenceChange object representing the change. 897 898 Return an object that represents the type of change that is being 899 made. oldrev and newrev should be SHA1s or ZEROS.""" 900 901 old =GitObject(oldrev) 902 new =GitObject(newrev) 903 rev = new or old 904 905# The revision type tells us what type the commit is, combined with 906# the location of the ref we can decide between 907# - working branch 908# - tracking branch 909# - unannotated tag 910# - annotated tag 911 m = ReferenceChange.REF_RE.match(refname) 912if m: 913 area = m.group('area') 914 short_refname = m.group('shortname') 915else: 916 area ='' 917 short_refname = refname 918 919if rev.type=='tag': 920# Annotated tag: 921 klass = AnnotatedTagChange 922elif rev.type=='commit': 923if area =='tags': 924# Non-annotated tag: 925 klass = NonAnnotatedTagChange 926elif area =='heads': 927# Branch: 928 klass = BranchChange 929elif area =='remotes': 930# Tracking branch: 931 environment.log_warning( 932'*** Push-update of tracking branch%r\n' 933'*** - incomplete email generated.\n' 934% (refname,) 935) 936 klass = OtherReferenceChange 937else: 938# Some other reference namespace: 939 environment.log_warning( 940'*** Push-update of strange reference%r\n' 941'*** - incomplete email generated.\n' 942% (refname,) 943) 944 klass = OtherReferenceChange 945else: 946# Anything else (is there anything else?) 947 environment.log_warning( 948'*** Unknown type of update to%r(%s)\n' 949'*** - incomplete email generated.\n' 950% (refname, rev.type,) 951) 952 klass = OtherReferenceChange 953 954returnklass( 955 environment, 956 refname=refname, short_refname=short_refname, 957 old=old, new=new, rev=rev, 958) 959 960def__init__(self, environment, refname, short_refname, old, new, rev): 961 Change.__init__(self, environment) 962 self.change_type = { 963(False,True):'create', 964(True,True):'update', 965(True,False):'delete', 966}[bool(old),bool(new)] 967 self.refname = refname 968 self.short_refname = short_refname 969 self.old = old 970 self.new = new 971 self.rev = rev 972 self.msgid =make_msgid() 973 self.diffopts = environment.diffopts 974 self.graphopts = environment.graphopts 975 self.logopts = environment.logopts 976 self.commitlogopts = environment.commitlogopts 977 self.showgraph = environment.refchange_showgraph 978 self.showlog = environment.refchange_showlog 979 980 self.header_template = REFCHANGE_HEADER_TEMPLATE 981 self.intro_template = REFCHANGE_INTRO_TEMPLATE 982 self.footer_template = FOOTER_TEMPLATE 983 984def_compute_values(self): 985 values = Change._compute_values(self) 986 987 values['change_type'] = self.change_type 988 values['refname_type'] = self.refname_type 989 values['refname'] = self.refname 990 values['short_refname'] = self.short_refname 991 values['msgid'] = self.msgid 992 values['recipients'] = self.recipients 993 values['oldrev'] =str(self.old) 994 values['oldrev_short'] = self.old.short 995 values['newrev'] =str(self.new) 996 values['newrev_short'] = self.new.short 997 998if self.old: 999 values['oldrev_type'] = self.old.type1000if self.new:1001 values['newrev_type'] = self.new.type10021003 reply_to = self.environment.get_reply_to_refchange(self)1004if reply_to:1005 values['reply_to'] = reply_to10061007return values10081009defsend_single_combined_email(self, known_added_sha1s):1010"""Determine if a combined refchange/revision email should be sent10111012 If there is only a single new (non-merge) commit added by a1013 change, it is useful to combine the ReferenceChange and1014 Revision emails into one. In such a case, return the single1015 revision; otherwise, return None.10161017 This method is overridden in BranchChange."""10181019return None10201021defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1022"""Generate an email describing this change AND specified revision.10231024 Iterate over the lines (including the header lines) of an1025 email describing this change. If body_filter is not None,1026 then use it to filter the lines that are intended for the1027 email body.10281029 The extra_header_values field is received as a dict and not as1030 **kwargs, to allow passing other keyword arguments in the1031 future (e.g. passing extra values to generate_email_intro()10321033 This method is overridden in BranchChange."""10341035raiseNotImplementedError10361037defget_subject(self):1038 template = {1039'create': REF_CREATED_SUBJECT_TEMPLATE,1040'update': REF_UPDATED_SUBJECT_TEMPLATE,1041'delete': REF_DELETED_SUBJECT_TEMPLATE,1042}[self.change_type]1043return self.expand(template)10441045defgenerate_email_header(self, **extra_values):1046if'subject'not in extra_values:1047 extra_values['subject'] = self.get_subject()10481049for line in self.expand_header_lines(1050 self.header_template, **extra_values1051):1052yield line10531054defgenerate_email_intro(self):1055for line in self.expand_lines(self.intro_template):1056yield line10571058defgenerate_email_body(self, push):1059"""Call the appropriate body-generation routine.10601061 Call one of generate_create_summary() /1062 generate_update_summary() / generate_delete_summary()."""10631064 change_summary = {1065'create': self.generate_create_summary,1066'delete': self.generate_delete_summary,1067'update': self.generate_update_summary,1068}[self.change_type](push)1069for line in change_summary:1070yield line10711072for line in self.generate_revision_change_summary(push):1073yield line10741075defgenerate_email_footer(self):1076return self.expand_lines(self.footer_template)10771078defgenerate_revision_change_graph(self, push):1079if self.showgraph:1080 args = ['--graph'] + self.graphopts1081for newold in('new','old'):1082 has_newold =False1083 spec = push.get_commits_spec(newold, self)1084for line ingit_log(spec, args=args, keepends=True):1085if not has_newold:1086 has_newold =True1087yield'\n'1088yield'Graph of%scommits:\n\n'% (1089 {'new': 'new', 'old': 'discarded'}[newold],)1090yield' '+ line1091if has_newold:1092yield'\n'10931094defgenerate_revision_change_log(self, new_commits_list):1095if self.showlog:1096yield'\n'1097yield'Detailed log of new commits:\n\n'1098for line inread_git_lines(1099['log','--no-walk']1100+ self.logopts1101+ new_commits_list1102+ ['--'],1103 keepends=True,1104):1105yield line11061107defgenerate_new_revision_summary(self, tot, new_commits_list, push):1108for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):1109yield line1110for line in self.generate_revision_change_graph(push):1111yield line1112for line in self.generate_revision_change_log(new_commits_list):1113yield line11141115defgenerate_revision_change_summary(self, push):1116"""Generate a summary of the revisions added/removed by this change."""11171118if self.new.commit_sha1 and not self.old.commit_sha1:1119# A new reference was created. List the new revisions1120# brought by the new reference (i.e., those revisions that1121# were not in the repository before this reference1122# change).1123 sha1s =list(push.get_new_commits(self))1124 sha1s.reverse()1125 tot =len(sha1s)1126 new_revisions = [1127Revision(self,GitObject(sha1), num=i +1, tot=tot)1128for(i, sha1)inenumerate(sha1s)1129]11301131if new_revisions:1132yield self.expand('This%(refname_type)sincludes the following new commits:\n')1133yield'\n'1134for r in new_revisions:1135(sha1, subject) = r.rev.get_summary()1136yield r.expand(1137 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,1138)1139yield'\n'1140for line in self.generate_new_revision_summary(1141 tot, [r.rev.sha1 for r in new_revisions], push):1142yield line1143else:1144for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1145yield line11461147elif self.new.commit_sha1 and self.old.commit_sha1:1148# A reference was changed to point at a different commit.1149# List the revisions that were removed and/or added *from1150# that reference* by this reference change, along with a1151# diff between the trees for its old and new values.11521153# List of the revisions that were added to the branch by1154# this update. Note this list can include revisions that1155# have already had notification emails; we want such1156# revisions in the summary even though we will not send1157# new notification emails for them.1158 adds =list(generate_summaries(1159'--topo-order','--reverse','%s..%s'1160% (self.old.commit_sha1, self.new.commit_sha1,)1161))11621163# List of the revisions that were removed from the branch1164# by this update. This will be empty except for1165# non-fast-forward updates.1166 discards =list(generate_summaries(1167'%s..%s'% (self.new.commit_sha1, self.old.commit_sha1,)1168))11691170if adds:1171 new_commits_list = push.get_new_commits(self)1172else:1173 new_commits_list = []1174 new_commits =CommitSet(new_commits_list)11751176if discards:1177 discarded_commits =CommitSet(push.get_discarded_commits(self))1178else:1179 discarded_commits =CommitSet([])11801181if discards and adds:1182for(sha1, subject)in discards:1183if sha1 in discarded_commits:1184 action ='discards'1185else:1186 action ='omits'1187yield self.expand(1188 BRIEF_SUMMARY_TEMPLATE, action=action,1189 rev_short=sha1, text=subject,1190)1191for(sha1, subject)in adds:1192if sha1 in new_commits:1193 action ='new'1194else:1195 action ='adds'1196yield self.expand(1197 BRIEF_SUMMARY_TEMPLATE, action=action,1198 rev_short=sha1, text=subject,1199)1200yield'\n'1201for line in self.expand_lines(NON_FF_TEMPLATE):1202yield line12031204elif discards:1205for(sha1, subject)in discards:1206if sha1 in discarded_commits:1207 action ='discards'1208else:1209 action ='omits'1210yield self.expand(1211 BRIEF_SUMMARY_TEMPLATE, action=action,1212 rev_short=sha1, text=subject,1213)1214yield'\n'1215for line in self.expand_lines(REWIND_ONLY_TEMPLATE):1216yield line12171218elif adds:1219(sha1, subject) = self.old.get_summary()1220yield self.expand(1221 BRIEF_SUMMARY_TEMPLATE, action='from',1222 rev_short=sha1, text=subject,1223)1224for(sha1, subject)in adds:1225if sha1 in new_commits:1226 action ='new'1227else:1228 action ='adds'1229yield self.expand(1230 BRIEF_SUMMARY_TEMPLATE, action=action,1231 rev_short=sha1, text=subject,1232)12331234yield'\n'12351236if new_commits:1237for line in self.generate_new_revision_summary(1238len(new_commits), new_commits_list, push):1239yield line1240else:1241for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):1242yield line1243for line in self.generate_revision_change_graph(push):1244yield line12451246# The diffstat is shown from the old revision to the new1247# revision. This is to show the truth of what happened in1248# this change. There's no point showing the stat from the1249# base to the new revision because the base is effectively a1250# random revision at this point - the user will be interested1251# in what this revision changed - including the undoing of1252# previous revisions in the case of non-fast-forward updates.1253yield'\n'1254yield'Summary of changes:\n'1255for line inread_git_lines(1256['diff-tree']1257+ self.diffopts1258+ ['%s..%s'% (self.old.commit_sha1, self.new.commit_sha1,)],1259 keepends=True,1260):1261yield line12621263elif self.old.commit_sha1 and not self.new.commit_sha1:1264# A reference was deleted. List the revisions that were1265# removed from the repository by this reference change.12661267 sha1s =list(push.get_discarded_commits(self))1268 tot =len(sha1s)1269 discarded_revisions = [1270Revision(self,GitObject(sha1), num=i +1, tot=tot)1271for(i, sha1)inenumerate(sha1s)1272]12731274if discarded_revisions:1275for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):1276yield line1277yield'\n'1278for r in discarded_revisions:1279(sha1, subject) = r.rev.get_summary()1280yield r.expand(1281 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,1282)1283for line in self.generate_revision_change_graph(push):1284yield line1285else:1286for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):1287yield line12881289elif not self.old.commit_sha1 and not self.new.commit_sha1:1290for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):1291yield line12921293defgenerate_create_summary(self, push):1294"""Called for the creation of a reference."""12951296# This is a new reference and so oldrev is not valid1297(sha1, subject) = self.new.get_summary()1298yield self.expand(1299 BRIEF_SUMMARY_TEMPLATE, action='at',1300 rev_short=sha1, text=subject,1301)1302yield'\n'13031304defgenerate_update_summary(self, push):1305"""Called for the change of a pre-existing branch."""13061307returniter([])13081309defgenerate_delete_summary(self, push):1310"""Called for the deletion of any type of reference."""13111312(sha1, subject) = self.old.get_summary()1313yield self.expand(1314 BRIEF_SUMMARY_TEMPLATE, action='was',1315 rev_short=sha1, text=subject,1316)1317yield'\n'131813191320classBranchChange(ReferenceChange):1321 refname_type ='branch'13221323def__init__(self, environment, refname, short_refname, old, new, rev):1324 ReferenceChange.__init__(1325 self, environment,1326 refname=refname, short_refname=short_refname,1327 old=old, new=new, rev=rev,1328)1329 self.recipients = environment.get_refchange_recipients(self)1330 self._single_revision =None13311332defsend_single_combined_email(self, known_added_sha1s):1333if not self.environment.combine_when_single_commit:1334return None13351336# In the sadly-all-too-frequent usecase of people pushing only1337# one of their commits at a time to a repository, users feel1338# the reference change summary emails are noise rather than1339# important signal. This is because, in this particular1340# usecase, there is a reference change summary email for each1341# new commit, and all these summaries do is point out that1342# there is one new commit (which can readily be inferred by1343# the existence of the individual revision email that is also1344# sent). In such cases, our users prefer there to be a combined1345# reference change summary/new revision email.1346#1347# So, if the change is an update and it doesn't discard any1348# commits, and it adds exactly one non-merge commit (gerrit1349# forces a workflow where every commit is individually merged1350# and the git-multimail hook fired off for just this one1351# change), then we send a combined refchange/revision email.1352try:1353# If this change is a reference update that doesn't discard1354# any commits...1355if self.change_type !='update':1356return None13571358ifread_git_lines(1359['merge-base', self.old.sha1, self.new.sha1]1360) != [self.old.sha1]:1361return None13621363# Check if this update introduced exactly one non-merge1364# commit:13651366defsplit_line(line):1367"""Split line into (sha1, [parent,...])."""13681369 words = line.split()1370return(words[0], words[1:])13711372# Get the new commits introduced by the push as a list of1373# (sha1, [parent,...])1374 new_commits = [1375split_line(line)1376for line inread_git_lines(1377[1378'log','-3','--format=%H %P',1379'%s..%s'% (self.old.sha1, self.new.sha1),1380]1381)1382]13831384if not new_commits:1385return None13861387# If the newest commit is a merge, save it for a later check1388# but otherwise ignore it1389 merge =None1390 tot =len(new_commits)1391iflen(new_commits[0][1]) >1:1392 merge = new_commits[0][0]1393del new_commits[0]13941395# Our primary check: we can't combine if more than one commit1396# is introduced. We also currently only combine if the new1397# commit is a non-merge commit, though it may make sense to1398# combine if it is a merge as well.1399if not(1400len(new_commits) ==11401andlen(new_commits[0][1]) ==11402and new_commits[0][0]in known_added_sha1s1403):1404return None14051406# We do not want to combine revision and refchange emails if1407# those go to separate locations.1408 rev =Revision(self,GitObject(new_commits[0][0]),1, tot)1409if rev.recipients != self.recipients:1410return None14111412# We ignored the newest commit if it was just a merge of the one1413# commit being introduced. But we don't want to ignore that1414# merge commit it it involved conflict resolutions. Check that.1415if merge and merge !=read_git_output(['diff-tree','--cc', merge]):1416return None14171418# We can combine the refchange and one new revision emails1419# into one. Return the Revision that a combined email should1420# be sent about.1421return rev1422except CommandError:1423# Cannot determine number of commits in old..new or new..old;1424# don't combine reference/revision emails:1425return None14261427defgenerate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):1428 values = revision.get_values()1429if extra_header_values:1430 values.update(extra_header_values)1431if'subject'not in extra_header_values:1432 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)14331434 self._single_revision = revision1435 self.header_template = COMBINED_HEADER_TEMPLATE1436 self.intro_template = COMBINED_INTRO_TEMPLATE1437 self.footer_template = COMBINED_FOOTER_TEMPLATE1438for line in self.generate_email(push, body_filter, values):1439yield line14401441defgenerate_email_body(self, push):1442'''Call the appropriate body generation routine.14431444 If this is a combined refchange/revision email, the special logic1445 for handling this combined email comes from this function. For1446 other cases, we just use the normal handling.'''14471448# If self._single_revision isn't set; don't override1449if not self._single_revision:1450for line insuper(BranchChange, self).generate_email_body(push):1451yield line1452return14531454# This is a combined refchange/revision email; we first provide1455# some info from the refchange portion, and then call the revision1456# generate_email_body function to handle the revision portion.1457 adds =list(generate_summaries(1458'--topo-order','--reverse','%s..%s'1459% (self.old.commit_sha1, self.new.commit_sha1,)1460))14611462yield self.expand("The following commit(s) were added to%(refname)sby this push:\n")1463for(sha1, subject)in adds:1464yield self.expand(1465 BRIEF_SUMMARY_TEMPLATE, action='new',1466 rev_short=sha1, text=subject,1467)14681469yield self._single_revision.rev.short +" is described below\n"1470yield'\n'14711472for line in self._single_revision.generate_email_body(push):1473yield line147414751476classAnnotatedTagChange(ReferenceChange):1477 refname_type ='annotated tag'14781479def__init__(self, environment, refname, short_refname, old, new, rev):1480 ReferenceChange.__init__(1481 self, environment,1482 refname=refname, short_refname=short_refname,1483 old=old, new=new, rev=rev,1484)1485 self.recipients = environment.get_announce_recipients(self)1486 self.show_shortlog = environment.announce_show_shortlog14871488 ANNOTATED_TAG_FORMAT = (1489'%(*objectname)\n'1490'%(*objecttype)\n'1491'%(taggername)\n'1492'%(taggerdate)'1493)14941495defdescribe_tag(self, push):1496"""Describe the new value of an annotated tag."""14971498# Use git for-each-ref to pull out the individual fields from1499# the tag1500[tagobject, tagtype, tagger, tagged] =read_git_lines(1501['for-each-ref','--format=%s'% (self.ANNOTATED_TAG_FORMAT,), self.refname],1502)15031504yield self.expand(1505 BRIEF_SUMMARY_TEMPLATE, action='tagging',1506 rev_short=tagobject, text='(%s)'% (tagtype,),1507)1508if tagtype =='commit':1509# If the tagged object is a commit, then we assume this is a1510# release, and so we calculate which tag this tag is1511# replacing1512try:1513 prevtag =read_git_output(['describe','--abbrev=0','%s^'% (self.new,)])1514except CommandError:1515 prevtag =None1516if prevtag:1517yield' replaces%s\n'% (prevtag,)1518else:1519 prevtag =None1520yield' length%sbytes\n'% (read_git_output(['cat-file','-s', tagobject]),)15211522yield' tagged by%s\n'% (tagger,)1523yield' on%s\n'% (tagged,)1524yield'\n'15251526# Show the content of the tag message; this might contain a1527# change log or release notes so is worth displaying.1528yield LOGBEGIN1529 contents =list(read_git_lines(['cat-file','tag', self.new.sha1], keepends=True))1530 contents = contents[contents.index('\n') +1:]1531if contents and contents[-1][-1:] !='\n':1532 contents.append('\n')1533for line in contents:1534yield line15351536if self.show_shortlog and tagtype =='commit':1537# Only commit tags make sense to have rev-list operations1538# performed on them1539yield'\n'1540if prevtag:1541# Show changes since the previous release1542 revlist =read_git_output(1543['rev-list','--pretty=short','%s..%s'% (prevtag, self.new,)],1544 keepends=True,1545)1546else:1547# No previous tag, show all the changes since time1548# began1549 revlist =read_git_output(1550['rev-list','--pretty=short','%s'% (self.new,)],1551 keepends=True,1552)1553for line inread_git_lines(['shortlog'],input=revlist, keepends=True):1554yield line15551556yield LOGEND1557yield'\n'15581559defgenerate_create_summary(self, push):1560"""Called for the creation of an annotated tag."""15611562for line in self.expand_lines(TAG_CREATED_TEMPLATE):1563yield line15641565for line in self.describe_tag(push):1566yield line15671568defgenerate_update_summary(self, push):1569"""Called for the update of an annotated tag.15701571 This is probably a rare event and may not even be allowed."""15721573for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1574yield line15751576for line in self.describe_tag(push):1577yield line15781579defgenerate_delete_summary(self, push):1580"""Called when a non-annotated reference is updated."""15811582for line in self.expand_lines(TAG_DELETED_TEMPLATE):1583yield line15841585yield self.expand(' tag was%(oldrev_short)s\n')1586yield'\n'158715881589classNonAnnotatedTagChange(ReferenceChange):1590 refname_type ='tag'15911592def__init__(self, environment, refname, short_refname, old, new, rev):1593 ReferenceChange.__init__(1594 self, environment,1595 refname=refname, short_refname=short_refname,1596 old=old, new=new, rev=rev,1597)1598 self.recipients = environment.get_refchange_recipients(self)15991600defgenerate_create_summary(self, push):1601"""Called for the creation of an annotated tag."""16021603for line in self.expand_lines(TAG_CREATED_TEMPLATE):1604yield line16051606defgenerate_update_summary(self, push):1607"""Called when a non-annotated reference is updated."""16081609for line in self.expand_lines(TAG_UPDATED_TEMPLATE):1610yield line16111612defgenerate_delete_summary(self, push):1613"""Called when a non-annotated reference is updated."""16141615for line in self.expand_lines(TAG_DELETED_TEMPLATE):1616yield line16171618for line in ReferenceChange.generate_delete_summary(self, push):1619yield line162016211622classOtherReferenceChange(ReferenceChange):1623 refname_type ='reference'16241625def__init__(self, environment, refname, short_refname, old, new, rev):1626# We use the full refname as short_refname, because otherwise1627# the full name of the reference would not be obvious from the1628# text of the email.1629 ReferenceChange.__init__(1630 self, environment,1631 refname=refname, short_refname=refname,1632 old=old, new=new, rev=rev,1633)1634 self.recipients = environment.get_refchange_recipients(self)163516361637classMailer(object):1638"""An object that can send emails."""16391640defsend(self, lines, to_addrs):1641"""Send an email consisting of lines.16421643 lines must be an iterable over the lines constituting the1644 header and body of the email. to_addrs is a list of recipient1645 addresses (can be needed even if lines already contains a1646 "To:" field). It can be either a string (comma-separated list1647 of email addresses) or a Python list of individual email1648 addresses.16491650 """16511652raiseNotImplementedError()165316541655classSendMailer(Mailer):1656"""Send emails using 'sendmail -oi -t'."""16571658 SENDMAIL_CANDIDATES = [1659'/usr/sbin/sendmail',1660'/usr/lib/sendmail',1661]16621663@staticmethod1664deffind_sendmail():1665for path in SendMailer.SENDMAIL_CANDIDATES:1666if os.access(path, os.X_OK):1667return path1668else:1669raiseConfigurationException(1670'No sendmail executable found. '1671'Try setting multimailhook.sendmailCommand.'1672)16731674def__init__(self, command=None, envelopesender=None):1675"""Construct a SendMailer instance.16761677 command should be the command and arguments used to invoke1678 sendmail, as a list of strings. If an envelopesender is1679 provided, it will also be passed to the command, via '-f1680 envelopesender'."""16811682if command:1683 self.command = command[:]1684else:1685 self.command = [self.find_sendmail(),'-oi','-t']16861687if envelopesender:1688 self.command.extend(['-f', envelopesender])16891690defsend(self, lines, to_addrs):1691try:1692 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)1693exceptOSError, e:1694 sys.stderr.write(1695'*** Cannot execute command:%s\n'%' '.join(self.command)1696+'***%s\n'%str(e)1697+'*** Try setting multimailhook.mailer to "smtp"\n'1698'*** to send emails without using the sendmail command.\n'1699)1700 sys.exit(1)1701try:1702 p.stdin.writelines(lines)1703exceptException, e:1704 sys.stderr.write(1705'*** Error while generating commit email\n'1706'*** - mail sending aborted.\n'1707)1708try:1709# subprocess.terminate() is not available in Python 2.41710 p.terminate()1711exceptAttributeError:1712pass1713raise e1714else:1715 p.stdin.close()1716 retcode = p.wait()1717if retcode:1718raiseCommandError(self.command, retcode)171917201721classSMTPMailer(Mailer):1722"""Send emails using Python's smtplib."""17231724def__init__(self, envelopesender, smtpserver,1725 smtpservertimeout=10.0, smtpserverdebuglevel=0,1726 smtpencryption='none',1727 smtpuser='', smtppass='',1728):1729if not envelopesender:1730 sys.stderr.write(1731'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'1732'please set either multimailhook.envelopeSender or user.email\n'1733)1734 sys.exit(1)1735if smtpencryption =='ssl'and not(smtpuser and smtppass):1736raiseConfigurationException(1737'Cannot use SMTPMailer with security option ssl '1738'without options username and password.'1739)1740 self.envelopesender = envelopesender1741 self.smtpserver = smtpserver1742 self.smtpservertimeout = smtpservertimeout1743 self.smtpserverdebuglevel = smtpserverdebuglevel1744 self.security = smtpencryption1745 self.username = smtpuser1746 self.password = smtppass1747try:1748defcall(klass, server, timeout):1749try:1750returnklass(server, timeout=timeout)1751exceptTypeError:1752# Old Python versions do not have timeout= argument.1753returnklass(server)1754if self.security =='none':1755 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)1756elif self.security =='ssl':1757 self.smtp =call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)1758elif self.security =='tls':1759if':'not in self.smtpserver:1760 self.smtpserver +=':587'# default port for TLS1761 self.smtp =call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)1762 self.smtp.ehlo()1763 self.smtp.starttls()1764 self.smtp.ehlo()1765else:1766 sys.stdout.write('*** Error: Control reached an invalid option. ***')1767 sys.exit(1)1768if self.smtpserverdebuglevel >0:1769 sys.stdout.write(1770"*** Setting debug on for SMTP server connection (%s) ***\n"1771% self.smtpserverdebuglevel)1772 self.smtp.set_debuglevel(self.smtpserverdebuglevel)1773exceptException, e:1774 sys.stderr.write(1775'*** Error establishing SMTP connection to%s***\n'1776% self.smtpserver)1777 sys.stderr.write('***%s\n'%str(e))1778 sys.exit(1)17791780def__del__(self):1781ifhasattr(self,'smtp'):1782 self.smtp.quit()17831784defsend(self, lines, to_addrs):1785try:1786if self.username or self.password:1787 sys.stderr.write("*** Authenticating as%s***\n"% self.username)1788 self.smtp.login(self.username, self.password)1789 msg =''.join(lines)1790# turn comma-separated list into Python list if needed.1791ifisinstance(to_addrs, basestring):1792 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]1793 self.smtp.sendmail(self.envelopesender, to_addrs, msg)1794exceptException, e:1795 sys.stderr.write('*** Error sending email ***\n')1796 sys.stderr.write('***%s\n'%str(e))1797 self.smtp.quit()1798 sys.exit(1)179918001801classOutputMailer(Mailer):1802"""Write emails to an output stream, bracketed by lines of '=' characters.18031804 This is intended for debugging purposes."""18051806 SEPARATOR ='='*75+'\n'18071808def__init__(self, f):1809 self.f = f18101811defsend(self, lines, to_addrs):1812 self.f.write(self.SEPARATOR)1813 self.f.writelines(lines)1814 self.f.write(self.SEPARATOR)181518161817defget_git_dir():1818"""Determine GIT_DIR.18191820 Determine GIT_DIR either from the GIT_DIR environment variable or1821 from the working directory, using Git's usual rules."""18221823try:1824returnread_git_output(['rev-parse','--git-dir'])1825except CommandError:1826 sys.stderr.write('fatal: git_multimail: not in a git directory\n')1827 sys.exit(1)182818291830classEnvironment(object):1831"""Describes the environment in which the push is occurring.18321833 An Environment object encapsulates information about the local1834 environment. For example, it knows how to determine:18351836 * the name of the repository to which the push occurred18371838 * what user did the push18391840 * what users want to be informed about various types of changes.18411842 An Environment object is expected to have the following methods:18431844 get_repo_shortname()18451846 Return a short name for the repository, for display1847 purposes.18481849 get_repo_path()18501851 Return the absolute path to the Git repository.18521853 get_emailprefix()18541855 Return a string that will be prefixed to every email's1856 subject.18571858 get_pusher()18591860 Return the username of the person who pushed the changes.1861 This value is used in the email body to indicate who1862 pushed the change.18631864 get_pusher_email() (may return None)18651866 Return the email address of the person who pushed the1867 changes. The value should be a single RFC 2822 email1868 address as a string; e.g., "Joe User <user@example.com>"1869 if available, otherwise "user@example.com". If set, the1870 value is used as the Reply-To address for refchange1871 emails. If it is impossible to determine the pusher's1872 email, this attribute should be set to None (in which case1873 no Reply-To header will be output).18741875 get_sender()18761877 Return the address to be used as the 'From' email address1878 in the email envelope.18791880 get_fromaddr()18811882 Return the 'From' email address used in the email 'From:'1883 headers. (May be a full RFC 2822 email address like 'Joe1884 User <user@example.com>'.)18851886 get_administrator()18871888 Return the name and/or email of the repository1889 administrator. This value is used in the footer as the1890 person to whom requests to be removed from the1891 notification list should be sent. Ideally, it should1892 include a valid email address.18931894 get_reply_to_refchange()1895 get_reply_to_commit()18961897 Return the address to use in the email "Reply-To" header,1898 as a string. These can be an RFC 2822 email address, or1899 None to omit the "Reply-To" header.1900 get_reply_to_refchange() is used for refchange emails;1901 get_reply_to_commit() is used for individual commit1902 emails.19031904 They should also define the following attributes:19051906 announce_show_shortlog (bool)19071908 True iff announce emails should include a shortlog.19091910 refchange_showgraph (bool)19111912 True iff refchanges emails should include a detailed graph.19131914 refchange_showlog (bool)19151916 True iff refchanges emails should include a detailed log.19171918 diffopts (list of strings)19191920 The options that should be passed to 'git diff' for the1921 summary email. The value should be a list of strings1922 representing words to be passed to the command.19231924 graphopts (list of strings)19251926 Analogous to diffopts, but contains options passed to1927 'git log --graph' when generating the detailed graph for1928 a set of commits (see refchange_showgraph)19291930 logopts (list of strings)19311932 Analogous to diffopts, but contains options passed to1933 'git log' when generating the detailed log for a set of1934 commits (see refchange_showlog)19351936 commitlogopts (list of strings)19371938 The options that should be passed to 'git log' for each1939 commit mail. The value should be a list of strings1940 representing words to be passed to the command.19411942 quiet (bool)1943 On success do not write to stderr19441945 stdout (bool)1946 Write email to stdout rather than emailing. Useful for debugging19471948 combine_when_single_commit (bool)19491950 True if a combined email should be produced when a single1951 new commit is pushed to a branch, False otherwise.19521953 """19541955 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')19561957def__init__(self, osenv=None):1958 self.osenv = osenv or os.environ1959 self.announce_show_shortlog =False1960 self.maxcommitemails =5001961 self.diffopts = ['--stat','--summary','--find-copies-harder']1962 self.graphopts = ['--oneline','--decorate']1963 self.logopts = []1964 self.refchange_showgraph =False1965 self.refchange_showlog =False1966 self.commitlogopts = ['-C','--stat','-p','--cc']1967 self.quiet =False1968 self.stdout =False1969 self.combine_when_single_commit =True19701971 self.COMPUTED_KEYS = [1972'administrator',1973'charset',1974'emailprefix',1975'fromaddr',1976'pusher',1977'pusher_email',1978'repo_path',1979'repo_shortname',1980'sender',1981]19821983 self._values =None19841985defget_repo_shortname(self):1986"""Use the last part of the repo path, with ".git" stripped off if present."""19871988 basename = os.path.basename(os.path.abspath(self.get_repo_path()))1989 m = self.REPO_NAME_RE.match(basename)1990if m:1991return m.group('name')1992else:1993return basename19941995defget_pusher(self):1996raiseNotImplementedError()19971998defget_pusher_email(self):1999return None20002001defget_fromaddr(self):2002 config =Config('user')2003 fromname = config.get('name', default='')2004 fromemail = config.get('email', default='')2005if fromemail:2006returnformataddr([fromname, fromemail])2007return self.get_sender()20082009defget_administrator(self):2010return'the administrator of this repository'20112012defget_emailprefix(self):2013return''20142015defget_repo_path(self):2016ifread_git_output(['rev-parse','--is-bare-repository']) =='true':2017 path =get_git_dir()2018else:2019 path =read_git_output(['rev-parse','--show-toplevel'])2020return os.path.abspath(path)20212022defget_charset(self):2023return CHARSET20242025defget_values(self):2026"""Return a dictionary{keyword: expansion}for this Environment.20272028 This method is called by Change._compute_values(). The keys2029 in the returned dictionary are available to be used in any of2030 the templates. The dictionary is created by calling2031 self.get_NAME() for each of the attributes named in2032 COMPUTED_KEYS and recording those that do not return None.2033 The return value is always a new dictionary."""20342035if self._values is None:2036 values = {}20372038for key in self.COMPUTED_KEYS:2039 value =getattr(self,'get_%s'% (key,))()2040if value is not None:2041 values[key] = value20422043 self._values = values20442045return self._values.copy()20462047defget_refchange_recipients(self, refchange):2048"""Return the recipients for notifications about refchange.20492050 Return the list of email addresses to which notifications2051 about the specified ReferenceChange should be sent."""20522053raiseNotImplementedError()20542055defget_announce_recipients(self, annotated_tag_change):2056"""Return the recipients for notifications about annotated_tag_change.20572058 Return the list of email addresses to which notifications2059 about the specified AnnotatedTagChange should be sent."""20602061raiseNotImplementedError()20622063defget_reply_to_refchange(self, refchange):2064return self.get_pusher_email()20652066defget_revision_recipients(self, revision):2067"""Return the recipients for messages about revision.20682069 Return the list of email addresses to which notifications2070 about the specified Revision should be sent. This method2071 could be overridden, for example, to take into account the2072 contents of the revision when deciding whom to notify about2073 it. For example, there could be a scheme for users to express2074 interest in particular files or subdirectories, and only2075 receive notification emails for revisions that affecting those2076 files."""20772078raiseNotImplementedError()20792080defget_reply_to_commit(self, revision):2081return revision.author20822083deffilter_body(self, lines):2084"""Filter the lines intended for an email body.20852086 lines is an iterable over the lines that would go into the2087 email body. Filter it (e.g., limit the number of lines, the2088 line length, character set, etc.), returning another iterable.2089 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin2090 for classes implementing this functionality."""20912092return lines20932094deflog_msg(self, msg):2095"""Write the string msg on a log file or on stderr.20962097 Sends the text to stderr by default, override to change the behavior."""2098 sys.stderr.write(msg)20992100deflog_warning(self, msg):2101"""Write the string msg on a log file or on stderr.21022103 Sends the text to stderr by default, override to change the behavior."""2104 sys.stderr.write(msg)21052106deflog_error(self, msg):2107"""Write the string msg on a log file or on stderr.21082109 Sends the text to stderr by default, override to change the behavior."""2110 sys.stderr.write(msg)211121122113classConfigEnvironmentMixin(Environment):2114"""A mixin that sets self.config to its constructor's config argument.21152116 This class's constructor consumes the "config" argument.21172118 Mixins that need to inspect the config should inherit from this2119 class (1) to make sure that "config" is still in the constructor2120 arguments with its own constructor runs and/or (2) to be sure that2121 self.config is set after construction."""21222123def__init__(self, config, **kw):2124super(ConfigEnvironmentMixin, self).__init__(**kw)2125 self.config = config212621272128classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):2129"""An Environment that reads most of its information from "git config"."""21302131def__init__(self, config, **kw):2132super(ConfigOptionsEnvironmentMixin, self).__init__(2133 config=config, **kw2134)21352136for var, cfg in(2137('announce_show_shortlog','announceshortlog'),2138('refchange_showgraph','refchangeShowGraph'),2139('refchange_showlog','refchangeshowlog'),2140('quiet','quiet'),2141('stdout','stdout'),2142):2143 val = config.get_bool(cfg)2144if val is not None:2145setattr(self, var, val)21462147 maxcommitemails = config.get('maxcommitemails')2148if maxcommitemails is not None:2149try:2150 self.maxcommitemails =int(maxcommitemails)2151exceptValueError:2152 self.log_warning(2153'*** Malformed value for multimailhook.maxCommitEmails:%s\n'% maxcommitemails2154+'*** Expected a number. Ignoring.\n'2155)21562157 diffopts = config.get('diffopts')2158if diffopts is not None:2159 self.diffopts = shlex.split(diffopts)21602161 graphopts = config.get('graphOpts')2162if graphopts is not None:2163 self.graphopts = shlex.split(graphopts)21642165 logopts = config.get('logopts')2166if logopts is not None:2167 self.logopts = shlex.split(logopts)21682169 commitlogopts = config.get('commitlogopts')2170if commitlogopts is not None:2171 self.commitlogopts = shlex.split(commitlogopts)21722173 reply_to = config.get('replyTo')2174 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)2175if(2176 self.__reply_to_refchange is not None2177and self.__reply_to_refchange.lower() =='author'2178):2179raiseConfigurationException(2180'"author" is not an allowed setting for replyToRefchange'2181)2182 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)21832184 combine = config.get_bool('combineWhenSingleCommit')2185if combine is not None:2186 self.combine_when_single_commit = combine21872188defget_administrator(self):2189return(2190 self.config.get('administrator')2191or self.get_sender()2192orsuper(ConfigOptionsEnvironmentMixin, self).get_administrator()2193)21942195defget_repo_shortname(self):2196return(2197 self.config.get('reponame')2198orsuper(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()2199)22002201defget_emailprefix(self):2202 emailprefix = self.config.get('emailprefix')2203if emailprefix is not None:2204 emailprefix = emailprefix.strip()2205if emailprefix:2206return emailprefix +' '2207else:2208return''2209else:2210return'[%s] '% (self.get_repo_shortname(),)22112212defget_sender(self):2213return self.config.get('envelopesender')22142215defget_fromaddr(self):2216 fromaddr = self.config.get('from')2217if fromaddr:2218return fromaddr2219returnsuper(ConfigOptionsEnvironmentMixin, self).get_fromaddr()22202221defget_reply_to_refchange(self, refchange):2222if self.__reply_to_refchange is None:2223returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)2224elif self.__reply_to_refchange.lower() =='pusher':2225return self.get_pusher_email()2226elif self.__reply_to_refchange.lower() =='none':2227return None2228else:2229return self.__reply_to_refchange22302231defget_reply_to_commit(self, revision):2232if self.__reply_to_commit is None:2233returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)2234elif self.__reply_to_commit.lower() =='author':2235return revision.author2236elif self.__reply_to_commit.lower() =='pusher':2237return self.get_pusher_email()2238elif self.__reply_to_commit.lower() =='none':2239return None2240else:2241return self.__reply_to_commit22422243defget_scancommitforcc(self):2244return self.config.get('scancommitforcc')224522462247classFilterLinesEnvironmentMixin(Environment):2248"""Handle encoding and maximum line length of body lines.22492250 emailmaxlinelength (int or None)22512252 The maximum length of any single line in the email body.2253 Longer lines are truncated at that length with ' [...]'2254 appended.22552256 strict_utf8 (bool)22572258 If this field is set to True, then the email body text is2259 expected to be UTF-8. Any invalid characters are2260 converted to U+FFFD, the Unicode replacement character2261 (encoded as UTF-8, of course).22622263 """22642265def__init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):2266super(FilterLinesEnvironmentMixin, self).__init__(**kw)2267 self.__strict_utf8= strict_utf82268 self.__emailmaxlinelength = emailmaxlinelength22692270deffilter_body(self, lines):2271 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)2272if self.__strict_utf8:2273 lines = (line.decode(ENCODING,'replace')for line in lines)2274# Limit the line length in Unicode-space to avoid2275# splitting characters:2276if self.__emailmaxlinelength:2277 lines =limit_linelength(lines, self.__emailmaxlinelength)2278 lines = (line.encode(ENCODING,'replace')for line in lines)2279elif self.__emailmaxlinelength:2280 lines =limit_linelength(lines, self.__emailmaxlinelength)22812282return lines228322842285classConfigFilterLinesEnvironmentMixin(2286 ConfigEnvironmentMixin,2287 FilterLinesEnvironmentMixin,2288):2289"""Handle encoding and maximum line length based on config."""22902291def__init__(self, config, **kw):2292 strict_utf8 = config.get_bool('emailstrictutf8', default=None)2293if strict_utf8 is not None:2294 kw['strict_utf8'] = strict_utf822952296 emailmaxlinelength = config.get('emailmaxlinelength')2297if emailmaxlinelength is not None:2298 kw['emailmaxlinelength'] =int(emailmaxlinelength)22992300super(ConfigFilterLinesEnvironmentMixin, self).__init__(2301 config=config, **kw2302)230323042305classMaxlinesEnvironmentMixin(Environment):2306"""Limit the email body to a specified number of lines."""23072308def__init__(self, emailmaxlines, **kw):2309super(MaxlinesEnvironmentMixin, self).__init__(**kw)2310 self.__emailmaxlines = emailmaxlines23112312deffilter_body(self, lines):2313 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)2314if self.__emailmaxlines:2315 lines =limit_lines(lines, self.__emailmaxlines)2316return lines231723182319classConfigMaxlinesEnvironmentMixin(2320 ConfigEnvironmentMixin,2321 MaxlinesEnvironmentMixin,2322):2323"""Limit the email body to the number of lines specified in config."""23242325def__init__(self, config, **kw):2326 emailmaxlines =int(config.get('emailmaxlines', default='0'))2327super(ConfigMaxlinesEnvironmentMixin, self).__init__(2328 config=config,2329 emailmaxlines=emailmaxlines,2330**kw2331)233223332334classFQDNEnvironmentMixin(Environment):2335"""A mixin that sets the host's FQDN to its constructor argument."""23362337def__init__(self, fqdn, **kw):2338super(FQDNEnvironmentMixin, self).__init__(**kw)2339 self.COMPUTED_KEYS += ['fqdn']2340 self.__fqdn = fqdn23412342defget_fqdn(self):2343"""Return the fully-qualified domain name for this host.23442345 Return None if it is unavailable or unwanted."""23462347return self.__fqdn234823492350classConfigFQDNEnvironmentMixin(2351 ConfigEnvironmentMixin,2352 FQDNEnvironmentMixin,2353):2354"""Read the FQDN from the config."""23552356def__init__(self, config, **kw):2357 fqdn = config.get('fqdn')2358super(ConfigFQDNEnvironmentMixin, self).__init__(2359 config=config,2360 fqdn=fqdn,2361**kw2362)236323642365classComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):2366"""Get the FQDN by calling socket.getfqdn()."""23672368def__init__(self, **kw):2369super(ComputeFQDNEnvironmentMixin, self).__init__(2370 fqdn=socket.getfqdn(),2371**kw2372)237323742375classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):2376"""Deduce pusher_email from pusher by appending an emaildomain."""23772378def__init__(self, **kw):2379super(PusherDomainEnvironmentMixin, self).__init__(**kw)2380 self.__emaildomain = self.config.get('emaildomain')23812382defget_pusher_email(self):2383if self.__emaildomain:2384# Derive the pusher's full email address in the default way:2385return'%s@%s'% (self.get_pusher(), self.__emaildomain)2386else:2387returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()238823892390classStaticRecipientsEnvironmentMixin(Environment):2391"""Set recipients statically based on constructor parameters."""23922393def__init__(2394 self,2395 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,2396**kw2397):2398super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)23992400# The recipients for various types of notification emails, as2401# RFC 2822 email addresses separated by commas (or the empty2402# string if no recipients are configured). Although there is2403# a mechanism to choose the recipient lists based on on the2404# actual *contents* of the change being reported, we only2405# choose based on the *type* of the change. Therefore we can2406# compute them once and for all:2407if not(refchange_recipients2408or announce_recipients2409or revision_recipients2410or scancommitforcc):2411raiseConfigurationException('No email recipients configured!')2412 self.__refchange_recipients = refchange_recipients2413 self.__announce_recipients = announce_recipients2414 self.__revision_recipients = revision_recipients24152416defget_refchange_recipients(self, refchange):2417return self.__refchange_recipients24182419defget_announce_recipients(self, annotated_tag_change):2420return self.__announce_recipients24212422defget_revision_recipients(self, revision):2423return self.__revision_recipients242424252426classConfigRecipientsEnvironmentMixin(2427 ConfigEnvironmentMixin,2428 StaticRecipientsEnvironmentMixin2429):2430"""Determine recipients statically based on config."""24312432def__init__(self, config, **kw):2433super(ConfigRecipientsEnvironmentMixin, self).__init__(2434 config=config,2435 refchange_recipients=self._get_recipients(2436 config,'refchangelist','mailinglist',2437),2438 announce_recipients=self._get_recipients(2439 config,'announcelist','refchangelist','mailinglist',2440),2441 revision_recipients=self._get_recipients(2442 config,'commitlist','mailinglist',2443),2444 scancommitforcc=config.get('scancommitforcc'),2445**kw2446)24472448def_get_recipients(self, config, *names):2449"""Return the recipients for a particular type of message.24502451 Return the list of email addresses to which a particular type2452 of notification email should be sent, by looking at the config2453 value for "multimailhook.$name" for each of names. Use the2454 value from the first name that is configured. The return2455 value is a (possibly empty) string containing RFC 2822 email2456 addresses separated by commas. If no configuration could be2457 found, raise a ConfigurationException."""24582459for name in names:2460 retval = config.get_recipients(name)2461if retval is not None:2462return retval2463else:2464return''246524662467classProjectdescEnvironmentMixin(Environment):2468"""Make a "projectdesc" value available for templates.24692470 By default, it is set to the first line of $GIT_DIR/description2471 (if that file is present and appears to be set meaningfully)."""24722473def__init__(self, **kw):2474super(ProjectdescEnvironmentMixin, self).__init__(**kw)2475 self.COMPUTED_KEYS += ['projectdesc']24762477defget_projectdesc(self):2478"""Return a one-line descripition of the project."""24792480 git_dir =get_git_dir()2481try:2482 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()2483if projectdesc and not projectdesc.startswith('Unnamed repository'):2484return projectdesc2485exceptIOError:2486pass24872488return'UNNAMED PROJECT'248924902491classGenericEnvironmentMixin(Environment):2492defget_pusher(self):2493return self.osenv.get('USER', self.osenv.get('USERNAME','unknown user'))249424952496classGenericEnvironment(2497 ProjectdescEnvironmentMixin,2498 ConfigMaxlinesEnvironmentMixin,2499 ComputeFQDNEnvironmentMixin,2500 ConfigFilterLinesEnvironmentMixin,2501 ConfigRecipientsEnvironmentMixin,2502 PusherDomainEnvironmentMixin,2503 ConfigOptionsEnvironmentMixin,2504 GenericEnvironmentMixin,2505 Environment,2506):2507pass250825092510classGitoliteEnvironmentMixin(Environment):2511defget_repo_shortname(self):2512# The gitolite environment variable $GL_REPO is a pretty good2513# repo_shortname (though it's probably not as good as a value2514# the user might have explicitly put in his config).2515return(2516 self.osenv.get('GL_REPO',None)2517orsuper(GitoliteEnvironmentMixin, self).get_repo_shortname()2518)25192520defget_pusher(self):2521return self.osenv.get('GL_USER','unknown user')25222523defget_fromaddr(self):2524 GL_USER = self.osenv.get('GL_USER')2525if GL_USER is not None:2526# Find the path to gitolite.conf. Note that gitolite v32527# did away with the GL_ADMINDIR and GL_CONF environment2528# variables (they are now hard-coded).2529 GL_ADMINDIR = self.osenv.get(2530'GL_ADMINDIR',2531 os.path.expanduser(os.path.join('~','.gitolite')))2532 GL_CONF = self.osenv.get(2533'GL_CONF',2534 os.path.join(GL_ADMINDIR,'conf','gitolite.conf'))2535if os.path.isfile(GL_CONF):2536 f =open(GL_CONF,'rU')2537try:2538 in_user_emails_section =False2539 re_template = r'^\s*#\s*{}\s*$'2540 re_begin, re_user, re_end = (2541 re.compile(re_template.format(x))2542for x in(2543 r'BEGIN\s+USER\s+EMAILS',2544 re.escape(GL_USER) + r'\s+(.*)',2545 r'END\s+USER\s+EMAILS',2546))2547for l in f:2548 l = l.rstrip('\n')2549if not in_user_emails_section:2550if re_begin.match(l):2551 in_user_emails_section =True2552continue2553if re_end.match(l):2554break2555 m = re_user.match(l)2556if m:2557return m.group(1)2558finally:2559 f.close()2560returnsuper(GitoliteEnvironmentMixin, self).get_fromaddr()256125622563classIncrementalDateTime(object):2564"""Simple wrapper to give incremental date/times.25652566 Each call will result in a date/time a second later than the2567 previous call. This can be used to falsify email headers, to2568 increase the likelihood that email clients sort the emails2569 correctly."""25702571def__init__(self):2572 self.time = time.time()25732574defnext(self):2575 formatted =formatdate(self.time,True)2576 self.time +=12577return formatted257825792580classGitoliteEnvironment(2581 ProjectdescEnvironmentMixin,2582 ConfigMaxlinesEnvironmentMixin,2583 ComputeFQDNEnvironmentMixin,2584 ConfigFilterLinesEnvironmentMixin,2585 ConfigRecipientsEnvironmentMixin,2586 PusherDomainEnvironmentMixin,2587 ConfigOptionsEnvironmentMixin,2588 GitoliteEnvironmentMixin,2589 Environment,2590):2591pass259225932594classPush(object):2595"""Represent an entire push (i.e., a group of ReferenceChanges).25962597 It is easy to figure out what commits were added to a *branch* by2598 a Reference change:25992600 git rev-list change.old..change.new26012602 or removed from a *branch*:26032604 git rev-list change.new..change.old26052606 But it is not quite so trivial to determine which entirely new2607 commits were added to the *repository* by a push and which old2608 commits were discarded by a push. A big part of the job of this2609 class is to figure out these things, and to make sure that new2610 commits are only detailed once even if they were added to multiple2611 references.26122613 The first step is to determine the "other" references--those2614 unaffected by the current push. They are computed by listing all2615 references then removing any affected by this push. The results2616 are stored in Push._other_ref_sha1s.26172618 The commits contained in the repository before this push were26192620 git rev-list other1 other2 other3 ... change1.old change2.old ...26212622 Where "changeN.old" is the old value of one of the references2623 affected by this push.26242625 The commits contained in the repository after this push are26262627 git rev-list other1 other2 other3 ... change1.new change2.new ...26282629 The commits added by this push are the difference between these2630 two sets, which can be written26312632 git rev-list \2633 ^other1 ^other2 ... \2634 ^change1.old ^change2.old ... \2635 change1.new change2.new ...26362637 The commits removed by this push can be computed by26382639 git rev-list \2640 ^other1 ^other2 ... \2641 ^change1.new ^change2.new ... \2642 change1.old change2.old ...26432644 The last point is that it is possible that other pushes are2645 occurring simultaneously to this one, so reference values can2646 change at any time. It is impossible to eliminate all race2647 conditions, but we reduce the window of time during which problems2648 can occur by translating reference names to SHA1s as soon as2649 possible and working with SHA1s thereafter (because SHA1s are2650 immutable)."""26512652# A map {(changeclass, changetype): integer} specifying the order2653# that reference changes will be processed if multiple reference2654# changes are included in a single push. The order is significant2655# mostly because new commit notifications are threaded together2656# with the first reference change that includes the commit. The2657# following order thus causes commits to be grouped with branch2658# changes (as opposed to tag changes) if possible.2659 SORT_ORDER =dict(2660(value, i)for(i, value)inenumerate([2661(BranchChange,'update'),2662(BranchChange,'create'),2663(AnnotatedTagChange,'update'),2664(AnnotatedTagChange,'create'),2665(NonAnnotatedTagChange,'update'),2666(NonAnnotatedTagChange,'create'),2667(BranchChange,'delete'),2668(AnnotatedTagChange,'delete'),2669(NonAnnotatedTagChange,'delete'),2670(OtherReferenceChange,'update'),2671(OtherReferenceChange,'create'),2672(OtherReferenceChange,'delete'),2673])2674)26752676def__init__(self, changes, ignore_other_refs=False):2677 self.changes =sorted(changes, key=self._sort_key)2678 self.__other_ref_sha1s =None2679 self.__cached_commits_spec = {}26802681if ignore_other_refs:2682 self.__other_ref_sha1s =set()26832684@classmethod2685def_sort_key(klass, change):2686return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)26872688@property2689def_other_ref_sha1s(self):2690"""The GitObjects referred to by references unaffected by this push.2691 """2692if self.__other_ref_sha1s is None:2693# The refnames being changed by this push:2694 updated_refs =set(2695 change.refname2696for change in self.changes2697)26982699# The SHA-1s of commits referred to by all references in this2700# repository *except* updated_refs:2701 sha1s =set()2702 fmt = (2703'%(objectname) %(objecttype) %(refname)\n'2704'%(*objectname) %(*objecttype)%(refname)'2705)2706for line inread_git_lines(2707['for-each-ref','--format=%s'% (fmt,)]):2708(sha1,type, name) = line.split(' ',2)2709if sha1 andtype=='commit'and name not in updated_refs:2710 sha1s.add(sha1)27112712 self.__other_ref_sha1s = sha1s27132714return self.__other_ref_sha1s27152716def_get_commits_spec_incl(self, new_or_old, reference_change=None):2717"""Get new or old SHA-1 from one or each of the changed refs.27182719 Return a list of SHA-1 commit identifier strings suitable as2720 arguments to 'git rev-list' (or 'git log' or ...). The2721 returned identifiers are either the old or new values from one2722 or all of the changed references, depending on the values of2723 new_or_old and reference_change.27242725 new_or_old is either the string 'new' or the string 'old'. If2726 'new', the returned SHA-1 identifiers are the new values from2727 each changed reference. If 'old', the SHA-1 identifiers are2728 the old values from each changed reference.27292730 If reference_change is specified and not None, only the new or2731 old reference from the specified reference is included in the2732 return value.27332734 This function returns None if there are no matching revisions2735 (e.g., because a branch was deleted and new_or_old is 'new').2736 """27372738if not reference_change:2739 incl_spec =sorted(2740getattr(change, new_or_old).sha12741for change in self.changes2742ifgetattr(change, new_or_old)2743)2744if not incl_spec:2745 incl_spec =None2746elif notgetattr(reference_change, new_or_old).commit_sha1:2747 incl_spec =None2748else:2749 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]2750return incl_spec27512752def_get_commits_spec_excl(self, new_or_old):2753"""Get exclusion revisions for determining new or discarded commits.27542755 Return a list of strings suitable as arguments to 'git2756 rev-list' (or 'git log' or ...) that will exclude all2757 commits that, depending on the value of new_or_old, were2758 either previously in the repository (useful for determining2759 which commits are new to the repository) or currently in the2760 repository (useful for determining which commits were2761 discarded from the repository).27622763 new_or_old is either the string 'new' or the string 'old'. If2764 'new', the commits to be excluded are those that were in the2765 repository before the push. If 'old', the commits to be2766 excluded are those that are currently in the repository. """27672768 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]2769 excl_revs = self._other_ref_sha1s.union(2770getattr(change, old_or_new).sha12771for change in self.changes2772ifgetattr(change, old_or_new).typein['commit','tag']2773)2774return['^'+ sha1 for sha1 insorted(excl_revs)]27752776defget_commits_spec(self, new_or_old, reference_change=None):2777"""Get rev-list arguments for added or discarded commits.27782779 Return a list of strings suitable as arguments to 'git2780 rev-list' (or 'git log' or ...) that select those commits2781 that, depending on the value of new_or_old, are either new to2782 the repository or were discarded from the repository.27832784 new_or_old is either the string 'new' or the string 'old'. If2785 'new', the returned list is used to select commits that are2786 new to the repository. If 'old', the returned value is used2787 to select the commits that have been discarded from the2788 repository.27892790 If reference_change is specified and not None, the new or2791 discarded commits are limited to those that are reachable from2792 the new or old value of the specified reference.27932794 This function returns None if there are no added (or discarded)2795 revisions.2796 """2797 key = (new_or_old, reference_change)2798if key not in self.__cached_commits_spec:2799 ret = self._get_commits_spec_incl(new_or_old, reference_change)2800if ret is not None:2801 ret.extend(self._get_commits_spec_excl(new_or_old))2802 self.__cached_commits_spec[key] = ret2803return self.__cached_commits_spec[key]28042805defget_new_commits(self, reference_change=None):2806"""Return a list of commits added by this push.28072808 Return a list of the object names of commits that were added2809 by the part of this push represented by reference_change. If2810 reference_change is None, then return a list of *all* commits2811 added by this push."""28122813 spec = self.get_commits_spec('new', reference_change)2814returngit_rev_list(spec)28152816defget_discarded_commits(self, reference_change):2817"""Return a list of commits discarded by this push.28182819 Return a list of the object names of commits that were2820 entirely discarded from the repository by the part of this2821 push represented by reference_change."""28222823 spec = self.get_commits_spec('old', reference_change)2824returngit_rev_list(spec)28252826defsend_emails(self, mailer, body_filter=None):2827"""Use send all of the notification emails needed for this push.28282829 Use send all of the notification emails (including reference2830 change emails and commit emails) needed for this push. Send2831 the emails using mailer. If body_filter is not None, then use2832 it to filter the lines that are intended for the email2833 body."""28342835# The sha1s of commits that were introduced by this push.2836# They will be removed from this set as they are processed, to2837# guarantee that one (and only one) email is generated for2838# each new commit.2839 unhandled_sha1s =set(self.get_new_commits())2840 send_date =IncrementalDateTime()2841for change in self.changes:2842 sha1s = []2843for sha1 inreversed(list(self.get_new_commits(change))):2844if sha1 in unhandled_sha1s:2845 sha1s.append(sha1)2846 unhandled_sha1s.remove(sha1)28472848# Check if we've got anyone to send to2849if not change.recipients:2850 change.environment.log_warning(2851'*** no recipients configured so no email will be sent\n'2852'*** for%rupdate%s->%s\n'2853% (change.refname, change.old.sha1, change.new.sha1,)2854)2855else:2856if not change.environment.quiet:2857 change.environment.log_msg(2858'Sending notification emails to:%s\n'% (change.recipients,))2859 extra_values = {'send_date': send_date.next()}28602861 rev = change.send_single_combined_email(sha1s)2862if rev:2863 mailer.send(2864 change.generate_combined_email(self, rev, body_filter, extra_values),2865 rev.recipients,2866)2867# This change is now fully handled; no need to handle2868# individual revisions any further.2869continue2870else:2871 mailer.send(2872 change.generate_email(self, body_filter, extra_values),2873 change.recipients,2874)28752876 max_emails = change.environment.maxcommitemails2877if max_emails andlen(sha1s) > max_emails:2878 change.environment.log_warning(2879'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s)2880+'*** Try setting multimailhook.maxCommitEmails to a greater value\n'2881+'*** Currently, multimailhook.maxCommitEmails=%d\n'% max_emails2882)2883return28842885for(num, sha1)inenumerate(sha1s):2886 rev =Revision(change,GitObject(sha1), num=num +1, tot=len(sha1s))2887if not rev.recipients and rev.cc_recipients:2888 change.environment.log_msg('*** Replacing Cc: with To:\n')2889 rev.recipients = rev.cc_recipients2890 rev.cc_recipients =None2891if rev.recipients:2892 extra_values = {'send_date': send_date.next()}2893 mailer.send(2894 rev.generate_email(self, body_filter, extra_values),2895 rev.recipients,2896)28972898# Consistency check:2899if unhandled_sha1s:2900 change.environment.log_error(2901'ERROR: No emails were sent for the following new commits:\n'2902'%s\n'2903% ('\n'.join(sorted(unhandled_sha1s)),)2904)290529062907defrun_as_post_receive_hook(environment, mailer):2908 changes = []2909for line in sys.stdin:2910(oldrev, newrev, refname) = line.strip().split(' ',2)2911 changes.append(2912 ReferenceChange.create(environment, oldrev, newrev, refname)2913)2914 push =Push(changes)2915 push.send_emails(mailer, body_filter=environment.filter_body)291629172918defrun_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):2919 changes = [2920 ReferenceChange.create(2921 environment,2922read_git_output(['rev-parse','--verify', oldrev]),2923read_git_output(['rev-parse','--verify', newrev]),2924 refname,2925),2926]2927 push =Push(changes, force_send)2928 push.send_emails(mailer, body_filter=environment.filter_body)292929302931defchoose_mailer(config, environment):2932 mailer = config.get('mailer', default='sendmail')29332934if mailer =='smtp':2935 smtpserver = config.get('smtpserver', default='localhost')2936 smtpservertimeout =float(config.get('smtpservertimeout', default=10.0))2937 smtpserverdebuglevel =int(config.get('smtpserverdebuglevel', default=0))2938 smtpencryption = config.get('smtpencryption', default='none')2939 smtpuser = config.get('smtpuser', default='')2940 smtppass = config.get('smtppass', default='')2941 mailer =SMTPMailer(2942 envelopesender=(environment.get_sender()or environment.get_fromaddr()),2943 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,2944 smtpserverdebuglevel=smtpserverdebuglevel,2945 smtpencryption=smtpencryption,2946 smtpuser=smtpuser,2947 smtppass=smtppass,2948)2949elif mailer =='sendmail':2950 command = config.get('sendmailcommand')2951if command:2952 command = shlex.split(command)2953 mailer =SendMailer(command=command, envelopesender=environment.get_sender())2954else:2955 environment.log_error(2956'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer2957+'please use one of "smtp" or "sendmail".\n'2958)2959 sys.exit(1)2960return mailer296129622963KNOWN_ENVIRONMENTS = {2964'generic': GenericEnvironmentMixin,2965'gitolite': GitoliteEnvironmentMixin,2966}296729682969defchoose_environment(config, osenv=None, env=None, recipients=None):2970if not osenv:2971 osenv = os.environ29722973 environment_mixins = [2974 ProjectdescEnvironmentMixin,2975 ConfigMaxlinesEnvironmentMixin,2976 ComputeFQDNEnvironmentMixin,2977 ConfigFilterLinesEnvironmentMixin,2978 PusherDomainEnvironmentMixin,2979 ConfigOptionsEnvironmentMixin,2980]2981 environment_kw = {2982'osenv': osenv,2983'config': config,2984}29852986if not env:2987 env = config.get('environment')29882989if not env:2990if'GL_USER'in osenv and'GL_REPO'in osenv:2991 env ='gitolite'2992else:2993 env ='generic'29942995 environment_mixins.append(KNOWN_ENVIRONMENTS[env])29962997if recipients:2998 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)2999 environment_kw['refchange_recipients'] = recipients3000 environment_kw['announce_recipients'] = recipients3001 environment_kw['revision_recipients'] = recipients3002 environment_kw['scancommitforcc'] = config.get('scancommitforcc')3003else:3004 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)30053006 environment_klass =type(3007'EffectiveEnvironment',3008tuple(environment_mixins) + (Environment,),3009{},3010)3011returnenvironment_klass(**environment_kw)301230133014defmain(args):3015 parser = optparse.OptionParser(3016 description=__doc__,3017 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',3018)30193020 parser.add_option(3021'--environment','--env', action='store',type='choice',3022 choices=['generic','gitolite'], default=None,3023help=(3024'Choose type of environment is in use. Default is taken from '3025'multimailhook.environment if set; otherwise "generic".'3026),3027)3028 parser.add_option(3029'--stdout', action='store_true', default=False,3030help='Output emails to stdout rather than sending them.',3031)3032 parser.add_option(3033'--recipients', action='store', default=None,3034help='Set list of email recipients for all types of emails.',3035)3036 parser.add_option(3037'--show-env', action='store_true', default=False,3038help=(3039'Write to stderr the values determined for the environment '3040'(intended for debugging purposes).'3041),3042)3043 parser.add_option(3044'--force-send', action='store_true', default=False,3045help=(3046'Force sending refchange email when using as an update hook. '3047'This is useful to work around the unreliable new commits '3048'detection in this mode.'3049),3050)30513052(options, args) = parser.parse_args(args)30533054 config =Config('multimailhook')30553056try:3057 environment =choose_environment(3058 config, osenv=os.environ,3059 env=options.environment,3060 recipients=options.recipients,3061)30623063if options.show_env:3064 sys.stderr.write('Environment values:\n')3065for(k, v)insorted(environment.get_values().items()):3066 sys.stderr.write('%s:%r\n'% (k, v))3067 sys.stderr.write('\n')30683069if options.stdout or environment.stdout:3070 mailer =OutputMailer(sys.stdout)3071else:3072 mailer =choose_mailer(config, environment)30733074# Dual mode: if arguments were specified on the command line, run3075# like an update hook; otherwise, run as a post-receive hook.3076if args:3077iflen(args) !=3:3078 parser.error('Need zero or three non-option arguments')3079(refname, oldrev, newrev) = args3080run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)3081else:3082run_as_post_receive_hook(environment, mailer)3083except ConfigurationException, e:3084 sys.exit(str(e))308530863087if __name__ =='__main__':3088main(sys.argv[1:])