1 # patchbomb.py - sending Mercurial changesets as patch emails
3 # Copyright 2005-2009 Matt Mackall <mpm@selenic.com> and others
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2, incorporated herein by reference.
8 '''command to send changesets as (a series of) patch emails
10 The series is started off with a "[PATCH 0 of N]" introduction, which
11 describes the series as a whole.
13 Each patch email has a Subject line of "[PATCH M of N] ...", using the
14 first line of the changeset description as the subject text. The
15 message contains two or three body parts:
17 - The changeset description.
18 - [Optional] The result of running diffstat on the patch.
19 - The patch itself, as generated by "hg export".
21 Each message refers to the first in the series using the In-Reply-To
22 and References headers, so they will show up as a sequence in threaded
23 mail and news readers, and in mail archives.
25 With the -d/--diffstat option, you will be prompted for each changeset
26 with a diffstat summary and the changeset summary, so you can be sure
27 you are sending the right changes.
29 To configure other defaults, add a section like this to your hgrc
33 from = My Name <my@email>
34 to = recipient1, recipient2, ...
38 Then you can use the "hg email" command to mail a series of changesets
41 To avoid sending patches prematurely, it is a good idea to first run
42 the "email" command with the "-n" option (test only). You will be
43 prompted for an email recipient address, a subject and an introductory
44 message describing the patches of your patchbomb. Then when all is
45 done, patchbomb messages are displayed. If the PAGER environment
46 variable is set, your pager will be fired up once for each patchbomb
47 message, so you can verify everything is alright.
49 The -m/--mbox option is also very useful. Instead of previewing each
50 patchbomb message in a pager or sending the messages directly, it will
51 create a UNIX mailbox file with the patch emails. This mailbox file
52 can be previewed with any mail user agent which supports UNIX mbox
53 files, e.g. with mutt::
57 When you are previewing the patchbomb messages, you can use ``formail``
58 (a utility that is commonly installed as part of the procmail
59 package), to send each message out::
61 % formail -s sendmail -bm -t < mbox
63 That should be all. Now your patchbomb is on its way out.
65 You can also either configure the method option in the email section
66 to be a sendmail compatible mailer or fill out the [smtp] section so
67 that the patchbomb extension can automatically send patchbombs
68 directly from the commandline. See the [email] and [smtp] sections in
72 import os, errno, socket, tempfile, cStringIO, time
73 import email.MIMEMultipart, email.MIMEBase
74 import email.Utils, email.Encoders, email.Generator
75 from mercurial import cmdutil, commands, hg, mail, patch, util
76 from mercurial.i18n import _
77 from mercurial.node import bin
79 def prompt(ui, prompt, default=None, rest=': ', empty_ok=False):
80 if not ui.interactive():
83 prompt += ' [%s]' % default
86 r = ui.prompt(prompt, default=default)
89 if default is not None:
93 ui.warn(_('Please enter a valid value.\n'))
95 def cdiffstat(ui, summary, patchlines):
96 s = patch.diffstat(patchlines)
98 ui.write(summary, '\n')
100 ans = prompt(ui, _('does the diffstat above look okay? '), 'y')
101 if not ans.lower().startswith('y'):
102 raise util.Abort(_('diffstat rejected'))
105 def makepatch(ui, repo, patch, opts, _charsets, idx, total, patchname=None):
112 if line.startswith('#'):
113 if line.startswith('# Node ID'):
114 node = line.split()[-1]
116 if line.startswith('diff -r') or line.startswith('diff --git'):
120 if not patchname and not node:
123 if opts.get('attach'):
124 body = ('\n'.join(desc[1:]).strip() or
125 'Patch subject is complete summary.')
128 if opts.get('plain'):
129 while patch and patch[0].startswith('# '):
133 while patch and not patch[0].strip():
136 if opts.get('diffstat'):
137 body += cdiffstat(ui, '\n'.join(desc), patch) + '\n\n'
139 if opts.get('attach') or opts.get('inline'):
140 msg = email.MIMEMultipart.MIMEMultipart()
142 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test')))
143 p = mail.mimetextpatch('\n'.join(patch), 'x-patch', opts.get('test'))
145 # if node is mq patch, it will have the patch file's name as a tag
147 patchtags = [t for t in repo.nodetags(binnode)
148 if t.endswith('.patch') or t.endswith('.diff')]
150 patchname = patchtags[0]
152 patchname = cmdutil.make_filename(repo, '%b-%n.patch',
153 binnode, seqno=idx, total=total)
155 patchname = cmdutil.make_filename(repo, '%b.patch', binnode)
156 disposition = 'inline'
157 if opts.get('attach'):
158 disposition = 'attachment'
159 p['Content-Disposition'] = disposition + '; filename=' + patchname
162 body += '\n'.join(patch)
163 msg = mail.mimetextpatch(body, display=opts.get('test'))
165 flag = ' '.join(opts.get('flag'))
169 subj = desc[0].strip().rstrip('. ')
170 if total == 1 and not opts.get('intro'):
171 subj = '[PATCH%s] %s' % (flag, opts.get('subject') or subj)
173 tlen = len(str(total))
174 subj = '[PATCH %0*d of %d%s] %s' % (tlen, idx, total, flag, subj)
175 msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
176 msg['X-Mercurial-Node'] = node
179 def patchbomb(ui, repo, *revs, **opts):
180 '''send changesets by email
182 By default, diffs are sent in the format generated by hg export,
183 one per message. The series starts with a "[PATCH 0 of N]"
184 introduction, which describes the series as a whole.
186 Each patch email has a Subject line of "[PATCH M of N] ...", using
187 the first line of the changeset description as the subject text.
188 The message contains two or three parts. First, the changeset
189 description. Next, (optionally) if the diffstat program is
190 installed and -d/--diffstat is used, the result of running
191 diffstat on the patch. Finally, the patch itself, as generated by
194 By default the patch is included as text in the email body for
195 easy reviewing. Using the -a/--attach option will instead create
196 an attachment for the patch. With -i/--inline an inline attachment
199 With -o/--outgoing, emails will be generated for patches not found
200 in the destination repository (or only those which are ancestors
201 of the specified revisions if any are provided)
203 With -b/--bundle, changesets are selected as for --outgoing, but a
204 single email containing a binary Mercurial bundle as an attachment
209 hg email -r 3000 # send patch 3000 only
210 hg email -r 3000 -r 3001 # send patches 3000 and 3001
211 hg email -r 3000:3005 # send patches 3000 through 3005
212 hg email 3000 # send patch 3000 (deprecated)
214 hg email -o # send all patches not in default
215 hg email -o DEST # send all patches not in DEST
216 hg email -o -r 3000 # send all ancestors of 3000 not in default
217 hg email -o -r 3000 DEST # send all ancestors of 3000 not in DEST
219 hg email -b # send bundle of all patches not in default
220 hg email -b DEST # send bundle of all patches not in DEST
221 hg email -b -r 3000 # bundle of all ancestors of 3000 not in default
222 hg email -b -r 3000 DEST # bundle of all ancestors of 3000 not in DEST
224 Before using this command, you will need to enable email in your
225 hgrc. See the [email] section in hgrc(5) for details.
228 _charsets = mail._charsets(ui)
230 def outgoing(dest, revs):
231 '''Return the revisions present locally but not in dest'''
232 dest = ui.expandpath(dest or 'default-push', dest or 'default')
233 revs = [repo.lookup(rev) for rev in revs]
234 other = hg.repository(cmdutil.remoteui(repo, opts), dest)
235 ui.status(_('comparing with %s\n') % dest)
236 o = repo.findoutgoing(other)
238 ui.status(_("no changes found\n"))
240 o = repo.changelog.nodesbetween(o, revs or None)[0]
241 return [str(repo.changelog.rev(r)) for r in o]
243 def getpatches(revs):
244 for r in cmdutil.revrange(repo, revs):
245 output = cStringIO.StringIO()
246 patch.export(repo, [r], fp=output,
247 opts=patch.diffopts(ui, opts))
248 yield output.getvalue().split('\n')
251 tmpdir = tempfile.mkdtemp(prefix='hg-email-bundle-')
252 tmpfn = os.path.join(tmpdir, 'bundle')
254 commands.bundle(ui, repo, tmpfn, dest, **opts)
255 return open(tmpfn, 'rb').read()
263 if not (opts.get('test') or opts.get('mbox')):
265 mail.validateconfig(ui)
267 if not (revs or opts.get('rev')
268 or opts.get('outgoing') or opts.get('bundle')
269 or opts.get('patches')):
270 raise util.Abort(_('specify at least one changeset with -r or -o'))
272 if opts.get('outgoing') and opts.get('bundle'):
273 raise util.Abort(_("--outgoing mode always on with --bundle;"
274 " do not re-specify --outgoing"))
276 if opts.get('outgoing') or opts.get('bundle'):
278 raise util.Abort(_("too many destinations"))
279 dest = revs and revs[0] or None
284 raise util.Abort(_('use only one form to specify the revision'))
285 revs = opts.get('rev')
287 if opts.get('outgoing'):
288 revs = outgoing(dest, opts.get('rev'))
289 if opts.get('bundle'):
294 start_time = util.parsedate(opts.get('date'))
296 start_time = util.makedate()
299 return '<%s.%s@%s>' % (id[:20], int(start_time[0]), socket.getfqdn())
301 def getdescription(body, sender):
303 body = open(opts.get('desc')).read()
305 ui.write(_('\nWrite the introductory message for the '
306 'patch series.\n\n'))
307 body = ui.edit(body, sender)
310 def getpatchmsgs(patches, patchnames=None):
314 ui.write(_('This patch series consists of %d patches.\n\n')
318 for i, p in enumerate(patches):
322 msg = makepatch(ui, repo, p, opts, _charsets, i + 1,
326 if len(patches) > 1 or opts.get('intro'):
327 tlen = len(str(len(patches)))
329 flag = ' '.join(opts.get('flag'))
331 subj = '[PATCH %0*d of %d %s] ' % (tlen, 0, len(patches), flag)
333 subj = '[PATCH %0*d of %d] ' % (tlen, 0, len(patches))
334 subj += opts.get('subject') or prompt(ui, 'Subject:', rest=subj,
338 if opts.get('diffstat'):
339 d = cdiffstat(ui, _('Final summary:\n'), jumbo)
343 body = getdescription(body, sender)
344 msg = mail.mimeencode(ui, body, _charsets, opts.get('test'))
345 msg['Subject'] = mail.headencode(ui, subj, _charsets,
348 msgs.insert(0, (msg, subj))
351 def getbundlemsgs(bundle):
352 subj = (opts.get('subject')
353 or prompt(ui, 'Subject:', 'A bundle for your repository'))
355 body = getdescription('', sender)
356 msg = email.MIMEMultipart.MIMEMultipart()
358 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test')))
359 datapart = email.MIMEBase.MIMEBase('application', 'x-mercurial-bundle')
360 datapart.set_payload(bundle)
361 bundlename = '%s.hg' % opts.get('bundlename', 'bundle')
362 datapart.add_header('Content-Disposition', 'attachment',
364 email.Encoders.encode_base64(datapart)
366 msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
369 sender = (opts.get('from') or ui.config('email', 'from') or
370 ui.config('patchbomb', 'from') or
371 prompt(ui, 'From', ui.username()))
373 # internal option used by pbranches
374 patches = opts.get('patches')
376 msgs = getpatchmsgs(patches, opts.get('patchnames'))
377 elif opts.get('bundle'):
378 msgs = getbundlemsgs(getbundle(dest))
380 msgs = getpatchmsgs(list(getpatches(revs)))
382 def getaddrs(opt, prpt, default = None):
383 addrs = opts.get(opt) or (ui.config('email', opt) or
384 ui.config('patchbomb', opt) or
385 prompt(ui, prpt, default)).split(',')
386 return [mail.addressencode(ui, a.strip(), _charsets, opts.get('test'))
387 for a in addrs if a.strip()]
389 to = getaddrs('to', 'To')
390 cc = getaddrs('cc', 'Cc', '')
392 bcc = opts.get('bcc') or (ui.config('email', 'bcc') or
393 ui.config('patchbomb', 'bcc') or '').split(',')
394 bcc = [mail.addressencode(ui, a.strip(), _charsets, opts.get('test'))
395 for a in bcc if a.strip()]
399 parent = opts.get('in_reply_to') or None
400 # angle brackets may be omitted, they're not semantically part of the msg-id
401 if parent is not None:
402 if not parent.startswith('<'):
403 parent = '<' + parent
404 if not parent.endswith('>'):
409 sender_addr = email.Utils.parseaddr(sender)[1]
410 sender = mail.addressencode(ui, sender, _charsets, opts.get('test'))
414 m['Message-Id'] = genmsgid(m['X-Mercurial-Node'])
416 m['Message-Id'] = genmsgid('patchbomb')
418 m['In-Reply-To'] = parent
419 m['References'] = parent
421 parent = m['Message-Id']
424 m['User-Agent'] = 'Mercurial-patchbomb/%s' % util.version()
425 m['Date'] = email.Utils.formatdate(start_time[0], localtime=True)
427 start_time = (start_time[0] + 1, start_time[1])
429 m['To'] = ', '.join(to)
431 m['Cc'] = ', '.join(cc)
433 m['Bcc'] = ', '.join(bcc)
435 ui.status(_('Displaying '), subj, ' ...\n')
437 if 'PAGER' in os.environ:
438 fp = util.popen(os.environ['PAGER'], 'w')
441 generator = email.Generator.Generator(fp, mangle_from_=False)
443 generator.flatten(m, 0)
445 except IOError, inst:
446 if inst.errno != errno.EPIPE:
450 elif opts.get('mbox'):
451 ui.status(_('Writing '), subj, ' ...\n')
452 fp = open(opts.get('mbox'), 'In-Reply-To' in m and 'ab+' or 'wb+')
453 generator = email.Generator.Generator(fp, mangle_from_=True)
454 date = time.ctime(start_time[0])
455 fp.write('From %s %s\n' % (sender_addr, date))
456 generator.flatten(m, 0)
461 sendmail = mail.connect(ui)
462 ui.status(_('Sending '), subj, ' ...\n')
463 # Exim does not remove the Bcc field
465 fp = cStringIO.StringIO()
466 generator = email.Generator.Generator(fp, mangle_from_=False)
467 generator.flatten(m, 0)
468 sendmail(sender, to + bcc + cc, fp.getvalue())
471 ('a', 'attach', None, _('send patches as attachments')),
472 ('i', 'inline', None, _('send patches as inline attachments')),
473 ('', 'bcc', [], _('email addresses of blind carbon copy recipients')),
474 ('c', 'cc', [], _('email addresses of copy recipients')),
475 ('d', 'diffstat', None, _('add diffstat output to messages')),
476 ('', 'date', '', _('use the given date as the sending date')),
477 ('', 'desc', '', _('use the given file as the series description')),
478 ('f', 'from', '', _('email address of sender')),
479 ('n', 'test', None, _('print messages that would be sent')),
481 _('write messages to mbox file instead of sending them')),
483 _('subject of first message (intro or single patch)')),
484 ('', 'in-reply-to', '',
485 _('message identifier to reply to')),
486 ('', 'flag', [], _('flags to add in subject prefixes')),
487 ('t', 'to', [], _('email addresses of recipients')),
494 [('g', 'git', None, _('use git extended diff format')),
495 ('', 'plain', None, _('omit hg patch header')),
496 ('o', 'outgoing', None,
497 _('send changes not found in the target repository')),
498 ('b', 'bundle', None,
499 _('send changes not in target as a binary bundle')),
500 ('', 'bundlename', 'bundle',
501 _('name of the bundle attachment file')),
502 ('r', 'rev', [], _('a revision to send')),
504 _('run even when remote repository is unrelated '
505 '(with -b/--bundle)')),
507 _('a base changeset to specify instead of a destination '
508 '(with -b/--bundle)')),
510 _('send an introduction email for a single patch')),
511 ] + emailopts + commands.remoteopts,
512 _('hg email [OPTION]... [DEST]...'))