patchbomb.py
1002 lines
| 31.0 KiB
| text/x-python
|
PythonLexer
/ hgext / patchbomb.py
|
r8252 | # patchbomb.py - sending Mercurial changesets as patch emails | ||
# | ||||
|
r47575 | # Copyright 2005-2009 Olivia Mackall <olivia@selenic.com> and others | ||
|
r8252 | # | ||
# This software may be used and distributed according to the terms of the | ||||
|
r10263 | # GNU General Public License version 2 or any later version. | ||
|
r8252 | |||
|
r8935 | '''command to send changesets as (a series of) patch emails | ||
|
r6666 | |||
|
r7997 | The series is started off with a "[PATCH 0 of N]" introduction, which | ||
describes the series as a whole. | ||||
|
r6666 | |||
|
r9269 | Each patch email has a Subject line of "[PATCH M of N] ...", using the | ||
first line of the changeset description as the subject text. The | ||||
message contains two or three body parts: | ||||
|
r6666 | |||
|
r9300 | - The changeset description. | ||
- [Optional] The result of running diffstat on the patch. | ||||
|
r10973 | - The patch itself, as generated by :hg:`export`. | ||
|
r6666 | |||
|
r9269 | Each message refers to the first in the series using the In-Reply-To | ||
and References headers, so they will show up as a sequence in threaded | ||||
mail and news readers, and in mail archives. | ||||
|
r6666 | |||
|
r13838 | To configure other defaults, add a section like this to your | ||
configuration file:: | ||||
|
r6666 | |||
[email] | ||||
from = My Name <my@email> | ||||
to = recipient1, recipient2, ... | ||||
cc = cc1, cc2, ... | ||||
bcc = bcc1, bcc2, ... | ||||
|
r11150 | reply-to = address1, address2, ... | ||
|
r6666 | |||
|
r10284 | Use ``[patchbomb]`` as configuration section name if you need to | ||
override global ``[email]`` address settings. | ||||
|
r10973 | Then you can use the :hg:`email` command to mail a series of | ||
changesets as a patchbomb. | ||||
|
r6666 | |||
|
r9269 | You can also either configure the method option in the email section | ||
to be a sendmail compatible mailer or fill out the [smtp] section so | ||||
that the patchbomb extension can automatically send patchbombs | ||||
directly from the commandline. See the [email] and [smtp] sections in | ||||
hgrc(5) for details. | ||||
|
r23487 | |||
|
r27697 | By default, :hg:`email` will prompt for a ``To`` or ``CC`` header if | ||
you do not supply one via configuration or the command line. You can | ||||
override this to never prompt by configuring an empty value:: | ||||
[email] | ||||
cc = | ||||
|
r23487 | You can control the default inclusion of an introduction message with the | ||
``patchbomb.intro`` configuration option. The configuration is always | ||||
overwritten by command line flags like --intro and --desc:: | ||||
[patchbomb] | ||||
intro=auto # include introduction message if more than 1 patch (default) | ||||
intro=never # never include an introduction message | ||||
intro=always # always include an introduction message | ||||
|
r23488 | |||
|
r31187 | You can specify a template for flags to be added in subject prefixes. Flags | ||
specified by --flag option are exported as ``{flags}`` keyword:: | ||||
[patchbomb] | ||||
flagtemplate = "{separate(' ', | ||||
ifeq(branch, 'default', '', branch|upper), | ||||
flags)}" | ||||
|
r23488 | You can set patchbomb to always ask for confirmation by setting | ||
``patchbomb.confirm`` to true. | ||||
|
r9071 | ''' | ||
|
r1669 | |||
|
r39067 | import email.encoders as emailencoders | ||
|
r38491 | import email.mime.base as emimebase | ||
|
r38490 | import email.mime.multipart as emimemultipart | ||
|
r36466 | import email.utils as eutil | ||
|
r28415 | import os | ||
import socket | ||||
|
r19810 | |||
|
r29205 | from mercurial.i18n import _ | ||
|
r43355 | from mercurial.pycompat import open | ||
|
r46729 | from mercurial.node import bin | ||
|
r28415 | from mercurial import ( | ||
cmdutil, | ||||
commands, | ||||
|
r36467 | encoding, | ||
|
r28415 | error, | ||
|
r31187 | formatter, | ||
|
r28415 | hg, | ||
|
r48928 | logcmdutil, | ||
|
r28415 | mail, | ||
patch, | ||||
|
r35035 | pycompat, | ||
|
r32337 | registrar, | ||
|
r28415 | scmutil, | ||
|
r31187 | templater, | ||
|
r28415 | util, | ||
) | ||||
r47669 | from mercurial.utils import ( | |||
dateutil, | ||||
urlutil, | ||||
) | ||||
|
r43346 | |||
|
r28861 | stringio = util.stringio | ||
|
r1669 | |||
|
r14309 | cmdtable = {} | ||
|
r32337 | command = registrar.command(cmdtable) | ||
|
r34113 | |||
configtable = {} | ||||
configitem = registrar.configitem(configtable) | ||||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'bundletype', | ||||
default=None, | ||||
|
r34113 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'bcc', | ||||
default=None, | ||||
|
r34761 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'cc', | ||||
default=None, | ||||
|
r34762 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'confirm', | ||||
default=False, | ||||
|
r34114 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'flagtemplate', | ||||
default=None, | ||||
|
r34115 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'from', | ||||
default=None, | ||||
|
r34116 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'intro', | ||||
default=b'auto', | ||||
|
r34117 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'publicurl', | ||||
default=None, | ||||
|
r34118 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'reply-to', | ||||
default=None, | ||||
|
r34763 | ) | ||
|
r43346 | configitem( | ||
|
r46554 | b'patchbomb', | ||
b'to', | ||||
default=None, | ||||
|
r34912 | ) | ||
|
r34113 | |||
|
r29841 | # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for | ||
|
r25186 | # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should | ||
# be specifying the version(s) of Mercurial they are tested with, or | ||||
# leave the attribute unspecified. | ||||
|
r43347 | testedwith = b'ships-with-hg-core' | ||
|
r14309 | |||
|
r43346 | |||
|
r26546 | def _addpullheader(seq, ctx): | ||
|
r46554 | """Add a header pointing to a public URL where the changeset is available""" | ||
|
r26546 | repo = ctx.repo() | ||
# experimental config: patchbomb.publicurl | ||||
# waiting for some logic that check that the changeset are available on the | ||||
# destination before patchbombing anything. | ||||
|
r43347 | publicurl = repo.ui.config(b'patchbomb', b'publicurl') | ||
|
r32825 | if publicurl: | ||
|
r43387 | return b'Available At %s\n# hg pull %s -r %s' % ( | ||
|
r43346 | publicurl, | ||
publicurl, | ||||
ctx, | ||||
) | ||||
|
r26546 | return None | ||
|
r43346 | |||
|
r26546 | def uisetup(ui): | ||
|
r43347 | cmdutil.extraexport.append(b'pullurl') | ||
cmdutil.extraexportmap[b'pullurl'] = _addpullheader | ||||
|
r26546 | |||
|
r43346 | |||
|
r33436 | def reposetup(ui, repo): | ||
if not repo.local(): | ||||
return | ||||
|
r43347 | repo._wlockfreeprefix.add(b'last-email.txt') | ||
|
r26546 | |||
|
r43346 | |||
|
r43347 | def prompt(ui, prompt, default=None, rest=b':'): | ||
|
r7354 | if default: | ||
|
r43347 | prompt += b' [%s]' % default | ||
|
r15166 | return ui.prompt(prompt + rest, default) | ||
|
r7354 | |||
|
r43346 | |||
|
r23487 | def introwanted(ui, opts, number): | ||
|
r15164 | '''is an introductory message apparently wanted?''' | ||
|
r43347 | introconfig = ui.config(b'patchbomb', b'intro') | ||
if opts.get(b'intro') or opts.get(b'desc'): | ||||
|
r23487 | intro = True | ||
|
r43347 | elif introconfig == b'always': | ||
|
r23487 | intro = True | ||
|
r43347 | elif introconfig == b'never': | ||
|
r23487 | intro = False | ||
|
r43347 | elif introconfig == b'auto': | ||
|
r40065 | intro = number > 1 | ||
|
r23487 | else: | ||
|
r43346 | ui.write_err( | ||
|
r43347 | _(b'warning: invalid patchbomb.intro value "%s"\n') % introconfig | ||
|
r43346 | ) | ||
|
r43347 | ui.write_err(_(b'(should be one of always, never, auto)\n')) | ||
|
r40065 | intro = number > 1 | ||
|
r23487 | return intro | ||
|
r10734 | |||
|
r43346 | |||
|
r31187 | def _formatflags(ui, repo, rev, flags): | ||
"""build flag string optionally by template""" | ||||
|
r43347 | tmpl = ui.config(b'patchbomb', b'flagtemplate') | ||
|
r31187 | if not tmpl: | ||
|
r43347 | return b' '.join(flags) | ||
|
r31187 | out = util.stringio() | ||
|
r45824 | spec = formatter.literal_templatespec(templater.unquotestring(tmpl)) | ||
|
r43369 | with formatter.templateformatter(ui, out, b'patchbombflag', {}, spec) as fm: | ||
|
r31187 | fm.startitem() | ||
fm.context(ctx=repo[rev]) | ||||
|
r43347 | fm.write(b'flags', b'%s', fm.formatlist(flags, name=b'flag')) | ||
|
r31187 | return out.getvalue() | ||
|
r43346 | |||
|
r31186 | def _formatprefix(ui, repo, rev, flags, idx, total, numbered): | ||
|
r31183 | """build prefix to patch subject""" | ||
|
r31187 | flag = _formatflags(ui, repo, rev, flags) | ||
|
r31183 | if flag: | ||
|
r43347 | flag = b' ' + flag | ||
|
r31183 | |||
if not numbered: | ||||
|
r43347 | return b'[PATCH%s]' % flag | ||
|
r31183 | else: | ||
|
r43347 | tlen = len(b"%d" % total) | ||
return b'[PATCH %0*d of %d%s]' % (tlen, idx, total, flag) | ||||
|
r31183 | |||
|
r43346 | |||
def makepatch( | ||||
ui, | ||||
repo, | ||||
rev, | ||||
patchlines, | ||||
opts, | ||||
_charsets, | ||||
idx, | ||||
total, | ||||
numbered, | ||||
patchname=None, | ||||
): | ||||
|
r7354 | |||
desc = [] | ||||
node = None | ||||
|
r43347 | body = b'' | ||
|
r7354 | |||
|
r12199 | for line in patchlines: | ||
|
r43347 | if line.startswith(b'#'): | ||
if line.startswith(b'# Node ID'): | ||||
|
r7354 | node = line.split()[-1] | ||
continue | ||||
|
r43347 | if line.startswith(b'diff -r') or line.startswith(b'diff --git'): | ||
|
r7354 | break | ||
desc.append(line) | ||||
if not patchname and not node: | ||||
raise ValueError | ||||
|
r43347 | if opts.get(b'attach') and not opts.get(b'body'): | ||
|
r43346 | body = ( | ||
|
r43347 | b'\n'.join(desc[1:]).strip() | ||
or b'Patch subject is complete summary.' | ||||
|
r43346 | ) | ||
|
r43347 | body += b'\n\n\n' | ||
|
r7354 | |||
|
r43347 | if opts.get(b'plain'): | ||
while patchlines and patchlines[0].startswith(b'# '): | ||||
|
r12199 | patchlines.pop(0) | ||
if patchlines: | ||||
patchlines.pop(0) | ||||
while patchlines and not patchlines[0].strip(): | ||||
patchlines.pop(0) | ||||
|
r7354 | |||
|
r30407 | ds = patch.diffstat(patchlines) | ||
|
r43347 | if opts.get(b'diffstat'): | ||
body += ds + b'\n\n' | ||||
|
r7354 | |||
|
r43347 | addattachment = opts.get(b'attach') or opts.get(b'inline') | ||
if not addattachment or opts.get(b'body'): | ||||
body += b'\n'.join(patchlines) | ||||
|
r16307 | |||
if addattachment: | ||||
|
r38490 | msg = emimemultipart.MIMEMultipart() | ||
|
r7354 | if body: | ||
|
r43347 | msg.attach(mail.mimeencode(ui, body, _charsets, opts.get(b'test'))) | ||
|
r43346 | p = mail.mimetextpatch( | ||
|
r44026 | b'\n'.join(patchlines), 'x-patch', opts.get(b'test') | ||
|
r43346 | ) | ||
|
r46729 | binnode = bin(node) | ||
|
r8761 | # if node is mq patch, it will have the patch file's name as a tag | ||
|
r7354 | if not patchname: | ||
|
r43346 | patchtags = [ | ||
t | ||||
for t in repo.nodetags(binnode) | ||||
|
r43347 | if t.endswith(b'.patch') or t.endswith(b'.diff') | ||
|
r43346 | ] | ||
|
r7354 | if patchtags: | ||
patchname = patchtags[0] | ||||
elif total > 1: | ||||
|
r43346 | patchname = cmdutil.makefilename( | ||
|
r43347 | repo[node], b'%b-%n.patch', seqno=idx, total=total | ||
|
r43346 | ) | ||
|
r7354 | else: | ||
|
r43347 | patchname = cmdutil.makefilename(repo[node], b'%b.patch') | ||
|
r39073 | disposition = r'inline' | ||
|
r43347 | if opts.get(b'attach'): | ||
|
r39073 | disposition = r'attachment' | ||
|
r43906 | p['Content-Disposition'] = ( | ||
disposition + '; filename=' + encoding.strfromlocal(patchname) | ||||
|
r43346 | ) | ||
|
r7354 | msg.attach(p) | ||
else: | ||||
|
r43347 | msg = mail.mimetextpatch(body, display=opts.get(b'test')) | ||
|
r7354 | |||
|
r43346 | prefix = _formatprefix( | ||
|
r43347 | ui, repo, rev, opts.get(b'flag'), idx, total, numbered | ||
|
r43346 | ) | ||
|
r43347 | subj = desc[0].strip().rstrip(b'. ') | ||
|
r15164 | if not numbered: | ||
|
r43347 | subj = b' '.join([prefix, opts.get(b'subject') or subj]) | ||
|
r7354 | else: | ||
|
r43347 | subj = b' '.join([prefix, subj]) | ||
|
r43978 | msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get(b'test')) | ||
msg['X-Mercurial-Node'] = pycompat.sysstr(node) | ||||
msg['X-Mercurial-Series-Index'] = '%i' % idx | ||||
msg['X-Mercurial-Series-Total'] = '%i' % total | ||||
|
r12200 | return msg, subj, ds | ||
|
r7354 | |||
|
r43346 | |||
|
r23210 | def _getpatches(repo, revs, **opts): | ||
"""return a list of patches for a list of revisions | ||||
Each patch in the list is itself a list of lines. | ||||
""" | ||||
ui = repo.ui | ||||
|
r43347 | prev = repo[b'.'].rev() | ||
|
r24568 | for r in revs: | ||
|
r23210 | if r == prev and (repo[None].files() or repo[None].deleted()): | ||
|
r43387 | ui.warn(_(b'warning: working directory has uncommitted changes\n')) | ||
|
r28861 | output = stringio() | ||
|
r43346 | cmdutil.exportfile( | ||
repo, [r], output, opts=patch.difffeatureopts(ui, opts, git=True) | ||||
) | ||||
|
r43347 | yield output.getvalue().split(b'\n') | ||
|
r43346 | |||
|
r23211 | def _getbundle(repo, dest, **opts): | ||
"""return a bundle containing changesets missing in "dest" | ||||
The `opts` keyword-arguments are the same as the one accepted by the | ||||
`bundle` command. | ||||
The bundle is a returned as a single in-memory binary blob. | ||||
""" | ||||
ui = repo.ui | ||||
|
r43347 | tmpdir = pycompat.mkdtemp(prefix=b'hg-email-bundle-') | ||
tmpfn = os.path.join(tmpdir, b'bundle') | ||||
btype = ui.config(b'patchbomb', b'bundletype') | ||||
|
r26563 | if btype: | ||
|
r43906 | opts['type'] = btype | ||
|
r23211 | try: | ||
r47710 | dests = [] | |||
if dest: | ||||
dests = [dest] | ||||
commands.bundle(ui, repo, tmpfn, *dests, **opts) | ||||
|
r27767 | return util.readfile(tmpfn) | ||
|
r23211 | finally: | ||
try: | ||||
os.unlink(tmpfn) | ||||
except OSError: | ||||
pass | ||||
os.rmdir(tmpdir) | ||||
|
r43346 | |||
|
r23212 | def _getdescription(repo, defaultbody, sender, **opts): | ||
"""obtain the body of the introduction message and return it | ||||
This is also used for the body of email with an attached bundle. | ||||
The body can be obtained either from the command line option or entered by | ||||
the user through the editor. | ||||
""" | ||||
ui = repo.ui | ||||
|
r43906 | if opts.get('desc'): | ||
body = open(opts.get('desc')).read() | ||||
|
r23212 | else: | ||
|
r43346 | ui.write( | ||
|
r43387 | _(b'\nWrite the introductory message for the patch series.\n\n') | ||
|
r43346 | ) | ||
body = ui.edit( | ||||
|
r43347 | defaultbody, sender, repopath=repo.path, action=b'patchbombbody' | ||
|
r43346 | ) | ||
|
r23212 | # Save series description in case sendmail fails | ||
|
r43347 | msgfile = repo.vfs(b'last-email.txt', b'wb') | ||
|
r23212 | msgfile.write(body) | ||
msgfile.close() | ||||
return body | ||||
|
r23211 | |||
|
r43346 | |||
|
r23213 | def _getbundlemsgs(repo, sender, bundle, **opts): | ||
"""Get the full email for sending a given bundle | ||||
This function returns a list of "email" tuples (subject, content, None). | ||||
The list is always one message long in that case. | ||||
""" | ||||
ui = repo.ui | ||||
_charsets = mail._charsets(ui) | ||||
|
r43906 | subj = opts.get('subject') or prompt( | ||
|
r43347 | ui, b'Subject:', b'A bundle for your repository' | ||
|
r43346 | ) | ||
|
r23213 | |||
|
r43347 | body = _getdescription(repo, b'', sender, **opts) | ||
|
r38490 | msg = emimemultipart.MIMEMultipart() | ||
|
r23213 | if body: | ||
|
r43906 | msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test'))) | ||
datapart = emimebase.MIMEBase('application', 'x-mercurial-bundle') | ||||
|
r23213 | datapart.set_payload(bundle) | ||
|
r43906 | bundlename = b'%s.hg' % opts.get('bundlename', b'bundle') | ||
|
r43346 | datapart.add_header( | ||
|
r43906 | 'Content-Disposition', | ||
'attachment', | ||||
|
r43346 | filename=encoding.strfromlocal(bundlename), | ||
) | ||||
|
r39067 | emailencoders.encode_base64(datapart) | ||
|
r23213 | msg.attach(datapart) | ||
|
r43978 | msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test')) | ||
|
r23213 | return [(msg, subj, None)] | ||
|
r43346 | |||
|
r31186 | def _makeintro(repo, sender, revs, patches, **opts): | ||
|
r23214 | """make an introduction email, asking the user for content if needed | ||
email is returned as (subject, body, cumulative-diffstat)""" | ||||
ui = repo.ui | ||||
_charsets = mail._charsets(ui) | ||||
|
r31186 | # use the last revision which is likely to be a bookmarked head | ||
|
r43346 | prefix = _formatprefix( | ||
|
r43906 | ui, repo, revs.last(), opts.get('flag'), 0, len(patches), numbered=True | ||
|
r43346 | ) | ||
|
r43906 | subj = opts.get('subject') or prompt( | ||
|
r43347 | ui, b'(optional) Subject: ', rest=prefix, default=b'' | ||
|
r43346 | ) | ||
|
r23214 | if not subj: | ||
|
r43346 | return None # skip intro if the user doesn't bother | ||
|
r23214 | |||
|
r43347 | subj = prefix + b' ' + subj | ||
|
r23214 | |||
|
r43347 | body = b'' | ||
|
r43906 | if opts.get('diffstat'): | ||
|
r23214 | # generate a cumulative diffstat of the whole patch series | ||
diffstat = patch.diffstat(sum(patches, [])) | ||||
|
r43347 | body = b'\n' + diffstat | ||
|
r23214 | else: | ||
diffstat = None | ||||
body = _getdescription(repo, body, sender, **opts) | ||||
|
r43906 | msg = mail.mimeencode(ui, body, _charsets, opts.get('test')) | ||
|
r43978 | msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test')) | ||
|
r23214 | return (msg, subj, diffstat) | ||
|
r43346 | |||
|
r31185 | def _getpatchmsgs(repo, sender, revs, patchnames=None, **opts): | ||
|
r23215 | """return a list of emails from a list of patches | ||
This involves introduction message creation if necessary. | ||||
This function returns a list of "email" tuples (subject, content, None). | ||||
""" | ||||
|
r35035 | bytesopts = pycompat.byteskwargs(opts) | ||
|
r23215 | ui = repo.ui | ||
_charsets = mail._charsets(ui) | ||||
|
r31185 | patches = list(_getpatches(repo, revs, **opts)) | ||
|
r23215 | msgs = [] | ||
|
r43347 | ui.write(_(b'this patch series consists of %d patches.\n\n') % len(patches)) | ||
|
r23215 | |||
# build the intro message, or skip it if the user declines | ||||
|
r35035 | if introwanted(ui, bytesopts, len(patches)): | ||
|
r31186 | msg = _makeintro(repo, sender, revs, patches, **opts) | ||
|
r23215 | if msg: | ||
msgs.append(msg) | ||||
# are we going to send more than one message? | ||||
numbered = len(msgs) + len(patches) > 1 | ||||
# now generate the actual patch messages | ||||
name = None | ||||
|
r31186 | assert len(revs) == len(patches) | ||
for i, (r, p) in enumerate(zip(revs, patches)): | ||||
|
r23215 | if patchnames: | ||
name = patchnames[i] | ||||
|
r43346 | msg = makepatch( | ||
ui, | ||||
repo, | ||||
r, | ||||
p, | ||||
bytesopts, | ||||
_charsets, | ||||
i + 1, | ||||
len(patches), | ||||
numbered, | ||||
name, | ||||
) | ||||
|
r23215 | msgs.append(msg) | ||
return msgs | ||||
|
r43346 | |||
|
r23486 | def _getoutgoing(repo, dest, revs): | ||
'''Return the revisions present locally but not in dest''' | ||||
ui = repo.ui | ||||
|
r48842 | paths = urlutil.get_push_paths(repo, ui, [dest] if dest else None) | ||
r47700 | safe_paths = [urlutil.hidepassword(p.rawloc) for p in paths] | |||
ui.status(_(b'comparing with %s\n') % b','.join(safe_paths)) | ||||
|
r23486 | |||
|
r24568 | revs = [r for r in revs if r >= 0] | ||
|
r23486 | if not revs: | ||
|
r35692 | revs = [repo.changelog.tiprev()] | ||
|
r43347 | revs = repo.revs(b'outgoing(%s) and ::%ld', dest or b'', revs) | ||
|
r23486 | if not revs: | ||
|
r43347 | ui.status(_(b"no changes found\n")) | ||
|
r24567 | return revs | ||
|
r23486 | |||
|
r43346 | |||
|
r39153 | def _msgid(node, timestamp): | ||
|
r43978 | try: | ||
hostname = encoding.strfromlocal(encoding.environ[b'HGHOSTNAME']) | ||||
except KeyError: | ||||
hostname = socket.getfqdn() | ||||
return '<%s.%d@%s>' % (node, timestamp, hostname) | ||||
|
r39153 | |||
|
r43346 | |||
|
r14309 | emailopts = [ | ||
|
r43347 | (b'', b'body', None, _(b'send patches as inline message text (default)')), | ||
(b'a', b'attach', None, _(b'send patches as attachments')), | ||||
(b'i', b'inline', None, _(b'send patches as inline attachments')), | ||||
|
r43346 | ( | ||
|
r43347 | b'', | ||
b'bcc', | ||||
[], | ||||
_(b'email addresses of blind carbon copy recipients'), | ||||
_(b'EMAIL'), | ||||
|
r43346 | ), | ||
|
r43347 | (b'c', b'cc', [], _(b'email addresses of copy recipients'), _(b'EMAIL')), | ||
(b'', b'confirm', None, _(b'ask for confirmation before sending')), | ||||
(b'd', b'diffstat', None, _(b'add diffstat output to messages')), | ||||
|
r43346 | ( | ||
|
r43347 | b'', | ||
b'date', | ||||
b'', | ||||
_(b'use the given date as the sending date'), | ||||
_(b'DATE'), | ||||
|
r43346 | ), | ||
( | ||||
|
r43347 | b'', | ||
b'desc', | ||||
b'', | ||||
_(b'use the given file as the series description'), | ||||
_(b'FILE'), | ||||
), | ||||
(b'f', b'from', b'', _(b'email address of sender'), _(b'EMAIL')), | ||||
(b'n', b'test', None, _(b'print messages that would be sent')), | ||||
( | ||||
b'm', | ||||
b'mbox', | ||||
b'', | ||||
_(b'write messages to mbox file instead of sending them'), | ||||
_(b'FILE'), | ||||
|
r43346 | ), | ||
( | ||||
|
r43347 | b'', | ||
b'reply-to', | ||||
[], | ||||
_(b'email addresses replies should be sent to'), | ||||
_(b'EMAIL'), | ||||
|
r43346 | ), | ||
|
r43347 | ( | ||
b's', | ||||
b'subject', | ||||
b'', | ||||
_(b'subject of first message (intro or single patch)'), | ||||
_(b'TEXT'), | ||||
), | ||||
( | ||||
b'', | ||||
b'in-reply-to', | ||||
b'', | ||||
_(b'message identifier to reply to'), | ||||
_(b'MSGID'), | ||||
), | ||||
(b'', b'flag', [], _(b'flags to add in subject prefixes'), _(b'FLAG')), | ||||
(b't', b'to', [], _(b'email addresses of recipients'), _(b'EMAIL')), | ||||
|
r43346 | ] | ||
|
r14309 | |||
|
r43346 | @command( | ||
|
r43347 | b'email', | ||
|
r43346 | [ | ||
|
r43347 | (b'g', b'git', None, _(b'use git extended diff format')), | ||
(b'', b'plain', None, _(b'omit hg patch header')), | ||||
|
r43346 | ( | ||
|
r43347 | b'o', | ||
b'outgoing', | ||||
|
r43346 | None, | ||
|
r43347 | _(b'send changes not found in the target repository'), | ||
|
r43346 | ), | ||
( | ||||
|
r43347 | b'b', | ||
b'bundle', | ||||
|
r43346 | None, | ||
|
r43347 | _(b'send changes not in target as a binary bundle'), | ||
|
r43346 | ), | ||
( | ||||
|
r43347 | b'B', | ||
b'bookmark', | ||||
b'', | ||||
_(b'send changes only reachable by given bookmark'), | ||||
_(b'BOOKMARK'), | ||||
|
r43346 | ), | ||
( | ||||
|
r43347 | b'', | ||
b'bundlename', | ||||
b'bundle', | ||||
_(b'name of the bundle attachment file'), | ||||
_(b'NAME'), | ||||
|
r43346 | ), | ||
|
r43347 | (b'r', b'rev', [], _(b'a revision to send'), _(b'REV')), | ||
|
r43346 | ( | ||
|
r43347 | b'', | ||
b'force', | ||||
|
r43346 | None, | ||
_( | ||||
|
r43347 | b'run even when remote repository is unrelated ' | ||
b'(with -b/--bundle)' | ||||
|
r43346 | ), | ||
), | ||||
( | ||||
|
r43347 | b'', | ||
b'base', | ||||
|
r43346 | [], | ||
_( | ||||
|
r43347 | b'a base changeset to specify instead of a destination ' | ||
b'(with -b/--bundle)' | ||||
|
r43346 | ), | ||
|
r43347 | _(b'REV'), | ||
|
r43346 | ), | ||
|
r43347 | ( | ||
b'', | ||||
b'intro', | ||||
None, | ||||
_(b'send an introduction email for a single patch'), | ||||
), | ||||
|
r43346 | ] | ||
+ emailopts | ||||
+ cmdutil.remoteopts, | ||||
|
r43347 | _(b'hg email [OPTION]... [DEST]...'), | ||
|
r43346 | helpcategory=command.CATEGORY_IMPORT_EXPORT, | ||
) | ||||
|
r27150 | def email(ui, repo, *revs, **opts): | ||
|
r46554 | """send changesets by email | ||
|
r1669 | |||
|
r11193 | By default, diffs are sent in the format generated by | ||
:hg:`export`, one per message. The series starts with a "[PATCH 0 | ||||
of N]" introduction, which describes the series as a whole. | ||||
|
r1672 | |||
|
r9269 | Each patch email has a Subject line of "[PATCH M of N] ...", using | ||
the first line of the changeset description as the subject text. | ||||
The message contains two or three parts. First, the changeset | ||||
|
r12749 | description. | ||
With the -d/--diffstat option, if the diffstat program is | ||||
installed, the result of running diffstat on the patch is inserted. | ||||
Finally, the patch itself, as generated by :hg:`export`. | ||||
|
r17880 | With the -d/--diffstat or --confirm options, you will be presented | ||
|
r12749 | with a final summary of all messages and asked for confirmation before | ||
the messages are sent. | ||||
|
r4262 | |||
|
r9269 | By default the patch is included as text in the email body for | ||
easy reviewing. Using the -a/--attach option will instead create | ||||
an attachment for the patch. With -i/--inline an inline attachment | ||||
|
r16307 | will be created. You can include a patch both as text in the email | ||
body and as a regular or an inline attachment by combining the | ||||
-a/--attach or -i/--inline with the --body option. | ||||
|
r8472 | |||
|
r32639 | With -B/--bookmark changesets reachable by the given bookmark are | ||
selected. | ||||
|
r9269 | With -o/--outgoing, emails will be generated for patches not found | ||
in the destination repository (or only those which are ancestors | ||||
of the specified revisions if any are provided) | ||||
|
r4280 | |||
|
r9269 | With -b/--bundle, changesets are selected as for --outgoing, but a | ||
single email containing a binary Mercurial bundle as an attachment | ||||
|
r26563 | will be sent. Use the ``patchbomb.bundletype`` config option to | ||
control the bundle type as with :hg:`bundle --type`. | ||||
|
r4280 | |||
|
r12749 | With -m/--mbox, instead of previewing each patchbomb message in a | ||
pager or sending the messages directly, it will create a UNIX | ||||
mailbox file with the patch emails. This mailbox file can be | ||||
previewed with any mail user agent which supports UNIX mbox | ||||
files. | ||||
With -n/--test, all steps will run, but mail will not be sent. | ||||
You will be prompted for an email recipient address, a subject and | ||||
an introductory message describing the patches of your patchbomb. | ||||
|
r31489 | Then when all is done, patchbomb messages are displayed. | ||
|
r12749 | |||
|
r13198 | In case email sending fails, you will find a backup of your series | ||
|
r23488 | introductory message in ``.hg/last-email.txt``. | ||
The default behavior of this command can be customized through | ||||
configuration. (See :hg:`help patchbomb` for details) | ||||
|
r13198 | |||
|
r9289 | Examples:: | ||
|
r4280 | |||
|
r9289 | hg email -r 3000 # send patch 3000 only | ||
hg email -r 3000 -r 3001 # send patches 3000 and 3001 | ||||
hg email -r 3000:3005 # send patches 3000 through 3005 | ||||
hg email 3000 # send patch 3000 (deprecated) | ||||
|
r4280 | |||
|
r9289 | hg email -o # send all patches not in default | ||
hg email -o DEST # send all patches not in DEST | ||||
hg email -o -r 3000 # send all ancestors of 3000 not in default | ||||
hg email -o -r 3000 DEST # send all ancestors of 3000 not in DEST | ||||
|
r4280 | |||
|
r32639 | hg email -B feature # send all ancestors of feature bookmark | ||
|
r9289 | hg email -b # send bundle of all patches not in default | ||
hg email -b DEST # send bundle of all patches not in DEST | ||||
hg email -b -r 3000 # bundle of all ancestors of 3000 not in default | ||||
hg email -b -r 3000 DEST # bundle of all ancestors of 3000 not in DEST | ||||
|
r4280 | |||
|
r12749 | hg email -o -m mbox && # generate an mbox file... | ||
mutt -R -f mbox # ... and view it with mutt | ||||
hg email -o -m mbox && # generate an mbox file ... | ||||
|
r12839 | formail -s sendmail \\ # ... and use formail to send from the mbox | ||
|
r12749 | -bm -t < mbox # ... using sendmail | ||
|
r9269 | Before using this command, you will need to enable email in your | ||
hgrc. See the [email] section in hgrc(5) for details. | ||||
|
r46554 | """ | ||
|
r35035 | opts = pycompat.byteskwargs(opts) | ||
|
r4262 | |||
|
r7115 | _charsets = mail._charsets(ui) | ||
|
r43347 | bundle = opts.get(b'bundle') | ||
date = opts.get(b'date') | ||||
mbox = opts.get(b'mbox') | ||||
outgoing = opts.get(b'outgoing') | ||||
rev = opts.get(b'rev') | ||||
bookmark = opts.get(b'bookmark') | ||||
|
r11413 | |||
|
r43347 | if not (opts.get(b'test') or mbox): | ||
|
r5472 | # really sending | ||
|
r4489 | mail.validateconfig(ui) | ||
|
r32639 | if not (revs or rev or outgoing or bundle or bookmark): | ||
|
r43347 | raise error.Abort( | ||
_(b'specify at least one changeset with -B, -r or -o') | ||||
) | ||||
|
r4493 | |||
|
r11413 | if outgoing and bundle: | ||
|
r43346 | raise error.Abort( | ||
_( | ||||
|
r43347 | b"--outgoing mode always on with --bundle;" | ||
b" do not re-specify --outgoing" | ||||
|
r43346 | ) | ||
) | ||||
|
r44348 | cmdutil.check_at_most_one_arg(opts, b'rev', b'bookmark') | ||
|
r4278 | |||
|
r11413 | if outgoing or bundle: | ||
|
r4262 | if len(revs) > 1: | ||
|
r43347 | raise error.Abort(_(b"too many destinations")) | ||
|
r24306 | if revs: | ||
dest = revs[0] | ||||
else: | ||||
dest = None | ||||
|
r4262 | revs = [] | ||
|
r11413 | if rev: | ||
|
r4262 | if revs: | ||
|
r43347 | raise error.Abort(_(b'use only one form to specify the revision')) | ||
|
r11413 | revs = rev | ||
|
r32639 | elif bookmark: | ||
if bookmark not in repo._bookmarks: | ||||
|
r43347 | raise error.Abort(_(b"bookmark '%s' not found") % bookmark) | ||
|
r38146 | revs = scmutil.bookmarkrevs(repo, bookmark) | ||
|
r4262 | |||
|
r48928 | revs = logcmdutil.revrange(repo, revs) | ||
|
r11413 | if outgoing: | ||
|
r24568 | revs = _getoutgoing(repo, dest, revs) | ||
|
r11413 | if bundle: | ||
|
r43347 | opts[b'revs'] = [b"%d" % r for r in revs] | ||
|
r4262 | |||
|
r26626 | # check if revision exist on the public destination | ||
|
r43347 | publicurl = repo.ui.config(b'patchbomb', b'publicurl') | ||
|
r32825 | if publicurl: | ||
|
r43347 | repo.ui.debug(b'checking that revision exist in the public repo\n') | ||
|
r26626 | try: | ||
publicpeer = hg.peer(repo, {}, publicurl) | ||||
except error.RepoError: | ||||
|
r43346 | repo.ui.write_err( | ||
|
r43347 | _(b'unable to access public repo: %s\n') % publicurl | ||
|
r43346 | ) | ||
|
r26626 | raise | ||
|
r43347 | if not publicpeer.capable(b'known'): | ||
repo.ui.debug(b'skipping existence checks: public repo too old\n') | ||||
|
r26626 | else: | ||
out = [repo[r] for r in revs] | ||||
known = publicpeer.known(h.node() for h in out) | ||||
missing = [] | ||||
for idx, h in enumerate(out): | ||||
if not known[idx]: | ||||
missing.append(h) | ||||
if missing: | ||||
|
r40065 | if len(missing) > 1: | ||
|
r43347 | msg = _(b'public "%s" is missing %s and %i others') | ||
|
r26626 | msg %= (publicurl, missing[0], len(missing) - 1) | ||
else: | ||||
|
r43347 | msg = _(b'public url %s is missing %s') | ||
|
r26626 | msg %= (publicurl, missing[0]) | ||
|
r36428 | missingrevs = [ctx.rev() for ctx in missing] | ||
|
r43347 | revhint = b' '.join( | ||
b'-r %s' % h for h in repo.set(b'heads(%ld)', missingrevs) | ||||
|
r43346 | ) | ||
|
r43347 | hint = _(b"use 'hg push %s %s'") % (publicurl, revhint) | ||
|
r26626 | raise error.Abort(msg, hint=hint) | ||
|
r4262 | # start | ||
|
r11413 | if date: | ||
|
r36625 | start_time = dateutil.parsedate(date) | ||
|
r4566 | else: | ||
|
r36625 | start_time = dateutil.makedate() | ||
|
r1669 | |||
def genmsgid(id): | ||||
|
r39153 | return _msgid(id[:20], int(start_time[0])) | ||
|
r1669 | |||
|
r25825 | # deprecated config: patchbomb.from | ||
|
r43346 | sender = ( | ||
|
r43347 | opts.get(b'from') | ||
or ui.config(b'email', b'from') | ||||
or ui.config(b'patchbomb', b'from') | ||||
or prompt(ui, b'From', ui.username()) | ||||
|
r43346 | ) | ||
|
r1669 | |||
|
r31184 | if bundle: | ||
|
r35035 | stropts = pycompat.strkwargs(opts) | ||
bundledata = _getbundle(repo, dest, **stropts) | ||||
bundleopts = stropts.copy() | ||||
|
r43906 | bundleopts.pop('bundle', None) # already processed | ||
|
r23213 | msgs = _getbundlemsgs(repo, sender, bundledata, **bundleopts) | ||
|
r4278 | else: | ||
|
r35035 | msgs = _getpatchmsgs(repo, sender, revs, **pycompat.strkwargs(opts)) | ||
|
r1669 | |||
|
r12200 | showaddrs = [] | ||
|
r15162 | def getaddrs(header, ask=False, default=None): | ||
configkey = header.lower() | ||||
|
r43347 | opt = header.replace(b'-', b'_').lower() | ||
|
r15162 | addrs = opts.get(opt) | ||
|
r11150 | if addrs: | ||
|
r43347 | showaddrs.append(b'%s: %s' % (header, b', '.join(addrs))) | ||
return mail.addrlistencode(ui, addrs, _charsets, opts.get(b'test')) | ||||
|
r9947 | |||
|
r15162 | # not on the command line: fallback to config and then maybe ask | ||
|
r43347 | addr = ui.config(b'email', configkey) or ui.config( | ||
b'patchbomb', configkey | ||||
|
r43346 | ) | ||
|
r27697 | if not addr: | ||
|
r43347 | specified = ui.hasconfig(b'email', configkey) or ui.hasconfig( | ||
b'patchbomb', configkey | ||||
|
r43346 | ) | ||
|
r27697 | if not specified and ask: | ||
addr = prompt(ui, header, default=default) | ||||
|
r15162 | if addr: | ||
|
r43347 | showaddrs.append(b'%s: %s' % (header, addr)) | ||
return mail.addrlistencode(ui, [addr], _charsets, opts.get(b'test')) | ||||
|
r32826 | elif default: | ||
return mail.addrlistencode( | ||||
|
r43347 | ui, [default], _charsets, opts.get(b'test') | ||
|
r43346 | ) | ||
|
r32826 | return [] | ||
|
r9947 | |||
|
r43347 | to = getaddrs(b'To', ask=True) | ||
|
r15164 | if not to: | ||
# we can get here in non-interactive mode | ||||
|
r43347 | raise error.Abort(_(b'no recipient addresses provided')) | ||
cc = getaddrs(b'Cc', ask=True, default=b'') | ||||
bcc = getaddrs(b'Bcc') | ||||
replyto = getaddrs(b'Reply-To') | ||||
|
r2679 | |||
|
r43347 | confirm = ui.configbool(b'patchbomb', b'confirm') | ||
confirm |= bool(opts.get(b'diffstat') or opts.get(b'confirm')) | ||||
|
r23488 | |||
if confirm: | ||||
|
r43347 | ui.write(_(b'\nFinal summary:\n\n'), label=b'patchbomb.finalsummary') | ||
ui.write((b'From: %s\n' % sender), label=b'patchbomb.from') | ||||
|
r12200 | for addr in showaddrs: | ||
|
r43347 | ui.write(b'%s\n' % addr, label=b'patchbomb.to') | ||
|
r12200 | for m, subj, ds in msgs: | ||
|
r43347 | ui.write((b'Subject: %s\n' % subj), label=b'patchbomb.subject') | ||
|
r12200 | if ds: | ||
|
r43347 | ui.write(ds, label=b'patchbomb.diffstats') | ||
ui.write(b'\n') | ||||
|
r43346 | if ui.promptchoice( | ||
|
r43387 | _(b'are you sure you want to send (yn)?$$ &Yes $$ &No') | ||
|
r43346 | ): | ||
|
r43347 | raise error.Abort(_(b'patchbomb canceled')) | ||
|
r12200 | |||
|
r43347 | ui.write(b'\n') | ||
|
r1669 | |||
|
r43347 | parent = opts.get(b'in_reply_to') or None | ||
|
r8826 | # angle brackets may be omitted, they're not semantically part of the msg-id | ||
if parent is not None: | ||||
|
r43978 | parent = encoding.strfromlocal(parent) | ||
if not parent.startswith('<'): | ||||
parent = '<' + parent | ||||
if not parent.endswith('>'): | ||||
parent += '>' | ||||
|
r8826 | |||
|
r36468 | sender_addr = eutil.parseaddr(encoding.strfromlocal(sender))[1] | ||
|
r43347 | sender = mail.addressencode(ui, sender, _charsets, opts.get(b'test')) | ||
|
r5973 | sendmail = None | ||
|
r21726 | firstpatch = None | ||
|
r43347 | progress = ui.makeprogress( | ||
_(b'sending'), unit=_(b'emails'), total=len(msgs) | ||||
) | ||||
|
r12265 | for i, (m, subj, ds) in enumerate(msgs): | ||
|
r1669 | try: | ||
|
r43978 | m['Message-Id'] = genmsgid(m['X-Mercurial-Node']) | ||
|
r21726 | if not firstpatch: | ||
|
r43978 | firstpatch = m['Message-Id'] | ||
m['X-Mercurial-Series-Id'] = firstpatch | ||||
|
r1669 | except TypeError: | ||
|
r43978 | m['Message-Id'] = genmsgid('patchbomb') | ||
|
r1669 | if parent: | ||
|
r43978 | m['In-Reply-To'] = parent | ||
m['References'] = parent | ||||
if not parent or 'X-Mercurial-Node' not in m: | ||||
parent = m['Message-Id'] | ||||
|
r8514 | |||
|
r43978 | m['User-Agent'] = 'Mercurial-patchbomb/%s' % util.version().decode() | ||
m['Date'] = eutil.formatdate(start_time[0], localtime=True) | ||||
|
r2443 | |||
|
r4027 | start_time = (start_time[0] + 1, start_time[1]) | ||
|
r43978 | m['From'] = sender | ||
m['To'] = ', '.join(to) | ||||
|
r5785 | if cc: | ||
|
r43978 | m['Cc'] = ', '.join(cc) | ||
|
r5785 | if bcc: | ||
|
r43978 | m['Bcc'] = ', '.join(bcc) | ||
|
r11150 | if replyto: | ||
|
r43978 | m['Reply-To'] = ', '.join(replyto) | ||
|
r43347 | if opts.get(b'test'): | ||
ui.status(_(b'displaying '), subj, b' ...\n') | ||||
ui.pager(b'email') | ||||
|
r43427 | generator = mail.Generator(ui, mangle_from_=False) | ||
|
r1871 | try: | ||
|
r44028 | generator.flatten(m, False) | ||
|
r43347 | ui.write(b'\n') | ||
|
r50199 | except BrokenPipeError: | ||
pass | ||||
|
r15560 | else: | ||
if not sendmail: | ||||
|
r29285 | sendmail = mail.connect(ui, mbox=mbox) | ||
|
r43347 | ui.status(_(b'sending '), subj, b' ...\n') | ||
|
r38422 | progress.update(i, item=subj) | ||
|
r15560 | if not mbox: | ||
# Exim does not remove the Bcc field | ||||
|
r43978 | del m['Bcc'] | ||
|
r28861 | fp = stringio() | ||
|
r43427 | generator = mail.Generator(fp, mangle_from_=False) | ||
|
r44028 | generator.flatten(m, False) | ||
|
r39062 | alldests = to + bcc + cc | ||
sendmail(sender_addr, alldests, fp.getvalue()) | ||||
|
r1669 | |||
|
r38422 | progress.complete() | ||