##// END OF EJS Templates
patchbomb: mime-encode clean utf-8 patches (issue814)...
Christian Ebert -
r7192:f31ba106 default
parent child Browse files
Show More
@@ -1,473 +1,474
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 import email.MIMEMultipart, email.MIMEText, email.MIMEBase
65 import email.MIMEMultipart, 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 114 _charsets = mail._charsets(ui)
115 115
116 116 def prompt(prompt, default = None, rest = ': ', empty_ok = False):
117 117 if not ui.interactive:
118 118 return default
119 119 if default:
120 120 prompt += ' [%s]' % default
121 121 prompt += rest
122 122 while True:
123 123 r = ui.prompt(prompt, default=default)
124 124 if r:
125 125 return r
126 126 if default is not None:
127 127 return default
128 128 if empty_ok:
129 129 return r
130 130 ui.warn(_('Please enter a valid value.\n'))
131 131
132 132 def confirm(s, denial):
133 133 if not prompt(s, default = 'y', rest = '? ').lower().startswith('y'):
134 134 raise util.Abort(denial)
135 135
136 136 def cdiffstat(summary, patchlines):
137 137 s = patch.diffstat(patchlines)
138 138 if s:
139 139 if summary:
140 140 ui.write(summary, '\n')
141 141 ui.write(s, '\n')
142 142 confirm(_('Does the diffstat above look okay'),
143 143 _('diffstat rejected'))
144 144 elif s is None:
145 145 ui.warn(_('No diffstat information available.\n'))
146 146 s = ''
147 147 return s
148 148
149 149 def makepatch(patch, idx, total):
150 150 desc = []
151 151 node = None
152 152 body = ''
153 153 for line in patch:
154 154 if line.startswith('#'):
155 155 if line.startswith('# Node ID'):
156 156 node = line.split()[-1]
157 157 continue
158 158 if line.startswith('diff -r') or line.startswith('diff --git'):
159 159 break
160 160 desc.append(line)
161 161 if not node:
162 162 raise ValueError
163 163
164 164 if opts.get('attach'):
165 165 body = ('\n'.join(desc[1:]).strip() or
166 166 'Patch subject is complete summary.')
167 167 body += '\n\n\n'
168 168
169 169 if opts.get('plain'):
170 170 while patch and patch[0].startswith('# '):
171 171 patch.pop(0)
172 172 if patch:
173 173 patch.pop(0)
174 174 while patch and not patch[0].strip():
175 175 patch.pop(0)
176 176 if opts.get('diffstat'):
177 177 body += cdiffstat('\n'.join(desc), patch) + '\n\n'
178 178 if opts.get('attach') or opts.get('inline'):
179 179 msg = email.MIMEMultipart.MIMEMultipart()
180 180 if body:
181 181 msg.attach(mail.mimeencode(ui, body, _charsets,
182 182 opts.get('test')))
183 p = email.MIMEText.MIMEText('\n'.join(patch), 'x-patch')
183 p = mail.mimetextpatch('\n'.join(patch), 'x-patch',
184 opts.get('test'))
184 185 binnode = bin(node)
185 186 # if node is mq patch, it will have patch file name as tag
186 187 patchname = [t for t in repo.nodetags(binnode)
187 188 if t.endswith('.patch') or t.endswith('.diff')]
188 189 if patchname:
189 190 patchname = patchname[0]
190 191 elif total > 1:
191 192 patchname = cmdutil.make_filename(repo, '%b-%n.patch',
192 193 binnode, idx, total)
193 194 else:
194 195 patchname = cmdutil.make_filename(repo, '%b.patch', binnode)
195 196 disposition = 'inline'
196 197 if opts.get('attach'):
197 198 disposition = 'attachment'
198 199 p['Content-Disposition'] = disposition + '; filename=' + patchname
199 200 msg.attach(p)
200 201 else:
201 202 body += '\n'.join(patch)
202 msg = email.MIMEText.MIMEText(body)
203 msg = mail.mimetextpatch(body, display=opts.get('test'))
203 204
204 205 subj = desc[0].strip().rstrip('. ')
205 206 if total == 1:
206 207 subj = '[PATCH] ' + (opts.get('subject') or subj)
207 208 else:
208 209 tlen = len(str(total))
209 210 subj = '[PATCH %0*d of %d] %s' % (tlen, idx, total, subj)
210 211 msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
211 212 msg['X-Mercurial-Node'] = node
212 213 return msg, subj
213 214
214 215 def outgoing(dest, revs):
215 216 '''Return the revisions present locally but not in dest'''
216 217 dest = ui.expandpath(dest or 'default-push', dest or 'default')
217 218 revs = [repo.lookup(rev) for rev in revs]
218 219 other = hg.repository(ui, dest)
219 220 ui.status(_('comparing with %s\n') % dest)
220 221 o = repo.findoutgoing(other)
221 222 if not o:
222 223 ui.status(_("no changes found\n"))
223 224 return []
224 225 o = repo.changelog.nodesbetween(o, revs or None)[0]
225 226 return [str(repo.changelog.rev(r)) for r in o]
226 227
227 228 def getbundle(dest):
228 229 tmpdir = tempfile.mkdtemp(prefix='hg-email-bundle-')
229 230 tmpfn = os.path.join(tmpdir, 'bundle')
230 231 try:
231 232 commands.bundle(ui, repo, tmpfn, dest, **opts)
232 233 return open(tmpfn, 'rb').read()
233 234 finally:
234 235 try:
235 236 os.unlink(tmpfn)
236 237 except:
237 238 pass
238 239 os.rmdir(tmpdir)
239 240
240 241 if not (opts.get('test') or opts.get('mbox')):
241 242 # really sending
242 243 mail.validateconfig(ui)
243 244
244 245 if not (revs or opts.get('rev')
245 246 or opts.get('outgoing') or opts.get('bundle')):
246 247 raise util.Abort(_('specify at least one changeset with -r or -o'))
247 248
248 249 cmdutil.setremoteconfig(ui, opts)
249 250 if opts.get('outgoing') and opts.get('bundle'):
250 251 raise util.Abort(_("--outgoing mode always on with --bundle;"
251 252 " do not re-specify --outgoing"))
252 253
253 254 if opts.get('outgoing') or opts.get('bundle'):
254 255 if len(revs) > 1:
255 256 raise util.Abort(_("too many destinations"))
256 257 dest = revs and revs[0] or None
257 258 revs = []
258 259
259 260 if opts.get('rev'):
260 261 if revs:
261 262 raise util.Abort(_('use only one form to specify the revision'))
262 263 revs = opts.get('rev')
263 264
264 265 if opts.get('outgoing'):
265 266 revs = outgoing(dest, opts.get('rev'))
266 267 if opts.get('bundle'):
267 268 opts['revs'] = revs
268 269
269 270 # start
270 271 if opts.get('date'):
271 272 start_time = util.parsedate(opts.get('date'))
272 273 else:
273 274 start_time = util.makedate()
274 275
275 276 def genmsgid(id):
276 277 return '<%s.%s@%s>' % (id[:20], int(start_time[0]), socket.getfqdn())
277 278
278 279 def getdescription(body, sender):
279 280 if opts.get('desc'):
280 281 body = open(opts.get('desc')).read()
281 282 else:
282 283 ui.write(_('\nWrite the introductory message for the '
283 284 'patch series.\n\n'))
284 285 body = ui.edit(body, sender)
285 286 return body
286 287
287 288 def getexportmsgs():
288 289 patches = []
289 290
290 291 class exportee:
291 292 def __init__(self, container):
292 293 self.lines = []
293 294 self.container = container
294 295 self.name = 'email'
295 296
296 297 def write(self, data):
297 298 self.lines.append(data)
298 299
299 300 def close(self):
300 301 self.container.append(''.join(self.lines).split('\n'))
301 302 self.lines = []
302 303
303 304 commands.export(ui, repo, *revs, **{'output': exportee(patches),
304 305 'switch_parent': False,
305 306 'text': None,
306 307 'git': opts.get('git')})
307 308
308 309 jumbo = []
309 310 msgs = []
310 311
311 312 ui.write(_('This patch series consists of %d patches.\n\n')
312 313 % len(patches))
313 314
314 315 for p, i in zip(patches, xrange(len(patches))):
315 316 jumbo.extend(p)
316 317 msgs.append(makepatch(p, i + 1, len(patches)))
317 318
318 319 if len(patches) > 1:
319 320 tlen = len(str(len(patches)))
320 321
321 322 subj = '[PATCH %0*d of %d] %s' % (
322 323 tlen, 0, len(patches),
323 324 opts.get('subject') or
324 325 prompt('Subject:',
325 326 rest=' [PATCH %0*d of %d] ' % (tlen, 0, len(patches))))
326 327
327 328 body = ''
328 329 if opts.get('diffstat'):
329 330 d = cdiffstat(_('Final summary:\n'), jumbo)
330 331 if d:
331 332 body = '\n' + d
332 333
333 334 body = getdescription(body, sender)
334 335 msg = mail.mimeencode(ui, body, _charsets, opts.get('test'))
335 336 msg['Subject'] = mail.headencode(ui, subj, _charsets,
336 337 opts.get('test'))
337 338
338 339 msgs.insert(0, (msg, subj))
339 340 return msgs
340 341
341 342 def getbundlemsgs(bundle):
342 343 subj = (opts.get('subject')
343 344 or prompt('Subject:', default='A bundle for your repository'))
344 345
345 346 body = getdescription('', sender)
346 347 msg = email.MIMEMultipart.MIMEMultipart()
347 348 if body:
348 349 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test')))
349 350 datapart = email.MIMEBase.MIMEBase('application', 'x-mercurial-bundle')
350 351 datapart.set_payload(bundle)
351 352 datapart.add_header('Content-Disposition', 'attachment',
352 353 filename='bundle.hg')
353 354 email.Encoders.encode_base64(datapart)
354 355 msg.attach(datapart)
355 356 msg['Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
356 357 return [(msg, subj)]
357 358
358 359 sender = (opts.get('from') or ui.config('email', 'from') or
359 360 ui.config('patchbomb', 'from') or
360 361 prompt('From', ui.username()))
361 362
362 363 if opts.get('bundle'):
363 364 msgs = getbundlemsgs(getbundle(dest))
364 365 else:
365 366 msgs = getexportmsgs()
366 367
367 368 def getaddrs(opt, prpt, default = None):
368 369 addrs = opts.get(opt) or (ui.config('email', opt) or
369 370 ui.config('patchbomb', opt) or
370 371 prompt(prpt, default = default)).split(',')
371 372 return [mail.addressencode(ui, a.strip(), _charsets, opts.get('test'))
372 373 for a in addrs if a.strip()]
373 374
374 375 to = getaddrs('to', 'To')
375 376 cc = getaddrs('cc', 'Cc', '')
376 377
377 378 bcc = opts.get('bcc') or (ui.config('email', 'bcc') or
378 379 ui.config('patchbomb', 'bcc') or '').split(',')
379 380 bcc = [mail.addressencode(ui, a.strip(), _charsets, opts.get('test'))
380 381 for a in bcc if a.strip()]
381 382
382 383 ui.write('\n')
383 384
384 385 parent = None
385 386
386 387 sender_addr = email.Utils.parseaddr(sender)[1]
387 388 sender = mail.addressencode(ui, sender, _charsets, opts.get('test'))
388 389 sendmail = None
389 390 for m, subj in msgs:
390 391 try:
391 392 m['Message-Id'] = genmsgid(m['X-Mercurial-Node'])
392 393 except TypeError:
393 394 m['Message-Id'] = genmsgid('patchbomb')
394 395 if parent:
395 396 m['In-Reply-To'] = parent
396 397 else:
397 398 parent = m['Message-Id']
398 399 m['Date'] = util.datestr(start_time, "%a, %d %b %Y %H:%M:%S %1%2")
399 400
400 401 start_time = (start_time[0] + 1, start_time[1])
401 402 m['From'] = sender
402 403 m['To'] = ', '.join(to)
403 404 if cc:
404 405 m['Cc'] = ', '.join(cc)
405 406 if bcc:
406 407 m['Bcc'] = ', '.join(bcc)
407 408 if opts.get('test'):
408 409 ui.status(_('Displaying '), subj, ' ...\n')
409 410 ui.flush()
410 411 if 'PAGER' in os.environ:
411 412 fp = util.popen(os.environ['PAGER'], 'w')
412 413 else:
413 414 fp = ui
414 415 generator = email.Generator.Generator(fp, mangle_from_=False)
415 416 try:
416 417 generator.flatten(m, 0)
417 418 fp.write('\n')
418 419 except IOError, inst:
419 420 if inst.errno != errno.EPIPE:
420 421 raise
421 422 if fp is not ui:
422 423 fp.close()
423 424 elif opts.get('mbox'):
424 425 ui.status(_('Writing '), subj, ' ...\n')
425 426 fp = open(opts.get('mbox'), 'In-Reply-To' in m and 'ab+' or 'wb+')
426 427 generator = email.Generator.Generator(fp, mangle_from_=True)
427 428 date = util.datestr(start_time, '%a %b %d %H:%M:%S %Y')
428 429 fp.write('From %s %s\n' % (sender_addr, date))
429 430 generator.flatten(m, 0)
430 431 fp.write('\n\n')
431 432 fp.close()
432 433 else:
433 434 if not sendmail:
434 435 sendmail = mail.connect(ui)
435 436 ui.status(_('Sending '), subj, ' ...\n')
436 437 # Exim does not remove the Bcc field
437 438 del m['Bcc']
438 439 fp = cStringIO.StringIO()
439 440 generator = email.Generator.Generator(fp, mangle_from_=False)
440 441 generator.flatten(m, 0)
441 442 sendmail(sender, to + bcc + cc, fp.getvalue())
442 443
443 444 cmdtable = {
444 445 "email":
445 446 (patchbomb,
446 447 [('a', 'attach', None, _('send patches as attachments')),
447 448 ('i', 'inline', None, _('send patches as inline attachments')),
448 449 ('', 'bcc', [], _('email addresses of blind copy recipients')),
449 450 ('c', 'cc', [], _('email addresses of copy recipients')),
450 451 ('d', 'diffstat', None, _('add diffstat output to messages')),
451 452 ('', 'date', '', _('use the given date as the sending date')),
452 453 ('', 'desc', '', _('use the given file as the series description')),
453 454 ('g', 'git', None, _('use git extended diff format')),
454 455 ('f', 'from', '', _('email address of sender')),
455 456 ('', 'plain', None, _('omit hg patch header')),
456 457 ('n', 'test', None, _('print messages that would be sent')),
457 458 ('m', 'mbox', '',
458 459 _('write messages to mbox file instead of sending them')),
459 460 ('o', 'outgoing', None,
460 461 _('send changes not found in the target repository')),
461 462 ('b', 'bundle', None,
462 463 _('send changes not in target as a binary bundle')),
463 464 ('r', 'rev', [], _('a revision to send')),
464 465 ('s', 'subject', '',
465 466 _('subject of first message (intro or single patch)')),
466 467 ('t', 'to', [], _('email addresses of recipients')),
467 468 ('', 'force', None,
468 469 _('run even when remote repository is unrelated (with -b)')),
469 470 ('', 'base', [],
470 471 _('a base changeset to specify instead of a destination (with -b)')),
471 472 ] + commands.remoteopts,
472 473 _('hg email [OPTION]... [DEST]...'))
473 474 }
General Comments 0
You need to be logged in to leave comments. Login now