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