patchbomb.py
423 lines
| 15.5 KiB
| text/x-python
|
PythonLexer
/ hgext / patchbomb.py
|
r1669 | # Command for sending a collection of Mercurial changesets as a series | ||
# of patch emails. | ||||
# | ||||
# The series is started off with a "[PATCH 0 of N]" introduction, | ||||
# which describes the series as a whole. | ||||
# | ||||
# 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: | ||||
# | ||||
# The remainder of the changeset description. | ||||
# | ||||
# [Optional] If the diffstat program is installed, the result of | ||||
# running diffstat on the patch. | ||||
# | ||||
# The patch itself, as generated by "hg export". | ||||
# | ||||
# Each message refers to all of its predecessors 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. | ||||
# | ||||
# For each changeset, you will be prompted with a diffstat summary and | ||||
# the changeset summary, so you can be sure you are sending the right | ||||
# changes. | ||||
# | ||||
|
r2926 | # To enable this extension: | ||
|
r1669 | # | ||
|
r2926 | # [extensions] | ||
# hgext.patchbomb = | ||||
|
r1702 | # | ||
|
r1669 | # To configure other defaults, add a section like this to your hgrc | ||
# file: | ||||
# | ||||
|
r2926 | # [email] | ||
# from = My Name <my@email> | ||||
# to = recipient1, recipient2, ... | ||||
# cc = cc1, cc2, ... | ||||
# bcc = bcc1, bcc2, ... | ||||
# | ||||
# Then you can use the "hg email" command to mail a series of changesets | ||||
# as a patchbomb. | ||||
# | ||||
# 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 an an introductory | ||||
# message describing the patches of your patchbomb. Then when all is | ||||
# done, your pager will be fired up once for each patchbomb message, so | ||||
# you can verify everything is alright. | ||||
# | ||||
# 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, i.e. with mutt: | ||||
# | ||||
# % mutt -R -f mbox | ||||
# | ||||
# When you are previewing the patchbomb messages, you can use `formail' | ||||
# (a utility that is commonly installed as part of the procmail package), | ||||
# to send each message out: | ||||
# | ||||
# % formail -s sendmail -bm -t < mbox | ||||
# | ||||
# That should be all. Now your patchbomb is on its way out. | ||||
|
r1669 | |||
|
r4278 | import os, errno, socket, tempfile | ||
import email.MIMEMultipart, email.MIMEText, email.MIMEBase | ||||
import email.Utils, email.Encoders | ||||
|
r4029 | from mercurial import cmdutil, commands, hg, mail, ui, patch, util | ||
|
r3891 | from mercurial.i18n import _ | ||
|
r2708 | from mercurial.node import * | ||
|
r1669 | |||
def patchbomb(ui, repo, *revs, **opts): | ||||
|
r4283 | '''send changesets by email | ||
|
r1672 | |||
|
r4283 | 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 | |||
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. First, the rest of | ||||
the changeset description. Next, (optionally) if the diffstat | ||||
program is installed, the result of running diffstat on the patch. | ||||
|
r4262 | Finally, the patch itself, as generated by "hg export". | ||
With --outgoing, emails will be generated for patches not | ||||
|
r4280 | found in the destination repository (or only those which are | ||
|
r4262 | ancestors of the specified revisions if any are provided) | ||
|
r4280 | |||
With --bundle, changesets are selected as for --outgoing, | ||||
but a single email containing a binary Mercurial bundle as an | ||||
attachment will be sent. | ||||
Examples: | ||||
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) | ||||
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 | ||||
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 | ||||
Before using this command, you will need to enable email in your hgrc. | ||||
|
r4283 | See the [email] section in hgrc(5) for details. | ||
|
r4262 | ''' | ||
|
r1669 | def prompt(prompt, default = None, rest = ': ', empty_ok = False): | ||
|
r4486 | try: | ||
# readline gives raw_input editing capabilities, but is not | ||||
# present on windows | ||||
import readline | ||||
except ImportError: pass | ||||
|
r1669 | if default: prompt += ' [%s]' % default | ||
prompt += rest | ||||
while True: | ||||
r = raw_input(prompt) | ||||
if r: return r | ||||
if default is not None: return default | ||||
if empty_ok: return r | ||||
|
r1670 | ui.warn(_('Please enter a valid value.\n')) | ||
|
r1669 | |||
def confirm(s): | ||||
if not prompt(s, default = 'y', rest = '? ').lower().startswith('y'): | ||||
raise ValueError | ||||
|
r3096 | def cdiffstat(summary, patchlines): | ||
s = patch.diffstat(patchlines) | ||||
|
r1669 | if s: | ||
if summary: | ||||
ui.write(summary, '\n') | ||||
ui.write(s, '\n') | ||||
|
r1670 | confirm(_('Does the diffstat above look okay')) | ||
|
r1669 | return s | ||
def makepatch(patch, idx, total): | ||||
desc = [] | ||||
node = None | ||||
body = '' | ||||
for line in patch: | ||||
if line.startswith('#'): | ||||
if line.startswith('# Node ID'): node = line.split()[-1] | ||||
continue | ||||
|
r3054 | if (line.startswith('diff -r') | ||
or line.startswith('diff --git')): | ||||
break | ||||
|
r1669 | desc.append(line) | ||
if not node: raise ValueError | ||||
#body = ('\n'.join(desc[1:]).strip() or | ||||
# 'Patch subject is complete summary.') | ||||
#body += '\n\n\n' | ||||
if opts['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['diffstat']: | ||||
body += cdiffstat('\n'.join(desc), patch) + '\n\n' | ||||
|
r2707 | if opts['attach']: | ||
msg = email.MIMEMultipart.MIMEMultipart() | ||||
if body: msg.attach(email.MIMEText.MIMEText(body, 'plain')) | ||||
|
r2708 | p = email.MIMEText.MIMEText('\n'.join(patch), 'x-patch') | ||
|
r2722 | binnode = bin(node) | ||
|
r2708 | # if node is mq patch, it will have patch file name as tag | ||
|
r2722 | patchname = [t for t in repo.nodetags(binnode) | ||
|
r2708 | if t.endswith('.patch') or t.endswith('.diff')] | ||
if patchname: | ||||
patchname = patchname[0] | ||||
elif total > 1: | ||||
|
r3253 | patchname = cmdutil.make_filename(repo, '%b-%n.patch', | ||
|
r2722 | binnode, idx, total) | ||
|
r2708 | else: | ||
|
r3253 | patchname = cmdutil.make_filename(repo, '%b.patch', binnode) | ||
|
r2708 | p['Content-Disposition'] = 'inline; filename=' + patchname | ||
msg.attach(p) | ||||
|
r2707 | else: | ||
body += '\n'.join(patch) | ||||
msg = email.MIMEText.MIMEText(body) | ||||
|
r4141 | |||
|
r4142 | subj = desc[0].strip().rstrip('. ') | ||
|
r1846 | if total == 1: | ||
|
r4141 | subj = '[PATCH] ' + (opts['subject'] or subj) | ||
|
r1846 | else: | ||
|
r3291 | tlen = len(str(total)) | ||
|
r4141 | subj = '[PATCH %0*d of %d] %s' % (tlen, idx, total, subj) | ||
|
r1669 | msg['Subject'] = subj | ||
msg['X-Mercurial-Node'] = node | ||||
return msg | ||||
|
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] | ||||
other = hg.repository(ui, dest) | ||||
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] | ||||
|
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) | ||
|
r4278 | return open(tmpfn).read() | ||
finally: | ||||
try: | ||||
os.unlink(tmpfn) | ||||
except: | ||||
pass | ||||
os.rmdir(tmpdir) | ||||
|
r4489 | if not opts['test']: | ||
mail.validateconfig(ui) | ||||
|
r4493 | if not (revs or opts.get('rev') or opts.get('outgoing')): | ||
raise util.Abort(_('specify at least one changeset with -r or -o')) | ||||
|
r4564 | cmdutil.setremoteconfig(ui, opts) | ||
|
r4492 | if opts.get('outgoing') and opts.get('bundle'): | ||
|
r4278 | raise util.Abort(_("--outgoing mode always on with --bundle; do not re-specify --outgoing")) | ||
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 | ||||
|
r4027 | start_time = util.makedate() | ||
|
r1669 | |||
def genmsgid(id): | ||||
|
r4027 | return '<%s.%s@%s>' % (id[:20], int(start_time[0]), socket.getfqdn()) | ||
|
r1669 | |||
|
r4278 | def getexportmsgs(): | ||
patches = [] | ||||
class exportee: | ||||
def __init__(self, container): | ||||
self.lines = [] | ||||
self.container = container | ||||
self.name = 'email' | ||||
def write(self, data): | ||||
self.lines.append(data) | ||||
def close(self): | ||||
self.container.append(''.join(self.lines).split('\n')) | ||||
self.lines = [] | ||||
|
r3291 | |||
|
r4278 | commands.export(ui, repo, *revs, **{'output': exportee(patches), | ||
'switch_parent': False, | ||||
'text': None, | ||||
'git': opts.get('git')}) | ||||
jumbo = [] | ||||
msgs = [] | ||||
ui.write(_('This patch series consists of %d patches.\n\n') % len(patches)) | ||||
for p, i in zip(patches, xrange(len(patches))): | ||||
jumbo.extend(p) | ||||
msgs.append(makepatch(p, i + 1, len(patches))) | ||||
if len(patches) > 1: | ||||
tlen = len(str(len(patches))) | ||||
|
r1845 | |||
|
r4278 | subj = '[PATCH %0*d of %d] %s' % ( | ||
tlen, 0, | ||||
len(patches), | ||||
opts['subject'] or | ||||
prompt('Subject:', rest = ' [PATCH %0*d of %d] ' % (tlen, 0, | ||||
len(patches)))) | ||||
body = '' | ||||
if opts['diffstat']: | ||||
d = cdiffstat(_('Final summary:\n'), jumbo) | ||||
if d: body = '\n' + d | ||||
ui.write(_('\nWrite the introductory message for the patch series.\n\n')) | ||||
body = ui.edit(body, sender) | ||||
msg = email.MIMEText.MIMEText(body) | ||||
msg['Subject'] = subj | ||||
msgs.insert(0, msg) | ||||
return msgs | ||||
|
r2704 | |||
|
r4278 | def getbundlemsgs(bundle): | ||
subj = opts['subject'] or \ | ||||
prompt('Subject:', default='A bundle for your repository') | ||||
ui.write(_('\nWrite the introductory message for the bundle.\n\n')) | ||||
body = ui.edit('', sender) | ||||
|
r2704 | |||
|
r4278 | msg = email.MIMEMultipart.MIMEMultipart() | ||
if body: | ||||
msg.attach(email.MIMEText.MIMEText(body, 'plain')) | ||||
datapart = email.MIMEBase.MIMEBase('application', 'x-mercurial-bundle') | ||||
datapart.set_payload(bundle) | ||||
|
r4284 | datapart.add_header('Content-Disposition', 'attachment', | ||
filename='bundle.hg') | ||||
|
r4278 | email.Encoders.encode_base64(datapart) | ||
msg.attach(datapart) | ||||
|
r2704 | msg['Subject'] = subj | ||
|
r4278 | return [msg] | ||
|
r1845 | |||
|
r4491 | sender = (opts['from'] or ui.config('email', 'from') or | ||
ui.config('patchbomb', 'from') or | ||||
prompt('From', ui.username())) | ||||
|
r4278 | if opts.get('bundle'): | ||
|
r4279 | msgs = getbundlemsgs(getbundle(dest)) | ||
|
r4278 | else: | ||
msgs = getexportmsgs() | ||||
|
r1669 | |||
|
r4485 | def getaddrs(opt, prpt, default = None): | ||
addrs = opts[opt] or (ui.config('email', opt) or | ||||
ui.config('patchbomb', opt) or | ||||
prompt(prpt, default = default)).split(',') | ||||
return [a.strip() for a in addrs if a.strip()] | ||||
to = getaddrs('to', 'To') | ||||
cc = getaddrs('cc', 'Cc', '') | ||||
bcc = opts['bcc'] or (ui.config('email', 'bcc') or | ||||
ui.config('patchbomb', 'bcc') or '').split(',') | ||||
bcc = [a.strip() for a in bcc if a.strip()] | ||||
|
r1669 | ui.write('\n') | ||
|
r1702 | if not opts['test'] and not opts['mbox']: | ||
|
r2889 | mailer = mail.connect(ui) | ||
|
r1669 | parent = None | ||
|
r2443 | |||
|
r1827 | sender_addr = email.Utils.parseaddr(sender)[1] | ||
|
r1669 | for m in msgs: | ||
try: | ||||
m['Message-Id'] = genmsgid(m['X-Mercurial-Node']) | ||||
except TypeError: | ||||
m['Message-Id'] = genmsgid('patchbomb') | ||||
if parent: | ||||
m['In-Reply-To'] = parent | ||||
else: | ||||
parent = m['Message-Id'] | ||||
|
r4027 | m['Date'] = util.datestr(date=start_time, | ||
format="%a, %d %b %Y %H:%M:%S", timezone=True) | ||||
|
r2443 | |||
|
r4027 | start_time = (start_time[0] + 1, start_time[1]) | ||
|
r1669 | m['From'] = sender | ||
m['To'] = ', '.join(to) | ||||
|
r2679 | if cc: m['Cc'] = ', '.join(cc) | ||
if bcc: m['Bcc'] = ', '.join(bcc) | ||||
|
r1669 | if opts['test']: | ||
|
r1702 | ui.status('Displaying ', m['Subject'], ' ...\n') | ||
|
r1669 | fp = os.popen(os.getenv('PAGER', 'more'), 'w') | ||
|
r1871 | try: | ||
fp.write(m.as_string(0)) | ||||
fp.write('\n') | ||||
except IOError, inst: | ||||
if inst.errno != errno.EPIPE: | ||||
raise | ||||
|
r1669 | fp.close() | ||
|
r1702 | elif opts['mbox']: | ||
ui.status('Writing ', m['Subject'], ' ...\n') | ||||
fp = open(opts['mbox'], m.has_key('In-Reply-To') and 'ab+' or 'wb+') | ||||
|
r4027 | date = util.datestr(date=start_time, | ||
format='%a %b %d %H:%M:%S %Y', timezone=False) | ||||
|
r1702 | fp.write('From %s %s\n' % (sender_addr, date)) | ||
fp.write(m.as_string(0)) | ||||
fp.write('\n\n') | ||||
fp.close() | ||||
|
r1669 | else: | ||
|
r1702 | ui.status('Sending ', m['Subject'], ' ...\n') | ||
|
r2790 | # Exim does not remove the Bcc field | ||
del m['Bcc'] | ||||
|
r2889 | mailer.sendmail(sender, to + bcc + cc, m.as_string(0)) | ||
|
r1669 | |||
cmdtable = { | ||||
'email': | ||||
(patchbomb, | ||||
|
r2707 | [('a', 'attach', None, 'send patches as inline attachments'), | ||
('', 'bcc', [], 'email addresses of blind copy recipients'), | ||||
|
r2679 | ('c', 'cc', [], 'email addresses of copy recipients'), | ||
|
r1669 | ('d', 'diffstat', None, 'add diffstat output to messages'), | ||
|
r3030 | ('g', 'git', None, _('use git extended diff format')), | ||
|
r1669 | ('f', 'from', '', 'email address of sender'), | ||
('', 'plain', None, 'omit hg patch header'), | ||||
('n', 'test', None, 'print messages that would be sent'), | ||||
|
r1702 | ('m', 'mbox', '', 'write messages to mbox file instead of sending them'), | ||
|
r4262 | ('o', 'outgoing', None, _('send changes not found in the target repository')), | ||
|
r4278 | ('b', 'bundle', None, _('send changes not in target as a binary bundle')), | ||
|
r4262 | ('r', 'rev', [], _('a revision to send')), | ||
|
r4141 | ('s', 'subject', '', 'subject of first message (intro or single patch)'), | ||
|
r4279 | ('t', 'to', [], 'email addresses of recipients'), | ||
('', 'force', None, _('run even when remote repository is unrelated (with -b)')), | ||||
('', 'base', [], | ||||
_('a base changeset to specify instead of a destination (with -b)'))] | ||||
+ commands.remoteopts, | ||||
|
r4262 | "hg email [OPTION]... [DEST]...") | ||
|
r1669 | } | ||