##// END OF EJS Templates
patchbomb: respect HGPLAIN when piping --test output to PAGER...
Yuya Nishihara -
r11183:b25464e9 default
parent child Browse files
Show More
@@ -1,533 +1,533
1 1 # patchbomb.py - sending Mercurial changesets as patch emails
2 2 #
3 3 # Copyright 2005-2009 Matt Mackall <mpm@selenic.com> and others
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 '''command to send changesets as (a series of) patch emails
9 9
10 10 The series is started off with a "[PATCH 0 of N]" introduction, which
11 11 describes the series as a whole.
12 12
13 13 Each patch email has a Subject line of "[PATCH M of N] ...", using the
14 14 first line of the changeset description as the subject text. The
15 15 message contains two or three body parts:
16 16
17 17 - The changeset description.
18 18 - [Optional] The result of running diffstat on the patch.
19 19 - The patch itself, as generated by :hg:`export`.
20 20
21 21 Each message refers to the first in the series using the In-Reply-To
22 22 and References headers, so they will show up as a sequence in threaded
23 23 mail and news readers, and in mail archives.
24 24
25 25 With the -d/--diffstat option, you will be prompted for each changeset
26 26 with a diffstat summary and the changeset summary, so you can be sure
27 27 you are sending the right changes.
28 28
29 29 To configure other defaults, add a section like this to your hgrc
30 30 file::
31 31
32 32 [email]
33 33 from = My Name <my@email>
34 34 to = recipient1, recipient2, ...
35 35 cc = cc1, cc2, ...
36 36 bcc = bcc1, bcc2, ...
37 37 reply-to = address1, address2, ...
38 38
39 39 Use ``[patchbomb]`` as configuration section name if you need to
40 40 override global ``[email]`` address settings.
41 41
42 42 Then you can use the :hg:`email` command to mail a series of
43 43 changesets as a patchbomb.
44 44
45 45 To avoid sending patches prematurely, it is a good idea to first run
46 46 the :hg:`email` command with the "-n" option (test only). You will be
47 47 prompted for an email recipient address, a subject and an introductory
48 48 message describing the patches of your patchbomb. Then when all is
49 49 done, patchbomb messages are displayed. If the PAGER environment
50 50 variable is set, your pager will be fired up once for each patchbomb
51 51 message, so you can verify everything is alright.
52 52
53 53 The -m/--mbox option is also very useful. Instead of previewing each
54 54 patchbomb message in a pager or sending the messages directly, it will
55 55 create a UNIX mailbox file with the patch emails. This mailbox file
56 56 can be previewed with any mail user agent which supports UNIX mbox
57 57 files, e.g. with mutt::
58 58
59 59 % mutt -R -f mbox
60 60
61 61 When you are previewing the patchbomb messages, you can use ``formail``
62 62 (a utility that is commonly installed as part of the procmail
63 63 package), to send each message out::
64 64
65 65 % formail -s sendmail -bm -t < mbox
66 66
67 67 That should be all. Now your patchbomb is on its way out.
68 68
69 69 You can also either configure the method option in the email section
70 70 to be a sendmail compatible mailer or fill out the [smtp] section so
71 71 that the patchbomb extension can automatically send patchbombs
72 72 directly from the commandline. See the [email] and [smtp] sections in
73 73 hgrc(5) for details.
74 74 '''
75 75
76 76 import os, errno, socket, tempfile, cStringIO, time
77 77 import email.MIMEMultipart, email.MIMEBase
78 78 import email.Utils, email.Encoders, email.Generator
79 79 from mercurial import cmdutil, commands, hg, mail, patch, util
80 80 from mercurial.i18n import _
81 81 from mercurial.node import bin
82 82
83 83 def prompt(ui, prompt, default=None, rest=':'):
84 84 if not ui.interactive():
85 85 if default is not None:
86 86 return default
87 87 raise util.Abort(_("%s Please enter a valid value" % (prompt + rest)))
88 88 if default:
89 89 prompt += ' [%s]' % default
90 90 prompt += rest
91 91 while True:
92 92 r = ui.prompt(prompt, default=default)
93 93 if r:
94 94 return r
95 95 if default is not None:
96 96 return default
97 97 ui.warn(_('Please enter a valid value.\n'))
98 98
99 99 def cdiffstat(ui, summary, patchlines):
100 100 s = patch.diffstat(patchlines)
101 101 if summary:
102 102 ui.write(summary, '\n')
103 103 ui.write(s, '\n')
104 104 ans = prompt(ui, _('does the diffstat above look okay?'), 'y')
105 105 if not ans.lower().startswith('y'):
106 106 raise util.Abort(_('diffstat rejected'))
107 107 return s
108 108
109 109 def introneeded(opts, number):
110 110 '''is an introductory message required?'''
111 111 return number > 1 or opts.get('intro') or opts.get('desc')
112 112
113 113 def makepatch(ui, repo, patch, opts, _charsets, idx, total, patchname=None):
114 114
115 115 desc = []
116 116 node = None
117 117 body = ''
118 118
119 119 for line in patch:
120 120 if line.startswith('#'):
121 121 if line.startswith('# Node ID'):
122 122 node = line.split()[-1]
123 123 continue
124 124 if line.startswith('diff -r') or line.startswith('diff --git'):
125 125 break
126 126 desc.append(line)
127 127
128 128 if not patchname and not node:
129 129 raise ValueError
130 130
131 131 if opts.get('attach'):
132 132 body = ('\n'.join(desc[1:]).strip() or
133 133 'Patch subject is complete summary.')
134 134 body += '\n\n\n'
135 135
136 136 if opts.get('plain'):
137 137 while patch and patch[0].startswith('# '):
138 138 patch.pop(0)
139 139 if patch:
140 140 patch.pop(0)
141 141 while patch and not patch[0].strip():
142 142 patch.pop(0)
143 143
144 144 if opts.get('diffstat'):
145 145 body += cdiffstat(ui, '\n'.join(desc), patch) + '\n\n'
146 146
147 147 if opts.get('attach') or opts.get('inline'):
148 148 msg = email.MIMEMultipart.MIMEMultipart()
149 149 if body:
150 150 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test')))
151 151 p = mail.mimetextpatch('\n'.join(patch), 'x-patch', opts.get('test'))
152 152 binnode = bin(node)
153 153 # if node is mq patch, it will have the patch file's name as a tag
154 154 if not patchname:
155 155 patchtags = [t for t in repo.nodetags(binnode)
156 156 if t.endswith('.patch') or t.endswith('.diff')]
157 157 if patchtags:
158 158 patchname = patchtags[0]
159 159 elif total > 1:
160 160 patchname = cmdutil.make_filename(repo, '%b-%n.patch',
161 161 binnode, seqno=idx, total=total)
162 162 else:
163 163 patchname = cmdutil.make_filename(repo, '%b.patch', binnode)
164 164 disposition = 'inline'
165 165 if opts.get('attach'):
166 166 disposition = 'attachment'
167 167 p['Content-Disposition'] = disposition + '; filename=' + patchname
168 168 msg.attach(p)
169 169 else:
170 170 body += '\n'.join(patch)
171 171 msg = mail.mimetextpatch(body, display=opts.get('test'))
172 172
173 173 flag = ' '.join(opts.get('flag'))
174 174 if flag:
175 175 flag = ' ' + flag
176 176
177 177 subj = desc[0].strip().rstrip('. ')
178 178 if not introneeded(opts, total):
179 179 subj = '[PATCH%s] %s' % (flag, opts.get('subject') or subj)
180 180 else:
181 181 tlen = len(str(total))
182 182 subj = '[PATCH %0*d of %d%s] %s' % (tlen, idx, total, flag, subj)
183 183 msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
184 184 msg['X-Mercurial-Node'] = node
185 185 return msg, subj
186 186
187 187 def patchbomb(ui, repo, *revs, **opts):
188 188 '''send changesets by email
189 189
190 190 By default, diffs are sent in the format generated by hg export,
191 191 one per message. The series starts with a "[PATCH 0 of N]"
192 192 introduction, which describes the series as a whole.
193 193
194 194 Each patch email has a Subject line of "[PATCH M of N] ...", using
195 195 the first line of the changeset description as the subject text.
196 196 The message contains two or three parts. First, the changeset
197 197 description. Next, (optionally) if the diffstat program is
198 198 installed and -d/--diffstat is used, the result of running
199 199 diffstat on the patch. Finally, the patch itself, as generated by
200 200 :hg:`export`.
201 201
202 202 By default the patch is included as text in the email body for
203 203 easy reviewing. Using the -a/--attach option will instead create
204 204 an attachment for the patch. With -i/--inline an inline attachment
205 205 will be created.
206 206
207 207 With -o/--outgoing, emails will be generated for patches not found
208 208 in the destination repository (or only those which are ancestors
209 209 of the specified revisions if any are provided)
210 210
211 211 With -b/--bundle, changesets are selected as for --outgoing, but a
212 212 single email containing a binary Mercurial bundle as an attachment
213 213 will be sent.
214 214
215 215 Examples::
216 216
217 217 hg email -r 3000 # send patch 3000 only
218 218 hg email -r 3000 -r 3001 # send patches 3000 and 3001
219 219 hg email -r 3000:3005 # send patches 3000 through 3005
220 220 hg email 3000 # send patch 3000 (deprecated)
221 221
222 222 hg email -o # send all patches not in default
223 223 hg email -o DEST # send all patches not in DEST
224 224 hg email -o -r 3000 # send all ancestors of 3000 not in default
225 225 hg email -o -r 3000 DEST # send all ancestors of 3000 not in DEST
226 226
227 227 hg email -b # send bundle of all patches not in default
228 228 hg email -b DEST # send bundle of all patches not in DEST
229 229 hg email -b -r 3000 # bundle of all ancestors of 3000 not in default
230 230 hg email -b -r 3000 DEST # bundle of all ancestors of 3000 not in DEST
231 231
232 232 Before using this command, you will need to enable email in your
233 233 hgrc. See the [email] section in hgrc(5) for details.
234 234 '''
235 235
236 236 _charsets = mail._charsets(ui)
237 237
238 238 def outgoing(dest, revs):
239 239 '''Return the revisions present locally but not in dest'''
240 240 dest = ui.expandpath(dest or 'default-push', dest or 'default')
241 241 dest, branches = hg.parseurl(dest)
242 242 revs, checkout = hg.addbranchrevs(repo, repo, branches, revs)
243 243 if revs:
244 244 revs = [repo.lookup(rev) for rev in revs]
245 245 other = hg.repository(cmdutil.remoteui(repo, opts), dest)
246 246 ui.status(_('comparing with %s\n') % dest)
247 247 o = repo.findoutgoing(other)
248 248 if not o:
249 249 ui.status(_("no changes found\n"))
250 250 return []
251 251 o = repo.changelog.nodesbetween(o, revs)[0]
252 252 return [str(repo.changelog.rev(r)) for r in o]
253 253
254 254 def getpatches(revs):
255 255 for r in cmdutil.revrange(repo, revs):
256 256 output = cStringIO.StringIO()
257 257 cmdutil.export(repo, [r], fp=output,
258 258 opts=patch.diffopts(ui, opts))
259 259 yield output.getvalue().split('\n')
260 260
261 261 def getbundle(dest):
262 262 tmpdir = tempfile.mkdtemp(prefix='hg-email-bundle-')
263 263 tmpfn = os.path.join(tmpdir, 'bundle')
264 264 try:
265 265 commands.bundle(ui, repo, tmpfn, dest, **opts)
266 266 return open(tmpfn, 'rb').read()
267 267 finally:
268 268 try:
269 269 os.unlink(tmpfn)
270 270 except:
271 271 pass
272 272 os.rmdir(tmpdir)
273 273
274 274 if not (opts.get('test') or opts.get('mbox')):
275 275 # really sending
276 276 mail.validateconfig(ui)
277 277
278 278 if not (revs or opts.get('rev')
279 279 or opts.get('outgoing') or opts.get('bundle')
280 280 or opts.get('patches')):
281 281 raise util.Abort(_('specify at least one changeset with -r or -o'))
282 282
283 283 if opts.get('outgoing') and opts.get('bundle'):
284 284 raise util.Abort(_("--outgoing mode always on with --bundle;"
285 285 " do not re-specify --outgoing"))
286 286
287 287 if opts.get('outgoing') or opts.get('bundle'):
288 288 if len(revs) > 1:
289 289 raise util.Abort(_("too many destinations"))
290 290 dest = revs and revs[0] or None
291 291 revs = []
292 292
293 293 if opts.get('rev'):
294 294 if revs:
295 295 raise util.Abort(_('use only one form to specify the revision'))
296 296 revs = opts.get('rev')
297 297
298 298 if opts.get('outgoing'):
299 299 revs = outgoing(dest, opts.get('rev'))
300 300 if opts.get('bundle'):
301 301 opts['revs'] = revs
302 302
303 303 # start
304 304 if opts.get('date'):
305 305 start_time = util.parsedate(opts.get('date'))
306 306 else:
307 307 start_time = util.makedate()
308 308
309 309 def genmsgid(id):
310 310 return '<%s.%s@%s>' % (id[:20], int(start_time[0]), socket.getfqdn())
311 311
312 312 def getdescription(body, sender):
313 313 if opts.get('desc'):
314 314 body = open(opts.get('desc')).read()
315 315 else:
316 316 ui.write(_('\nWrite the introductory message for the '
317 317 'patch series.\n\n'))
318 318 body = ui.edit(body, sender)
319 319 return body
320 320
321 321 def getpatchmsgs(patches, patchnames=None):
322 322 jumbo = []
323 323 msgs = []
324 324
325 325 ui.write(_('This patch series consists of %d patches.\n\n')
326 326 % len(patches))
327 327
328 328 name = None
329 329 for i, p in enumerate(patches):
330 330 jumbo.extend(p)
331 331 if patchnames:
332 332 name = patchnames[i]
333 333 msg = makepatch(ui, repo, p, opts, _charsets, i + 1,
334 334 len(patches), name)
335 335 msgs.append(msg)
336 336
337 337 if introneeded(opts, len(patches)):
338 338 tlen = len(str(len(patches)))
339 339
340 340 flag = ' '.join(opts.get('flag'))
341 341 if flag:
342 342 subj = '[PATCH %0*d of %d %s]' % (tlen, 0, len(patches), flag)
343 343 else:
344 344 subj = '[PATCH %0*d of %d]' % (tlen, 0, len(patches))
345 345 subj += ' ' + (opts.get('subject') or
346 346 prompt(ui, 'Subject: ', rest=subj))
347 347
348 348 body = ''
349 349 if opts.get('diffstat'):
350 350 d = cdiffstat(ui, _('Final summary:\n'), jumbo)
351 351 if d:
352 352 body = '\n' + d
353 353
354 354 body = getdescription(body, sender)
355 355 msg = mail.mimeencode(ui, body, _charsets, opts.get('test'))
356 356 msg['Subject'] = mail.headencode(ui, subj, _charsets,
357 357 opts.get('test'))
358 358
359 359 msgs.insert(0, (msg, subj))
360 360 return msgs
361 361
362 362 def getbundlemsgs(bundle):
363 363 subj = (opts.get('subject')
364 364 or prompt(ui, 'Subject:', 'A bundle for your repository'))
365 365
366 366 body = getdescription('', sender)
367 367 msg = email.MIMEMultipart.MIMEMultipart()
368 368 if body:
369 369 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test')))
370 370 datapart = email.MIMEBase.MIMEBase('application', 'x-mercurial-bundle')
371 371 datapart.set_payload(bundle)
372 372 bundlename = '%s.hg' % opts.get('bundlename', 'bundle')
373 373 datapart.add_header('Content-Disposition', 'attachment',
374 374 filename=bundlename)
375 375 email.Encoders.encode_base64(datapart)
376 376 msg.attach(datapart)
377 377 msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
378 378 return [(msg, subj)]
379 379
380 380 sender = (opts.get('from') or ui.config('email', 'from') or
381 381 ui.config('patchbomb', 'from') or
382 382 prompt(ui, 'From', ui.username()))
383 383
384 384 # internal option used by pbranches
385 385 patches = opts.get('patches')
386 386 if patches:
387 387 msgs = getpatchmsgs(patches, opts.get('patchnames'))
388 388 elif opts.get('bundle'):
389 389 msgs = getbundlemsgs(getbundle(dest))
390 390 else:
391 391 msgs = getpatchmsgs(list(getpatches(revs)))
392 392
393 393 def getaddrs(opt, prpt=None, default=None):
394 394 addrs = opts.get(opt.replace('-', '_'))
395 395 if addrs:
396 396 return mail.addrlistencode(ui, addrs, _charsets,
397 397 opts.get('test'))
398 398
399 399 addrs = (ui.config('email', opt) or
400 400 ui.config('patchbomb', opt) or '')
401 401 if not addrs and prpt:
402 402 addrs = prompt(ui, prpt, default)
403 403
404 404 return mail.addrlistencode(ui, [addrs], _charsets, opts.get('test'))
405 405
406 406 to = getaddrs('to', 'To')
407 407 cc = getaddrs('cc', 'Cc', '')
408 408 bcc = getaddrs('bcc')
409 409 replyto = getaddrs('reply-to')
410 410
411 411 ui.write('\n')
412 412
413 413 parent = opts.get('in_reply_to') or None
414 414 # angle brackets may be omitted, they're not semantically part of the msg-id
415 415 if parent is not None:
416 416 if not parent.startswith('<'):
417 417 parent = '<' + parent
418 418 if not parent.endswith('>'):
419 419 parent += '>'
420 420
421 421 first = True
422 422
423 423 sender_addr = email.Utils.parseaddr(sender)[1]
424 424 sender = mail.addressencode(ui, sender, _charsets, opts.get('test'))
425 425 sendmail = None
426 426 for m, subj in msgs:
427 427 try:
428 428 m['Message-Id'] = genmsgid(m['X-Mercurial-Node'])
429 429 except TypeError:
430 430 m['Message-Id'] = genmsgid('patchbomb')
431 431 if parent:
432 432 m['In-Reply-To'] = parent
433 433 m['References'] = parent
434 434 if first:
435 435 parent = m['Message-Id']
436 436 first = False
437 437
438 438 m['User-Agent'] = 'Mercurial-patchbomb/%s' % util.version()
439 439 m['Date'] = email.Utils.formatdate(start_time[0], localtime=True)
440 440
441 441 start_time = (start_time[0] + 1, start_time[1])
442 442 m['From'] = sender
443 443 m['To'] = ', '.join(to)
444 444 if cc:
445 445 m['Cc'] = ', '.join(cc)
446 446 if bcc:
447 447 m['Bcc'] = ', '.join(bcc)
448 448 if replyto:
449 449 m['Reply-To'] = ', '.join(replyto)
450 450 if opts.get('test'):
451 451 ui.status(_('Displaying '), subj, ' ...\n')
452 452 ui.flush()
453 if 'PAGER' in os.environ:
453 if 'PAGER' in os.environ and not ui.plain():
454 454 fp = util.popen(os.environ['PAGER'], 'w')
455 455 else:
456 456 fp = ui
457 457 generator = email.Generator.Generator(fp, mangle_from_=False)
458 458 try:
459 459 generator.flatten(m, 0)
460 460 fp.write('\n')
461 461 except IOError, inst:
462 462 if inst.errno != errno.EPIPE:
463 463 raise
464 464 if fp is not ui:
465 465 fp.close()
466 466 elif opts.get('mbox'):
467 467 ui.status(_('Writing '), subj, ' ...\n')
468 468 fp = open(opts.get('mbox'), 'In-Reply-To' in m and 'ab+' or 'wb+')
469 469 generator = email.Generator.Generator(fp, mangle_from_=True)
470 470 # Should be time.asctime(), but Windows prints 2-characters day
471 471 # of month instead of one. Make them print the same thing.
472 472 date = time.strftime('%a %b %d %H:%M:%S %Y',
473 473 time.localtime(start_time[0]))
474 474 fp.write('From %s %s\n' % (sender_addr, date))
475 475 generator.flatten(m, 0)
476 476 fp.write('\n\n')
477 477 fp.close()
478 478 else:
479 479 if not sendmail:
480 480 sendmail = mail.connect(ui)
481 481 ui.status(_('Sending '), subj, ' ...\n')
482 482 # Exim does not remove the Bcc field
483 483 del m['Bcc']
484 484 fp = cStringIO.StringIO()
485 485 generator = email.Generator.Generator(fp, mangle_from_=False)
486 486 generator.flatten(m, 0)
487 487 sendmail(sender, to + bcc + cc, fp.getvalue())
488 488
489 489 emailopts = [
490 490 ('a', 'attach', None, _('send patches as attachments')),
491 491 ('i', 'inline', None, _('send patches as inline attachments')),
492 492 ('', 'bcc', [], _('email addresses of blind carbon copy recipients')),
493 493 ('c', 'cc', [], _('email addresses of copy recipients')),
494 494 ('d', 'diffstat', None, _('add diffstat output to messages')),
495 495 ('', 'date', '', _('use the given date as the sending date')),
496 496 ('', 'desc', '', _('use the given file as the series description')),
497 497 ('f', 'from', '', _('email address of sender')),
498 498 ('n', 'test', None, _('print messages that would be sent')),
499 499 ('m', 'mbox', '',
500 500 _('write messages to mbox file instead of sending them')),
501 501 ('', 'reply-to', [], _('email addresses replies should be sent to')),
502 502 ('s', 'subject', '',
503 503 _('subject of first message (intro or single patch)')),
504 504 ('', 'in-reply-to', '',
505 505 _('message identifier to reply to')),
506 506 ('', 'flag', [], _('flags to add in subject prefixes')),
507 507 ('t', 'to', [], _('email addresses of recipients')),
508 508 ]
509 509
510 510
511 511 cmdtable = {
512 512 "email":
513 513 (patchbomb,
514 514 [('g', 'git', None, _('use git extended diff format')),
515 515 ('', 'plain', None, _('omit hg patch header')),
516 516 ('o', 'outgoing', None,
517 517 _('send changes not found in the target repository')),
518 518 ('b', 'bundle', None,
519 519 _('send changes not in target as a binary bundle')),
520 520 ('', 'bundlename', 'bundle',
521 521 _('name of the bundle attachment file')),
522 522 ('r', 'rev', [], _('a revision to send')),
523 523 ('', 'force', None,
524 524 _('run even when remote repository is unrelated '
525 525 '(with -b/--bundle)')),
526 526 ('', 'base', [],
527 527 _('a base changeset to specify instead of a destination '
528 528 '(with -b/--bundle)')),
529 529 ('', 'intro', None,
530 530 _('send an introduction email for a single patch')),
531 531 ] + emailopts + commands.remoteopts,
532 532 _('hg email [OPTION]... [DEST]...'))
533 533 }
General Comments 0
You need to be logged in to leave comments. Login now