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