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