patchbomb.py
513 lines
| 19.0 KiB
| text/x-python
|
PythonLexer
/ hgext / patchbomb.py
|
r8252 | # patchbomb.py - sending Mercurial changesets as patch emails | ||
# | ||||
# Copyright 2005-2009 Matt Mackall <mpm@selenic.com> and others | ||||
# | ||||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2, incorporated herein by reference. | ||||
|
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. | ||||
- 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 | |||
|
r9269 | With the -d/--diffstat option, you will be prompted for each changeset | ||
with a diffstat summary and the changeset summary, so you can be sure | ||||
you are sending the right changes. | ||||
|
r6666 | |||
|
r9269 | To configure other defaults, add a section like this to your hgrc | ||
file:: | ||||
|
r6666 | |||
[email] | ||||
from = My Name <my@email> | ||||
to = recipient1, recipient2, ... | ||||
cc = cc1, cc2, ... | ||||
bcc = bcc1, bcc2, ... | ||||
|
r9269 | Then you can use the "hg email" command to mail a series of changesets | ||
as a patchbomb. | ||||
|
r6666 | |||
|
r9269 | To avoid sending patches prematurely, it is a good idea to first run | ||
the "email" command with the "-n" option (test only). You will be | ||||
prompted for an email recipient address, a subject and an introductory | ||||
message describing the patches of your patchbomb. Then when all is | ||||
done, patchbomb messages are displayed. If the PAGER environment | ||||
variable is set, your pager will be fired up once for each patchbomb | ||||
message, so you can verify everything is alright. | ||||
|
r6666 | |||
|
r9269 | The -m/--mbox option is also very useful. 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, e.g. with mutt:: | ||||
|
r6666 | |||
% mutt -R -f mbox | ||||
|
r9286 | When you are previewing the patchbomb messages, you can use ``formail`` | ||
|
r9269 | (a utility that is commonly installed as part of the procmail | ||
package), to send each message out:: | ||||
|
r6666 | |||
% formail -s sendmail -bm -t < mbox | ||||
|
r7694 | That should be all. Now your patchbomb is on its way out. | ||
|
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. | ||||
|
r9071 | ''' | ||
|
r1669 | |||
|
r9046 | import os, errno, socket, tempfile, cStringIO, time | ||
|
r7192 | import email.MIMEMultipart, email.MIMEBase | ||
|
r6447 | import email.Utils, email.Encoders, email.Generator | ||
|
r7685 | from mercurial import cmdutil, commands, hg, mail, patch, util | ||
|
r3891 | from mercurial.i18n import _ | ||
|
r6211 | from mercurial.node import bin | ||
|
r1669 | |||
|
r9648 | def prompt(ui, prompt, default=None, rest=':'): | ||
|
r8208 | if not ui.interactive(): | ||
|
r9648 | if default is not None: | ||
|
r9612 | return default | ||
|
r9647 | raise util.Abort(_("%s Please enter a valid value" % (prompt+rest))) | ||
|
r7354 | if default: | ||
prompt += ' [%s]' % default | ||||
prompt += rest | ||||
while True: | ||||
r = ui.prompt(prompt, default=default) | ||||
if r: | ||||
return r | ||||
if default is not None: | ||||
return default | ||||
ui.warn(_('Please enter a valid value.\n')) | ||||
def cdiffstat(ui, summary, patchlines): | ||||
s = patch.diffstat(patchlines) | ||||
|
r7547 | if summary: | ||
ui.write(summary, '\n') | ||||
ui.write(s, '\n') | ||||
|
r9647 | ans = prompt(ui, _('does the diffstat above look okay?'), 'y') | ||
|
r7547 | if not ans.lower().startswith('y'): | ||
raise util.Abort(_('diffstat rejected')) | ||||
|
r7354 | return s | ||
def makepatch(ui, repo, patch, opts, _charsets, idx, total, patchname=None): | ||||
desc = [] | ||||
node = None | ||||
body = '' | ||||
for line in patch: | ||||
if line.startswith('#'): | ||||
if line.startswith('# Node ID'): | ||||
node = line.split()[-1] | ||||
continue | ||||
if line.startswith('diff -r') or line.startswith('diff --git'): | ||||
break | ||||
desc.append(line) | ||||
if not patchname and not node: | ||||
raise ValueError | ||||
if opts.get('attach'): | ||||
body = ('\n'.join(desc[1:]).strip() or | ||||
'Patch subject is complete summary.') | ||||
body += '\n\n\n' | ||||
if opts.get('plain'): | ||||
while patch and patch[0].startswith('# '): | ||||
patch.pop(0) | ||||
if patch: | ||||
patch.pop(0) | ||||
while patch and not patch[0].strip(): | ||||
patch.pop(0) | ||||
if opts.get('diffstat'): | ||||
body += cdiffstat(ui, '\n'.join(desc), patch) + '\n\n' | ||||
if opts.get('attach') or opts.get('inline'): | ||||
msg = email.MIMEMultipart.MIMEMultipart() | ||||
if body: | ||||
msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test'))) | ||||
p = mail.mimetextpatch('\n'.join(patch), 'x-patch', opts.get('test')) | ||||
binnode = bin(node) | ||||
|
r8761 | # if node is mq patch, it will have the patch file's name as a tag | ||
|
r7354 | if not patchname: | ||
patchtags = [t for t in repo.nodetags(binnode) | ||||
if t.endswith('.patch') or t.endswith('.diff')] | ||||
if patchtags: | ||||
patchname = patchtags[0] | ||||
elif total > 1: | ||||
patchname = cmdutil.make_filename(repo, '%b-%n.patch', | ||||
|
r7359 | binnode, seqno=idx, total=total) | ||
|
r7354 | else: | ||
patchname = cmdutil.make_filename(repo, '%b.patch', binnode) | ||||
disposition = 'inline' | ||||
if opts.get('attach'): | ||||
disposition = 'attachment' | ||||
p['Content-Disposition'] = disposition + '; filename=' + patchname | ||||
msg.attach(p) | ||||
else: | ||||
body += '\n'.join(patch) | ||||
msg = mail.mimetextpatch(body, display=opts.get('test')) | ||||
|
r9346 | flag = ' '.join(opts.get('flag')) | ||
if flag: | ||||
flag = ' ' + flag | ||||
|
r7354 | subj = desc[0].strip().rstrip('. ') | ||
|
r7360 | if total == 1 and not opts.get('intro'): | ||
|
r9346 | subj = '[PATCH%s] %s' % (flag, opts.get('subject') or subj) | ||
|
r7354 | else: | ||
tlen = len(str(total)) | ||||
|
r9346 | subj = '[PATCH %0*d of %d%s] %s' % (tlen, idx, total, flag, subj) | ||
|
r7354 | msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test')) | ||
msg['X-Mercurial-Node'] = node | ||||
return msg, subj | ||||
|
r1669 | def patchbomb(ui, repo, *revs, **opts): | ||
|
r4283 | '''send changesets by email | ||
|
r1669 | |||
|
r9269 | 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 | ||||
description. Next, (optionally) if the diffstat program is | ||||
installed and -d/--diffstat is used, the result of running | ||||
diffstat on the patch. Finally, the patch itself, as generated by | ||||
"hg export". | ||||
|
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 | ||||
will be created. | ||||
|
r8472 | |||
|
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 | ||||
will be sent. | ||||
|
r4280 | |||
|
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 | |||
|
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 | |||
|
r9269 | Before using this command, you will need to enable email in your | ||
hgrc. See the [email] section in hgrc(5) for details. | ||||
|
r4262 | ''' | ||
|
r7115 | _charsets = mail._charsets(ui) | ||
|
r4262 | def outgoing(dest, revs): | ||
'''Return the revisions present locally but not in dest''' | ||||
dest = ui.expandpath(dest or 'default-push', dest or 'default') | ||||
revs = [repo.lookup(rev) for rev in revs] | ||||
|
r8188 | other = hg.repository(cmdutil.remoteui(repo, opts), dest) | ||
|
r4262 | ui.status(_('comparing with %s\n') % dest) | ||
o = repo.findoutgoing(other) | ||||
if not o: | ||||
ui.status(_("no changes found\n")) | ||||
return [] | ||||
o = repo.changelog.nodesbetween(o, revs or None)[0] | ||||
return [str(repo.changelog.rev(r)) for r in o] | ||||
|
r7615 | def getpatches(revs): | ||
for r in cmdutil.revrange(repo, revs): | ||||
output = cStringIO.StringIO() | ||||
|
r7874 | patch.export(repo, [r], fp=output, | ||
opts=patch.diffopts(ui, opts)) | ||||
|
r7615 | yield output.getvalue().split('\n') | ||
|
r4279 | def getbundle(dest): | ||
|
r4278 | tmpdir = tempfile.mkdtemp(prefix='hg-email-bundle-') | ||
tmpfn = os.path.join(tmpdir, 'bundle') | ||||
try: | ||||
|
r4279 | commands.bundle(ui, repo, tmpfn, dest, **opts) | ||
|
r5752 | return open(tmpfn, 'rb').read() | ||
|
r4278 | finally: | ||
try: | ||||
os.unlink(tmpfn) | ||||
except: | ||||
pass | ||||
os.rmdir(tmpdir) | ||||
|
r5818 | if not (opts.get('test') or opts.get('mbox')): | ||
|
r5472 | # really sending | ||
|
r4489 | mail.validateconfig(ui) | ||
|
r5643 | if not (revs or opts.get('rev') | ||
|
r7353 | or opts.get('outgoing') or opts.get('bundle') | ||
or opts.get('patches')): | ||||
|
r4493 | raise util.Abort(_('specify at least one changeset with -r or -o')) | ||
|
r4492 | if opts.get('outgoing') and opts.get('bundle'): | ||
|
r5746 | raise util.Abort(_("--outgoing mode always on with --bundle;" | ||
" do not re-specify --outgoing")) | ||||
|
r4278 | |||
if opts.get('outgoing') or opts.get('bundle'): | ||||
|
r4262 | if len(revs) > 1: | ||
raise util.Abort(_("too many destinations")) | ||||
dest = revs and revs[0] or None | ||||
revs = [] | ||||
if opts.get('rev'): | ||||
if revs: | ||||
raise util.Abort(_('use only one form to specify the revision')) | ||||
revs = opts.get('rev') | ||||
if opts.get('outgoing'): | ||||
revs = outgoing(dest, opts.get('rev')) | ||||
|
r4279 | if opts.get('bundle'): | ||
opts['revs'] = revs | ||||
|
r4262 | |||
# start | ||||
|
r4566 | if opts.get('date'): | ||
|
r5818 | start_time = util.parsedate(opts.get('date')) | ||
|
r4566 | else: | ||
start_time = util.makedate() | ||||
|
r1669 | |||
def genmsgid(id): | ||||
|
r4027 | return '<%s.%s@%s>' % (id[:20], int(start_time[0]), socket.getfqdn()) | ||
|
r1669 | |||
|
r5753 | def getdescription(body, sender): | ||
|
r5818 | if opts.get('desc'): | ||
body = open(opts.get('desc')).read() | ||||
|
r5753 | else: | ||
ui.write(_('\nWrite the introductory message for the ' | ||||
'patch series.\n\n')) | ||||
body = ui.edit(body, sender) | ||||
return body | ||||
|
r7353 | def getpatchmsgs(patches, patchnames=None): | ||
|
r4278 | jumbo = [] | ||
msgs = [] | ||||
|
r1669 | |||
|
r5746 | ui.write(_('This patch series consists of %d patches.\n\n') | ||
% len(patches)) | ||||
|
r4278 | |||
|
r7353 | name = None | ||
|
r7616 | for i, p in enumerate(patches): | ||
|
r4278 | jumbo.extend(p) | ||
|
r7353 | if patchnames: | ||
name = patchnames[i] | ||||
|
r7354 | msg = makepatch(ui, repo, p, opts, _charsets, i + 1, | ||
len(patches), name) | ||||
msgs.append(msg) | ||||
|
r4278 | |||
|
r7360 | if len(patches) > 1 or opts.get('intro'): | ||
|
r4278 | tlen = len(str(len(patches))) | ||
|
r1669 | |||
|
r9346 | flag = ' '.join(opts.get('flag')) | ||
if flag: | ||||
|
r9647 | subj = '[PATCH %0*d of %d %s]' % (tlen, 0, len(patches), flag) | ||
|
r9346 | else: | ||
|
r9647 | subj = '[PATCH %0*d of %d]' % (tlen, 0, len(patches)) | ||
subj += ' ' + (opts.get('subject') or | ||||
prompt(ui, 'Subject: ', rest=subj)) | ||||
|
r1669 | |||
|
r4278 | body = '' | ||
|
r5818 | if opts.get('diffstat'): | ||
|
r7354 | d = cdiffstat(ui, _('Final summary:\n'), jumbo) | ||
|
r5785 | if d: | ||
body = '\n' + d | ||||
|
r4278 | |||
|
r5753 | body = getdescription(body, sender) | ||
|
r7115 | msg = mail.mimeencode(ui, body, _charsets, opts.get('test')) | ||
msg['Subject'] = mail.headencode(ui, subj, _charsets, | ||||
opts.get('test')) | ||||
|
r1669 | |||
|
r7115 | msgs.insert(0, (msg, subj)) | ||
|
r4278 | return msgs | ||
|
r2704 | |||
|
r4278 | def getbundlemsgs(bundle): | ||
|
r5818 | subj = (opts.get('subject') | ||
|
r7354 | or prompt(ui, 'Subject:', 'A bundle for your repository')) | ||
|
r1669 | |||
|
r5753 | body = getdescription('', sender) | ||
|
r4278 | msg = email.MIMEMultipart.MIMEMultipart() | ||
if body: | ||||
|
r7115 | msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test'))) | ||
|
r4278 | datapart = email.MIMEBase.MIMEBase('application', 'x-mercurial-bundle') | ||
datapart.set_payload(bundle) | ||||
|
r7889 | bundlename = '%s.hg' % opts.get('bundlename', 'bundle') | ||
|
r4284 | datapart.add_header('Content-Disposition', 'attachment', | ||
|
r7889 | filename=bundlename) | ||
|
r4278 | email.Encoders.encode_base64(datapart) | ||
msg.attach(datapart) | ||||
|
r7115 | msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test')) | ||
return [(msg, subj)] | ||||
|
r1669 | |||
|
r5818 | sender = (opts.get('from') or ui.config('email', 'from') or | ||
|
r2198 | ui.config('patchbomb', 'from') or | ||
|
r7354 | prompt(ui, 'From', ui.username())) | ||
|
r1669 | |||
|
r7615 | # internal option used by pbranches | ||
|
r7353 | patches = opts.get('patches') | ||
if patches: | ||||
msgs = getpatchmsgs(patches, opts.get('patchnames')) | ||||
elif opts.get('bundle'): | ||||
|
r4279 | msgs = getbundlemsgs(getbundle(dest)) | ||
|
r4278 | else: | ||
|
r7615 | msgs = getpatchmsgs(list(getpatches(revs))) | ||
|
r1669 | |||
def getaddrs(opt, prpt, default = None): | ||||
|
r5818 | addrs = opts.get(opt) or (ui.config('email', opt) or | ||
ui.config('patchbomb', opt) or | ||||
|
r7354 | prompt(ui, prpt, default)).split(',') | ||
|
r7115 | return [mail.addressencode(ui, a.strip(), _charsets, opts.get('test')) | ||
for a in addrs if a.strip()] | ||||
|
r4485 | |||
|
r1669 | to = getaddrs('to', 'To') | ||
cc = getaddrs('cc', 'Cc', '') | ||||
|
r5818 | bcc = opts.get('bcc') or (ui.config('email', 'bcc') or | ||
|
r2679 | ui.config('patchbomb', 'bcc') or '').split(',') | ||
|
r7115 | bcc = [mail.addressencode(ui, a.strip(), _charsets, opts.get('test')) | ||
for a in bcc if a.strip()] | ||||
|
r2679 | |||
|
r1669 | ui.write('\n') | ||
|
r8025 | parent = opts.get('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: | ||||
if not parent.startswith('<'): | ||||
parent = '<' + parent | ||||
if not parent.endswith('>'): | ||||
parent += '>' | ||||
|
r8514 | first = True | ||
|
r2443 | |||
|
r1827 | sender_addr = email.Utils.parseaddr(sender)[1] | ||
|
r7115 | sender = mail.addressencode(ui, sender, _charsets, opts.get('test')) | ||
|
r5973 | sendmail = None | ||
|
r7115 | for m, subj in msgs: | ||
|
r1669 | try: | ||
m['Message-Id'] = genmsgid(m['X-Mercurial-Node']) | ||||
except TypeError: | ||||
m['Message-Id'] = genmsgid('patchbomb') | ||||
if parent: | ||||
m['In-Reply-To'] = parent | ||||
|
r7413 | m['References'] = parent | ||
|
r8514 | if first: | ||
|
r1669 | parent = m['Message-Id'] | ||
|
r8514 | first = False | ||
|
r8160 | m['User-Agent'] = 'Mercurial-patchbomb/%s' % util.version() | ||
|
r9047 | m['Date'] = email.Utils.formatdate(start_time[0], localtime=True) | ||
|
r2443 | |||
|
r4027 | start_time = (start_time[0] + 1, start_time[1]) | ||
|
r1669 | m['From'] = sender | ||
m['To'] = ', '.join(to) | ||||
|
r5785 | if cc: | ||
m['Cc'] = ', '.join(cc) | ||||
if bcc: | ||||
m['Bcc'] = ', '.join(bcc) | ||||
|
r5818 | if opts.get('test'): | ||
|
r7115 | ui.status(_('Displaying '), subj, ' ...\n') | ||
|
r4596 | ui.flush() | ||
|
r4599 | if 'PAGER' in os.environ: | ||
|
r6548 | fp = util.popen(os.environ['PAGER'], 'w') | ||
|
r4599 | else: | ||
fp = ui | ||||
|
r6447 | generator = email.Generator.Generator(fp, mangle_from_=False) | ||
|
r1871 | try: | ||
|
r6447 | generator.flatten(m, 0) | ||
|
r1871 | fp.write('\n') | ||
except IOError, inst: | ||||
if inst.errno != errno.EPIPE: | ||||
raise | ||||
|
r4599 | if fp is not ui: | ||
fp.close() | ||||
|
r5818 | elif opts.get('mbox'): | ||
|
r7115 | ui.status(_('Writing '), subj, ' ...\n') | ||
|
r5915 | fp = open(opts.get('mbox'), 'In-Reply-To' in m and 'ab+' or 'wb+') | ||
|
r6447 | generator = email.Generator.Generator(fp, mangle_from_=True) | ||
|
r9046 | date = time.ctime(start_time[0]) | ||
|
r1702 | fp.write('From %s %s\n' % (sender_addr, date)) | ||
|
r6447 | generator.flatten(m, 0) | ||
|
r1702 | fp.write('\n\n') | ||
fp.close() | ||||
|
r1669 | else: | ||
|
r5973 | if not sendmail: | ||
sendmail = mail.connect(ui) | ||||
|
r7115 | ui.status(_('Sending '), subj, ' ...\n') | ||
|
r2790 | # Exim does not remove the Bcc field | ||
del m['Bcc'] | ||||
|
r6447 | fp = cStringIO.StringIO() | ||
generator = email.Generator.Generator(fp, mangle_from_=False) | ||||
generator.flatten(m, 0) | ||||
sendmail(sender, to + bcc + cc, fp.getvalue()) | ||||
|
r1669 | |||
|
r7352 | emailopts = [ | ||
('a', 'attach', None, _('send patches as attachments')), | ||||
|
r5819 | ('i', 'inline', None, _('send patches as inline attachments')), | ||
|
r7807 | ('', 'bcc', [], _('email addresses of blind carbon copy recipients')), | ||
|
r4730 | ('c', 'cc', [], _('email addresses of copy recipients')), | ||
('d', 'diffstat', None, _('add diffstat output to messages')), | ||||
('', 'date', '', _('use the given date as the sending date')), | ||||
|
r4887 | ('', 'desc', '', _('use the given file as the series description')), | ||
|
r4730 | ('f', 'from', '', _('email address of sender')), | ||
('n', 'test', None, _('print messages that would be sent')), | ||||
('m', 'mbox', '', | ||||
_('write messages to mbox file instead of sending them')), | ||||
|
r7352 | ('s', 'subject', '', | ||
_('subject of first message (intro or single patch)')), | ||||
|
r8025 | ('', 'in-reply-to', '', | ||
|
r8331 | _('message identifier to reply to')), | ||
|
r9346 | ('', 'flag', [], _('flags to add in subject prefixes')), | ||
|
r7352 | ('t', 'to', [], _('email addresses of recipients')), | ||
] | ||||
cmdtable = { | ||||
"email": | ||||
(patchbomb, | ||||
[('g', 'git', None, _('use git extended diff format')), | ||||
('', 'plain', None, _('omit hg patch header')), | ||||
|
r4730 | ('o', 'outgoing', None, | ||
_('send changes not found in the target repository')), | ||||
('b', 'bundle', None, | ||||
_('send changes not in target as a binary bundle')), | ||||
|
r7889 | ('', 'bundlename', 'bundle', | ||
|
r8761 | _('name of the bundle attachment file')), | ||
|
r4730 | ('r', 'rev', [], _('a revision to send')), | ||
('', 'force', None, | ||||
|
r8076 | _('run even when remote repository is unrelated ' | ||
'(with -b/--bundle)')), | ||||
|
r4730 | ('', 'base', [], | ||
|
r8076 | _('a base changeset to specify instead of a destination ' | ||
'(with -b/--bundle)')), | ||||
|
r7360 | ('', 'intro', None, | ||
_('send an introduction email for a single patch')), | ||||
|
r7352 | ] + emailopts + commands.remoteopts, | ||
|
r4730 | _('hg email [OPTION]... [DEST]...')) | ||
} | ||||