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