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:1748if self.security =='none':1749 self.smtp = smtplib.SMTP(self.smtpserver, timeout=self.smtpservertimeout)1750elif self.security =='ssl':1751 self.smtp = smtplib.SMTP_SSL(self.smtpserver, timeout=self.smtpservertimeout)1752elif self.security =='tls':1753if':'not in self.smtpserver:1754 self.smtpserver +=':587'# default port for TLS1755 self.smtp = smtplib.SMTP(self.smtpserver, timeout=self.smtpservertimeout)1756 self.smtp.ehlo()1757 self.smtp.starttls()1758 self.smtp.ehlo()1759else:1760 sys.stdout.write('*** Error: Control reached an invalid option. ***')1761 sys.exit(1)1762if self.smtpserverdebuglevel >0:1763 sys.stdout.write(1764"*** Setting debug on for SMTP server connection (%s) ***\n"1765% self.smtpserverdebuglevel)1766 self.smtp.set_debuglevel(self.smtpserverdebuglevel)1767exceptException, e:1768 sys.stderr.write(1769'*** Error establishing SMTP connection to%s***\n'1770% self.smtpserver)1771 sys.stderr.write('***%s\n'%str(e))1772 sys.exit(1)17731774def__del__(self):1775ifhasattr(self,'smtp'):1776 self.smtp.quit()17771778defsend(self, lines, to_addrs):1779try:1780if self.username or self.password:1781 sys.stderr.write("*** Authenticating as%s***\n"% self.username)1782 self.smtp.login(self.username, self.password)1783 msg =''.join(lines)1784# turn comma-separated list into Python list if needed.1785ifisinstance(to_addrs, basestring):1786 to_addrs = [email for(name, email)ingetaddresses([to_addrs])]1787 self.smtp.sendmail(self.envelopesender, to_addrs, msg)1788exceptException, e:1789 sys.stderr.write('*** Error sending email ***\n')1790 sys.stderr.write('***%s\n'%str(e))1791 self.smtp.quit()1792 sys.exit(1)179317941795classOutputMailer(Mailer):1796"""Write emails to an output stream, bracketed by lines of '=' characters.17971798 This is intended for debugging purposes."""17991800 SEPARATOR ='='*75+'\n'18011802def__init__(self, f):1803 self.f = f18041805defsend(self, lines, to_addrs):1806 self.f.write(self.SEPARATOR)1807 self.f.writelines(lines)1808 self.f.write(self.SEPARATOR)180918101811defget_git_dir():1812"""Determine GIT_DIR.18131814 Determine GIT_DIR either from the GIT_DIR environment variable or1815 from the working directory, using Git's usual rules."""18161817try:1818returnread_git_output(['rev-parse','--git-dir'])1819except CommandError:1820 sys.stderr.write('fatal: git_multimail: not in a git directory\n')1821 sys.exit(1)182218231824classEnvironment(object):1825"""Describes the environment in which the push is occurring.18261827 An Environment object encapsulates information about the local1828 environment. For example, it knows how to determine:18291830 * the name of the repository to which the push occurred18311832 * what user did the push18331834 * what users want to be informed about various types of changes.18351836 An Environment object is expected to have the following methods:18371838 get_repo_shortname()18391840 Return a short name for the repository, for display1841 purposes.18421843 get_repo_path()18441845 Return the absolute path to the Git repository.18461847 get_emailprefix()18481849 Return a string that will be prefixed to every email's1850 subject.18511852 get_pusher()18531854 Return the username of the person who pushed the changes.1855 This value is used in the email body to indicate who1856 pushed the change.18571858 get_pusher_email() (may return None)18591860 Return the email address of the person who pushed the1861 changes. The value should be a single RFC 2822 email1862 address as a string; e.g., "Joe User <user@example.com>"1863 if available, otherwise "user@example.com". If set, the1864 value is used as the Reply-To address for refchange1865 emails. If it is impossible to determine the pusher's1866 email, this attribute should be set to None (in which case1867 no Reply-To header will be output).18681869 get_sender()18701871 Return the address to be used as the 'From' email address1872 in the email envelope.18731874 get_fromaddr()18751876 Return the 'From' email address used in the email 'From:'1877 headers. (May be a full RFC 2822 email address like 'Joe1878 User <user@example.com>'.)18791880 get_administrator()18811882 Return the name and/or email of the repository1883 administrator. This value is used in the footer as the1884 person to whom requests to be removed from the1885 notification list should be sent. Ideally, it should1886 include a valid email address.18871888 get_reply_to_refchange()1889 get_reply_to_commit()18901891 Return the address to use in the email "Reply-To" header,1892 as a string. These can be an RFC 2822 email address, or1893 None to omit the "Reply-To" header.1894 get_reply_to_refchange() is used for refchange emails;1895 get_reply_to_commit() is used for individual commit1896 emails.18971898 They should also define the following attributes:18991900 announce_show_shortlog (bool)19011902 True iff announce emails should include a shortlog.19031904 refchange_showgraph (bool)19051906 True iff refchanges emails should include a detailed graph.19071908 refchange_showlog (bool)19091910 True iff refchanges emails should include a detailed log.19111912 diffopts (list of strings)19131914 The options that should be passed to 'git diff' for the1915 summary email. The value should be a list of strings1916 representing words to be passed to the command.19171918 graphopts (list of strings)19191920 Analogous to diffopts, but contains options passed to1921 'git log --graph' when generating the detailed graph for1922 a set of commits (see refchange_showgraph)19231924 logopts (list of strings)19251926 Analogous to diffopts, but contains options passed to1927 'git log' when generating the detailed log for a set of1928 commits (see refchange_showlog)19291930 commitlogopts (list of strings)19311932 The options that should be passed to 'git log' for each1933 commit mail. The value should be a list of strings1934 representing words to be passed to the command.19351936 quiet (bool)1937 On success do not write to stderr19381939 stdout (bool)1940 Write email to stdout rather than emailing. Useful for debugging19411942 combine_when_single_commit (bool)19431944 True if a combined email should be produced when a single1945 new commit is pushed to a branch, False otherwise.19461947 """19481949 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')19501951def__init__(self, osenv=None):1952 self.osenv = osenv or os.environ1953 self.announce_show_shortlog =False1954 self.maxcommitemails =5001955 self.diffopts = ['--stat','--summary','--find-copies-harder']1956 self.graphopts = ['--oneline','--decorate']1957 self.logopts = []1958 self.refchange_showgraph =False1959 self.refchange_showlog =False1960 self.commitlogopts = ['-C','--stat','-p','--cc']1961 self.quiet =False1962 self.stdout =False1963 self.combine_when_single_commit =True19641965 self.COMPUTED_KEYS = [1966'administrator',1967'charset',1968'emailprefix',1969'fromaddr',1970'pusher',1971'pusher_email',1972'repo_path',1973'repo_shortname',1974'sender',1975]19761977 self._values =None19781979defget_repo_shortname(self):1980"""Use the last part of the repo path, with ".git" stripped off if present."""19811982 basename = os.path.basename(os.path.abspath(self.get_repo_path()))1983 m = self.REPO_NAME_RE.match(basename)1984if m:1985return m.group('name')1986else:1987return basename19881989defget_pusher(self):1990raiseNotImplementedError()19911992defget_pusher_email(self):1993return None19941995defget_fromaddr(self):1996 config =Config('user')1997 fromname = config.get('name', default='')1998 fromemail = config.get('email', default='')1999if fromemail:2000returnformataddr([fromname, fromemail])2001return self.get_sender()20022003defget_administrator(self):2004return'the administrator of this repository'20052006defget_emailprefix(self):2007return''20082009defget_repo_path(self):2010ifread_git_output(['rev-parse','--is-bare-repository']) =='true':2011 path =get_git_dir()2012else:2013 path =read_git_output(['rev-parse','--show-toplevel'])2014return os.path.abspath(path)20152016defget_charset(self):2017return CHARSET20182019defget_values(self):2020"""Return a dictionary{keyword: expansion}for this Environment.20212022 This method is called by Change._compute_values(). The keys2023 in the returned dictionary are available to be used in any of2024 the templates. The dictionary is created by calling2025 self.get_NAME() for each of the attributes named in2026 COMPUTED_KEYS and recording those that do not return None.2027 The return value is always a new dictionary."""20282029if self._values is None:2030 values = {}20312032for key in self.COMPUTED_KEYS:2033 value =getattr(self,'get_%s'% (key,))()2034if value is not None:2035 values[key] = value20362037 self._values = values20382039return self._values.copy()20402041defget_refchange_recipients(self, refchange):2042"""Return the recipients for notifications about refchange.20432044 Return the list of email addresses to which notifications2045 about the specified ReferenceChange should be sent."""20462047raiseNotImplementedError()20482049defget_announce_recipients(self, annotated_tag_change):2050"""Return the recipients for notifications about annotated_tag_change.20512052 Return the list of email addresses to which notifications2053 about the specified AnnotatedTagChange should be sent."""20542055raiseNotImplementedError()20562057defget_reply_to_refchange(self, refchange):2058return self.get_pusher_email()20592060defget_revision_recipients(self, revision):2061"""Return the recipients for messages about revision.20622063 Return the list of email addresses to which notifications2064 about the specified Revision should be sent. This method2065 could be overridden, for example, to take into account the2066 contents of the revision when deciding whom to notify about2067 it. For example, there could be a scheme for users to express2068 interest in particular files or subdirectories, and only2069 receive notification emails for revisions that affecting those2070 files."""20712072raiseNotImplementedError()20732074defget_reply_to_commit(self, revision):2075return revision.author20762077deffilter_body(self, lines):2078"""Filter the lines intended for an email body.20792080 lines is an iterable over the lines that would go into the2081 email body. Filter it (e.g., limit the number of lines, the2082 line length, character set, etc.), returning another iterable.2083 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin2084 for classes implementing this functionality."""20852086return lines20872088deflog_msg(self, msg):2089"""Write the string msg on a log file or on stderr.20902091 Sends the text to stderr by default, override to change the behavior."""2092 sys.stderr.write(msg)20932094deflog_warning(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_error(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)210521062107classConfigEnvironmentMixin(Environment):2108"""A mixin that sets self.config to its constructor's config argument.21092110 This class's constructor consumes the "config" argument.21112112 Mixins that need to inspect the config should inherit from this2113 class (1) to make sure that "config" is still in the constructor2114 arguments with its own constructor runs and/or (2) to be sure that2115 self.config is set after construction."""21162117def__init__(self, config, **kw):2118super(ConfigEnvironmentMixin, self).__init__(**kw)2119 self.config = config212021212122classConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):2123"""An Environment that reads most of its information from "git config"."""21242125def__init__(self, config, **kw):2126super(ConfigOptionsEnvironmentMixin, self).__init__(2127 config=config, **kw2128)21292130for var, cfg in(2131('announce_show_shortlog','announceshortlog'),2132('refchange_showgraph','refchangeShowGraph'),2133('refchange_showlog','refchangeshowlog'),2134('quiet','quiet'),2135('stdout','stdout'),2136):2137 val = config.get_bool(cfg)2138if val is not None:2139setattr(self, var, val)21402141 maxcommitemails = config.get('maxcommitemails')2142if maxcommitemails is not None:2143try:2144 self.maxcommitemails =int(maxcommitemails)2145exceptValueError:2146 self.log_warning(2147'*** Malformed value for multimailhook.maxCommitEmails:%s\n'% maxcommitemails2148+'*** Expected a number. Ignoring.\n'2149)21502151 diffopts = config.get('diffopts')2152if diffopts is not None:2153 self.diffopts = shlex.split(diffopts)21542155 graphopts = config.get('graphOpts')2156if graphopts is not None:2157 self.graphopts = shlex.split(graphopts)21582159 logopts = config.get('logopts')2160if logopts is not None:2161 self.logopts = shlex.split(logopts)21622163 commitlogopts = config.get('commitlogopts')2164if commitlogopts is not None:2165 self.commitlogopts = shlex.split(commitlogopts)21662167 reply_to = config.get('replyTo')2168 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)2169if(2170 self.__reply_to_refchange is not None2171and self.__reply_to_refchange.lower() =='author'2172):2173raiseConfigurationException(2174'"author" is not an allowed setting for replyToRefchange'2175)2176 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)21772178 combine = config.get_bool('combineWhenSingleCommit')2179if combine is not None:2180 self.combine_when_single_commit = combine21812182defget_administrator(self):2183return(2184 self.config.get('administrator')2185or self.get_sender()2186orsuper(ConfigOptionsEnvironmentMixin, self).get_administrator()2187)21882189defget_repo_shortname(self):2190return(2191 self.config.get('reponame')2192orsuper(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()2193)21942195defget_emailprefix(self):2196 emailprefix = self.config.get('emailprefix')2197if emailprefix is not None:2198 emailprefix = emailprefix.strip()2199if emailprefix:2200return emailprefix +' '2201else:2202return''2203else:2204return'[%s] '% (self.get_repo_shortname(),)22052206defget_sender(self):2207return self.config.get('envelopesender')22082209defget_fromaddr(self):2210 fromaddr = self.config.get('from')2211if fromaddr:2212return fromaddr2213returnsuper(ConfigOptionsEnvironmentMixin, self).get_fromaddr()22142215defget_reply_to_refchange(self, refchange):2216if self.__reply_to_refchange is None:2217returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)2218elif self.__reply_to_refchange.lower() =='pusher':2219return self.get_pusher_email()2220elif self.__reply_to_refchange.lower() =='none':2221return None2222else:2223return self.__reply_to_refchange22242225defget_reply_to_commit(self, revision):2226if self.__reply_to_commit is None:2227returnsuper(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)2228elif self.__reply_to_commit.lower() =='author':2229return revision.author2230elif self.__reply_to_commit.lower() =='pusher':2231return self.get_pusher_email()2232elif self.__reply_to_commit.lower() =='none':2233return None2234else:2235return self.__reply_to_commit22362237defget_scancommitforcc(self):2238return self.config.get('scancommitforcc')223922402241classFilterLinesEnvironmentMixin(Environment):2242"""Handle encoding and maximum line length of body lines.22432244 emailmaxlinelength (int or None)22452246 The maximum length of any single line in the email body.2247 Longer lines are truncated at that length with ' [...]'2248 appended.22492250 strict_utf8 (bool)22512252 If this field is set to True, then the email body text is2253 expected to be UTF-8. Any invalid characters are2254 converted to U+FFFD, the Unicode replacement character2255 (encoded as UTF-8, of course).22562257 """22582259def__init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):2260super(FilterLinesEnvironmentMixin, self).__init__(**kw)2261 self.__strict_utf8= strict_utf82262 self.__emailmaxlinelength = emailmaxlinelength22632264deffilter_body(self, lines):2265 lines =super(FilterLinesEnvironmentMixin, self).filter_body(lines)2266if self.__strict_utf8:2267 lines = (line.decode(ENCODING,'replace')for line in lines)2268# Limit the line length in Unicode-space to avoid2269# splitting characters:2270if self.__emailmaxlinelength:2271 lines =limit_linelength(lines, self.__emailmaxlinelength)2272 lines = (line.encode(ENCODING,'replace')for line in lines)2273elif self.__emailmaxlinelength:2274 lines =limit_linelength(lines, self.__emailmaxlinelength)22752276return lines227722782279classConfigFilterLinesEnvironmentMixin(2280 ConfigEnvironmentMixin,2281 FilterLinesEnvironmentMixin,2282):2283"""Handle encoding and maximum line length based on config."""22842285def__init__(self, config, **kw):2286 strict_utf8 = config.get_bool('emailstrictutf8', default=None)2287if strict_utf8 is not None:2288 kw['strict_utf8'] = strict_utf822892290 emailmaxlinelength = config.get('emailmaxlinelength')2291if emailmaxlinelength is not None:2292 kw['emailmaxlinelength'] =int(emailmaxlinelength)22932294super(ConfigFilterLinesEnvironmentMixin, self).__init__(2295 config=config, **kw2296)229722982299classMaxlinesEnvironmentMixin(Environment):2300"""Limit the email body to a specified number of lines."""23012302def__init__(self, emailmaxlines, **kw):2303super(MaxlinesEnvironmentMixin, self).__init__(**kw)2304 self.__emailmaxlines = emailmaxlines23052306deffilter_body(self, lines):2307 lines =super(MaxlinesEnvironmentMixin, self).filter_body(lines)2308if self.__emailmaxlines:2309 lines =limit_lines(lines, self.__emailmaxlines)2310return lines231123122313classConfigMaxlinesEnvironmentMixin(2314 ConfigEnvironmentMixin,2315 MaxlinesEnvironmentMixin,2316):2317"""Limit the email body to the number of lines specified in config."""23182319def__init__(self, config, **kw):2320 emailmaxlines =int(config.get('emailmaxlines', default='0'))2321super(ConfigMaxlinesEnvironmentMixin, self).__init__(2322 config=config,2323 emailmaxlines=emailmaxlines,2324**kw2325)232623272328classFQDNEnvironmentMixin(Environment):2329"""A mixin that sets the host's FQDN to its constructor argument."""23302331def__init__(self, fqdn, **kw):2332super(FQDNEnvironmentMixin, self).__init__(**kw)2333 self.COMPUTED_KEYS += ['fqdn']2334 self.__fqdn = fqdn23352336defget_fqdn(self):2337"""Return the fully-qualified domain name for this host.23382339 Return None if it is unavailable or unwanted."""23402341return self.__fqdn234223432344classConfigFQDNEnvironmentMixin(2345 ConfigEnvironmentMixin,2346 FQDNEnvironmentMixin,2347):2348"""Read the FQDN from the config."""23492350def__init__(self, config, **kw):2351 fqdn = config.get('fqdn')2352super(ConfigFQDNEnvironmentMixin, self).__init__(2353 config=config,2354 fqdn=fqdn,2355**kw2356)235723582359classComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):2360"""Get the FQDN by calling socket.getfqdn()."""23612362def__init__(self, **kw):2363super(ComputeFQDNEnvironmentMixin, self).__init__(2364 fqdn=socket.getfqdn(),2365**kw2366)236723682369classPusherDomainEnvironmentMixin(ConfigEnvironmentMixin):2370"""Deduce pusher_email from pusher by appending an emaildomain."""23712372def__init__(self, **kw):2373super(PusherDomainEnvironmentMixin, self).__init__(**kw)2374 self.__emaildomain = self.config.get('emaildomain')23752376defget_pusher_email(self):2377if self.__emaildomain:2378# Derive the pusher's full email address in the default way:2379return'%s@%s'% (self.get_pusher(), self.__emaildomain)2380else:2381returnsuper(PusherDomainEnvironmentMixin, self).get_pusher_email()238223832384classStaticRecipientsEnvironmentMixin(Environment):2385"""Set recipients statically based on constructor parameters."""23862387def__init__(2388 self,2389 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,2390**kw2391):2392super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)23932394# The recipients for various types of notification emails, as2395# RFC 2822 email addresses separated by commas (or the empty2396# string if no recipients are configured). Although there is2397# a mechanism to choose the recipient lists based on on the2398# actual *contents* of the change being reported, we only2399# choose based on the *type* of the change. Therefore we can2400# compute them once and for all:2401if not(refchange_recipients2402or announce_recipients2403or revision_recipients2404or scancommitforcc):2405raiseConfigurationException('No email recipients configured!')2406 self.__refchange_recipients = refchange_recipients2407 self.__announce_recipients = announce_recipients2408 self.__revision_recipients = revision_recipients24092410defget_refchange_recipients(self, refchange):2411return self.__refchange_recipients24122413defget_announce_recipients(self, annotated_tag_change):2414return self.__announce_recipients24152416defget_revision_recipients(self, revision):2417return self.__revision_recipients241824192420classConfigRecipientsEnvironmentMixin(2421 ConfigEnvironmentMixin,2422 StaticRecipientsEnvironmentMixin2423):2424"""Determine recipients statically based on config."""24252426def__init__(self, config, **kw):2427super(ConfigRecipientsEnvironmentMixin, self).__init__(2428 config=config,2429 refchange_recipients=self._get_recipients(2430 config,'refchangelist','mailinglist',2431),2432 announce_recipients=self._get_recipients(2433 config,'announcelist','refchangelist','mailinglist',2434),2435 revision_recipients=self._get_recipients(2436 config,'commitlist','mailinglist',2437),2438 scancommitforcc=config.get('scancommitforcc'),2439**kw2440)24412442def_get_recipients(self, config, *names):2443"""Return the recipients for a particular type of message.24442445 Return the list of email addresses to which a particular type2446 of notification email should be sent, by looking at the config2447 value for "multimailhook.$name" for each of names. Use the2448 value from the first name that is configured. The return2449 value is a (possibly empty) string containing RFC 2822 email2450 addresses separated by commas. If no configuration could be2451 found, raise a ConfigurationException."""24522453for name in names:2454 retval = config.get_recipients(name)2455if retval is not None:2456return retval2457else:2458return''245924602461classProjectdescEnvironmentMixin(Environment):2462"""Make a "projectdesc" value available for templates.24632464 By default, it is set to the first line of $GIT_DIR/description2465 (if that file is present and appears to be set meaningfully)."""24662467def__init__(self, **kw):2468super(ProjectdescEnvironmentMixin, self).__init__(**kw)2469 self.COMPUTED_KEYS += ['projectdesc']24702471defget_projectdesc(self):2472"""Return a one-line descripition of the project."""24732474 git_dir =get_git_dir()2475try:2476 projectdesc =open(os.path.join(git_dir,'description')).readline().strip()2477if projectdesc and not projectdesc.startswith('Unnamed repository'):2478return projectdesc2479exceptIOError:2480pass24812482return'UNNAMED PROJECT'248324842485classGenericEnvironmentMixin(Environment):2486defget_pusher(self):2487return self.osenv.get('USER', self.osenv.get('USERNAME','unknown user'))248824892490classGenericEnvironment(2491 ProjectdescEnvironmentMixin,2492 ConfigMaxlinesEnvironmentMixin,2493 ComputeFQDNEnvironmentMixin,2494 ConfigFilterLinesEnvironmentMixin,2495 ConfigRecipientsEnvironmentMixin,2496 PusherDomainEnvironmentMixin,2497 ConfigOptionsEnvironmentMixin,2498 GenericEnvironmentMixin,2499 Environment,2500):2501pass250225032504classGitoliteEnvironmentMixin(Environment):2505defget_repo_shortname(self):2506# The gitolite environment variable $GL_REPO is a pretty good2507# repo_shortname (though it's probably not as good as a value2508# the user might have explicitly put in his config).2509return(2510 self.osenv.get('GL_REPO',None)2511orsuper(GitoliteEnvironmentMixin, self).get_repo_shortname()2512)25132514defget_pusher(self):2515return self.osenv.get('GL_USER','unknown user')25162517defget_fromaddr(self):2518 GL_USER = self.osenv.get('GL_USER')2519if GL_USER is not None:2520# Find the path to gitolite.conf. Note that gitolite v32521# did away with the GL_ADMINDIR and GL_CONF environment2522# variables (they are now hard-coded).2523 GL_ADMINDIR = self.osenv.get(2524'GL_ADMINDIR',2525 os.path.expanduser(os.path.join('~','.gitolite')))2526 GL_CONF = self.osenv.get(2527'GL_CONF',2528 os.path.join(GL_ADMINDIR,'conf','gitolite.conf'))2529if os.path.isfile(GL_CONF):2530 f =open(GL_CONF,'rU')2531try:2532 in_user_emails_section =False2533 re_template = r'^\s*#\s*{}\s*$'2534 re_begin, re_user, re_end = (2535 re.compile(re_template.format(x))2536for x in(2537 r'BEGIN\s+USER\s+EMAILS',2538 re.escape(GL_USER) + r'\s+(.*)',2539 r'END\s+USER\s+EMAILS',2540))2541for l in f:2542 l = l.rstrip('\n')2543if not in_user_emails_section:2544if re_begin.match(l):2545 in_user_emails_section =True2546continue2547if re_end.match(l):2548break2549 m = re_user.match(l)2550if m:2551return m.group(1)2552finally:2553 f.close()2554returnsuper(GitoliteEnvironmentMixin, self).get_fromaddr()255525562557classIncrementalDateTime(object):2558"""Simple wrapper to give incremental date/times.25592560 Each call will result in a date/time a second later than the2561 previous call. This can be used to falsify email headers, to2562 increase the likelihood that email clients sort the emails2563 correctly."""25642565def__init__(self):2566 self.time = time.time()25672568defnext(self):2569 formatted =formatdate(self.time,True)2570 self.time +=12571return formatted257225732574classGitoliteEnvironment(2575 ProjectdescEnvironmentMixin,2576 ConfigMaxlinesEnvironmentMixin,2577 ComputeFQDNEnvironmentMixin,2578 ConfigFilterLinesEnvironmentMixin,2579 ConfigRecipientsEnvironmentMixin,2580 PusherDomainEnvironmentMixin,2581 ConfigOptionsEnvironmentMixin,2582 GitoliteEnvironmentMixin,2583 Environment,2584):2585pass258625872588classPush(object):2589"""Represent an entire push (i.e., a group of ReferenceChanges).25902591 It is easy to figure out what commits were added to a *branch* by2592 a Reference change:25932594 git rev-list change.old..change.new25952596 or removed from a *branch*:25972598 git rev-list change.new..change.old25992600 But it is not quite so trivial to determine which entirely new2601 commits were added to the *repository* by a push and which old2602 commits were discarded by a push. A big part of the job of this2603 class is to figure out these things, and to make sure that new2604 commits are only detailed once even if they were added to multiple2605 references.26062607 The first step is to determine the "other" references--those2608 unaffected by the current push. They are computed by listing all2609 references then removing any affected by this push. The results2610 are stored in Push._other_ref_sha1s.26112612 The commits contained in the repository before this push were26132614 git rev-list other1 other2 other3 ... change1.old change2.old ...26152616 Where "changeN.old" is the old value of one of the references2617 affected by this push.26182619 The commits contained in the repository after this push are26202621 git rev-list other1 other2 other3 ... change1.new change2.new ...26222623 The commits added by this push are the difference between these2624 two sets, which can be written26252626 git rev-list \2627 ^other1 ^other2 ... \2628 ^change1.old ^change2.old ... \2629 change1.new change2.new ...26302631 The commits removed by this push can be computed by26322633 git rev-list \2634 ^other1 ^other2 ... \2635 ^change1.new ^change2.new ... \2636 change1.old change2.old ...26372638 The last point is that it is possible that other pushes are2639 occurring simultaneously to this one, so reference values can2640 change at any time. It is impossible to eliminate all race2641 conditions, but we reduce the window of time during which problems2642 can occur by translating reference names to SHA1s as soon as2643 possible and working with SHA1s thereafter (because SHA1s are2644 immutable)."""26452646# A map {(changeclass, changetype): integer} specifying the order2647# that reference changes will be processed if multiple reference2648# changes are included in a single push. The order is significant2649# mostly because new commit notifications are threaded together2650# with the first reference change that includes the commit. The2651# following order thus causes commits to be grouped with branch2652# changes (as opposed to tag changes) if possible.2653 SORT_ORDER =dict(2654(value, i)for(i, value)inenumerate([2655(BranchChange,'update'),2656(BranchChange,'create'),2657(AnnotatedTagChange,'update'),2658(AnnotatedTagChange,'create'),2659(NonAnnotatedTagChange,'update'),2660(NonAnnotatedTagChange,'create'),2661(BranchChange,'delete'),2662(AnnotatedTagChange,'delete'),2663(NonAnnotatedTagChange,'delete'),2664(OtherReferenceChange,'update'),2665(OtherReferenceChange,'create'),2666(OtherReferenceChange,'delete'),2667])2668)26692670def__init__(self, changes, ignore_other_refs=False):2671 self.changes =sorted(changes, key=self._sort_key)2672 self.__other_ref_sha1s =None2673 self.__cached_commits_spec = {}26742675if ignore_other_refs:2676 self.__other_ref_sha1s =set()26772678@classmethod2679def_sort_key(klass, change):2680return(klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)26812682@property2683def_other_ref_sha1s(self):2684"""The GitObjects referred to by references unaffected by this push.2685 """2686if self.__other_ref_sha1s is None:2687# The refnames being changed by this push:2688 updated_refs =set(2689 change.refname2690for change in self.changes2691)26922693# The SHA-1s of commits referred to by all references in this2694# repository *except* updated_refs:2695 sha1s =set()2696 fmt = (2697'%(objectname) %(objecttype) %(refname)\n'2698'%(*objectname) %(*objecttype)%(refname)'2699)2700for line inread_git_lines(2701['for-each-ref','--format=%s'% (fmt,)]):2702(sha1,type, name) = line.split(' ',2)2703if sha1 andtype=='commit'and name not in updated_refs:2704 sha1s.add(sha1)27052706 self.__other_ref_sha1s = sha1s27072708return self.__other_ref_sha1s27092710def_get_commits_spec_incl(self, new_or_old, reference_change=None):2711"""Get new or old SHA-1 from one or each of the changed refs.27122713 Return a list of SHA-1 commit identifier strings suitable as2714 arguments to 'git rev-list' (or 'git log' or ...). The2715 returned identifiers are either the old or new values from one2716 or all of the changed references, depending on the values of2717 new_or_old and reference_change.27182719 new_or_old is either the string 'new' or the string 'old'. If2720 'new', the returned SHA-1 identifiers are the new values from2721 each changed reference. If 'old', the SHA-1 identifiers are2722 the old values from each changed reference.27232724 If reference_change is specified and not None, only the new or2725 old reference from the specified reference is included in the2726 return value.27272728 This function returns None if there are no matching revisions2729 (e.g., because a branch was deleted and new_or_old is 'new').2730 """27312732if not reference_change:2733 incl_spec =sorted(2734getattr(change, new_or_old).sha12735for change in self.changes2736ifgetattr(change, new_or_old)2737)2738if not incl_spec:2739 incl_spec =None2740elif notgetattr(reference_change, new_or_old).commit_sha1:2741 incl_spec =None2742else:2743 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]2744return incl_spec27452746def_get_commits_spec_excl(self, new_or_old):2747"""Get exclusion revisions for determining new or discarded commits.27482749 Return a list of strings suitable as arguments to 'git2750 rev-list' (or 'git log' or ...) that will exclude all2751 commits that, depending on the value of new_or_old, were2752 either previously in the repository (useful for determining2753 which commits are new to the repository) or currently in the2754 repository (useful for determining which commits were2755 discarded from the repository).27562757 new_or_old is either the string 'new' or the string 'old'. If2758 'new', the commits to be excluded are those that were in the2759 repository before the push. If 'old', the commits to be2760 excluded are those that are currently in the repository. """27612762 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]2763 excl_revs = self._other_ref_sha1s.union(2764getattr(change, old_or_new).sha12765for change in self.changes2766ifgetattr(change, old_or_new).typein['commit','tag']2767)2768return['^'+ sha1 for sha1 insorted(excl_revs)]27692770defget_commits_spec(self, new_or_old, reference_change=None):2771"""Get rev-list arguments for added or discarded commits.27722773 Return a list of strings suitable as arguments to 'git2774 rev-list' (or 'git log' or ...) that select those commits2775 that, depending on the value of new_or_old, are either new to2776 the repository or were discarded from the repository.27772778 new_or_old is either the string 'new' or the string 'old'. If2779 'new', the returned list is used to select commits that are2780 new to the repository. If 'old', the returned value is used2781 to select the commits that have been discarded from the2782 repository.27832784 If reference_change is specified and not None, the new or2785 discarded commits are limited to those that are reachable from2786 the new or old value of the specified reference.27872788 This function returns None if there are no added (or discarded)2789 revisions.2790 """2791 key = (new_or_old, reference_change)2792if key not in self.__cached_commits_spec:2793 ret = self._get_commits_spec_incl(new_or_old, reference_change)2794if ret is not None:2795 ret.extend(self._get_commits_spec_excl(new_or_old))2796 self.__cached_commits_spec[key] = ret2797return self.__cached_commits_spec[key]27982799defget_new_commits(self, reference_change=None):2800"""Return a list of commits added by this push.28012802 Return a list of the object names of commits that were added2803 by the part of this push represented by reference_change. If2804 reference_change is None, then return a list of *all* commits2805 added by this push."""28062807 spec = self.get_commits_spec('new', reference_change)2808returngit_rev_list(spec)28092810defget_discarded_commits(self, reference_change):2811"""Return a list of commits discarded by this push.28122813 Return a list of the object names of commits that were2814 entirely discarded from the repository by the part of this2815 push represented by reference_change."""28162817 spec = self.get_commits_spec('old', reference_change)2818returngit_rev_list(spec)28192820defsend_emails(self, mailer, body_filter=None):2821"""Use send all of the notification emails needed for this push.28222823 Use send all of the notification emails (including reference2824 change emails and commit emails) needed for this push. Send2825 the emails using mailer. If body_filter is not None, then use2826 it to filter the lines that are intended for the email2827 body."""28282829# The sha1s of commits that were introduced by this push.2830# They will be removed from this set as they are processed, to2831# guarantee that one (and only one) email is generated for2832# each new commit.2833 unhandled_sha1s =set(self.get_new_commits())2834 send_date =IncrementalDateTime()2835for change in self.changes:2836 sha1s = []2837for sha1 inreversed(list(self.get_new_commits(change))):2838if sha1 in unhandled_sha1s:2839 sha1s.append(sha1)2840 unhandled_sha1s.remove(sha1)28412842# Check if we've got anyone to send to2843if not change.recipients:2844 change.environment.log_warning(2845'*** no recipients configured so no email will be sent\n'2846'*** for%rupdate%s->%s\n'2847% (change.refname, change.old.sha1, change.new.sha1,)2848)2849else:2850if not change.environment.quiet:2851 change.environment.log_msg(2852'Sending notification emails to:%s\n'% (change.recipients,))2853 extra_values = {'send_date': send_date.next()}28542855 rev = change.send_single_combined_email(sha1s)2856if rev:2857 mailer.send(2858 change.generate_combined_email(self, rev, body_filter, extra_values),2859 rev.recipients,2860)2861# This change is now fully handled; no need to handle2862# individual revisions any further.2863continue2864else:2865 mailer.send(2866 change.generate_email(self, body_filter, extra_values),2867 change.recipients,2868)28692870 max_emails = change.environment.maxcommitemails2871if max_emails andlen(sha1s) > max_emails:2872 change.environment.log_warning(2873'*** Too many new commits (%d), not sending commit emails.\n'%len(sha1s)2874+'*** Try setting multimailhook.maxCommitEmails to a greater value\n'2875+'*** Currently, multimailhook.maxCommitEmails=%d\n'% max_emails2876)2877return28782879for(num, sha1)inenumerate(sha1s):2880 rev =Revision(change,GitObject(sha1), num=num +1, tot=len(sha1s))2881if not rev.recipients and rev.cc_recipients:2882 change.environment.log_msg('*** Replacing Cc: with To:\n')2883 rev.recipients = rev.cc_recipients2884 rev.cc_recipients =None2885if rev.recipients:2886 extra_values = {'send_date': send_date.next()}2887 mailer.send(2888 rev.generate_email(self, body_filter, extra_values),2889 rev.recipients,2890)28912892# Consistency check:2893if unhandled_sha1s:2894 change.environment.log_error(2895'ERROR: No emails were sent for the following new commits:\n'2896'%s\n'2897% ('\n'.join(sorted(unhandled_sha1s)),)2898)289929002901defrun_as_post_receive_hook(environment, mailer):2902 changes = []2903for line in sys.stdin:2904(oldrev, newrev, refname) = line.strip().split(' ',2)2905 changes.append(2906 ReferenceChange.create(environment, oldrev, newrev, refname)2907)2908 push =Push(changes)2909 push.send_emails(mailer, body_filter=environment.filter_body)291029112912defrun_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):2913 changes = [2914 ReferenceChange.create(2915 environment,2916read_git_output(['rev-parse','--verify', oldrev]),2917read_git_output(['rev-parse','--verify', newrev]),2918 refname,2919),2920]2921 push =Push(changes, force_send)2922 push.send_emails(mailer, body_filter=environment.filter_body)292329242925defchoose_mailer(config, environment):2926 mailer = config.get('mailer', default='sendmail')29272928if mailer =='smtp':2929 smtpserver = config.get('smtpserver', default='localhost')2930 smtpservertimeout =float(config.get('smtpservertimeout', default=10.0))2931 smtpserverdebuglevel =int(config.get('smtpserverdebuglevel', default=0))2932 smtpencryption = config.get('smtpencryption', default='none')2933 smtpuser = config.get('smtpuser', default='')2934 smtppass = config.get('smtppass', default='')2935 mailer =SMTPMailer(2936 envelopesender=(environment.get_sender()or environment.get_fromaddr()),2937 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,2938 smtpserverdebuglevel=smtpserverdebuglevel,2939 smtpencryption=smtpencryption,2940 smtpuser=smtpuser,2941 smtppass=smtppass,2942)2943elif mailer =='sendmail':2944 command = config.get('sendmailcommand')2945if command:2946 command = shlex.split(command)2947 mailer =SendMailer(command=command, envelopesender=environment.get_sender())2948else:2949 environment.log_error(2950'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'% mailer2951+'please use one of "smtp" or "sendmail".\n'2952)2953 sys.exit(1)2954return mailer295529562957KNOWN_ENVIRONMENTS = {2958'generic': GenericEnvironmentMixin,2959'gitolite': GitoliteEnvironmentMixin,2960}296129622963defchoose_environment(config, osenv=None, env=None, recipients=None):2964if not osenv:2965 osenv = os.environ29662967 environment_mixins = [2968 ProjectdescEnvironmentMixin,2969 ConfigMaxlinesEnvironmentMixin,2970 ComputeFQDNEnvironmentMixin,2971 ConfigFilterLinesEnvironmentMixin,2972 PusherDomainEnvironmentMixin,2973 ConfigOptionsEnvironmentMixin,2974]2975 environment_kw = {2976'osenv': osenv,2977'config': config,2978}29792980if not env:2981 env = config.get('environment')29822983if not env:2984if'GL_USER'in osenv and'GL_REPO'in osenv:2985 env ='gitolite'2986else:2987 env ='generic'29882989 environment_mixins.append(KNOWN_ENVIRONMENTS[env])29902991if recipients:2992 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)2993 environment_kw['refchange_recipients'] = recipients2994 environment_kw['announce_recipients'] = recipients2995 environment_kw['revision_recipients'] = recipients2996 environment_kw['scancommitforcc'] = config.get('scancommitforcc')2997else:2998 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)29993000 environment_klass =type(3001'EffectiveEnvironment',3002tuple(environment_mixins) + (Environment,),3003{},3004)3005returnenvironment_klass(**environment_kw)300630073008defmain(args):3009 parser = optparse.OptionParser(3010 description=__doc__,3011 usage='%prog [OPTIONS]\nor: %prog [OPTIONS] REFNAME OLDREV NEWREV',3012)30133014 parser.add_option(3015'--environment','--env', action='store',type='choice',3016 choices=['generic','gitolite'], default=None,3017help=(3018'Choose type of environment is in use. Default is taken from '3019'multimailhook.environment if set; otherwise "generic".'3020),3021)3022 parser.add_option(3023'--stdout', action='store_true', default=False,3024help='Output emails to stdout rather than sending them.',3025)3026 parser.add_option(3027'--recipients', action='store', default=None,3028help='Set list of email recipients for all types of emails.',3029)3030 parser.add_option(3031'--show-env', action='store_true', default=False,3032help=(3033'Write to stderr the values determined for the environment '3034'(intended for debugging purposes).'3035),3036)3037 parser.add_option(3038'--force-send', action='store_true', default=False,3039help=(3040'Force sending refchange email when using as an update hook. '3041'This is useful to work around the unreliable new commits '3042'detection in this mode.'3043),3044)30453046(options, args) = parser.parse_args(args)30473048 config =Config('multimailhook')30493050try:3051 environment =choose_environment(3052 config, osenv=os.environ,3053 env=options.environment,3054 recipients=options.recipients,3055)30563057if options.show_env:3058 sys.stderr.write('Environment values:\n')3059for(k, v)insorted(environment.get_values().items()):3060 sys.stderr.write('%s:%r\n'% (k, v))3061 sys.stderr.write('\n')30623063if options.stdout or environment.stdout:3064 mailer =OutputMailer(sys.stdout)3065else:3066 mailer =choose_mailer(config, environment)30673068# Dual mode: if arguments were specified on the command line, run3069# like an update hook; otherwise, run as a post-receive hook.3070if args:3071iflen(args) !=3:3072 parser.error('Need zero or three non-option arguments')3073(refname, oldrev, newrev) = args3074run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)3075else:3076run_as_post_receive_hook(environment, mailer)3077except ConfigurationException, e:3078 sys.exit(str(e))307930803081if __name__ =='__main__':3082main(sys.argv[1:])