##// END OF EJS Templates
Add --outgoing option to patchbomb
Brendan Cully -
r4262:f51317e2 default
parent child Browse files
Show More
@@ -1,305 +1,343 b''
1 # Command for sending a collection of Mercurial changesets as a series
1 # Command for sending a collection of Mercurial changesets as a series
2 # of patch emails.
2 # of patch emails.
3 #
3 #
4 # The series is started off with a "[PATCH 0 of N]" introduction,
4 # The series is started off with a "[PATCH 0 of N]" introduction,
5 # which describes the series as a whole.
5 # which describes the series as a whole.
6 #
6 #
7 # Each patch email has a Subject line of "[PATCH M of N] ...", using
7 # Each patch email has a Subject line of "[PATCH M of N] ...", using
8 # the first line of the changeset description as the subject text.
8 # the first line of the changeset description as the subject text.
9 # The message contains two or three body parts:
9 # The message contains two or three body parts:
10 #
10 #
11 # The remainder of the changeset description.
11 # The remainder of the changeset description.
12 #
12 #
13 # [Optional] If the diffstat program is installed, the result of
13 # [Optional] If the diffstat program is installed, the result of
14 # running diffstat on the patch.
14 # running diffstat on the patch.
15 #
15 #
16 # The patch itself, as generated by "hg export".
16 # The patch itself, as generated by "hg export".
17 #
17 #
18 # Each message refers to all of its predecessors using the In-Reply-To
18 # Each message refers to all of its predecessors using the In-Reply-To
19 # and References headers, so they will show up as a sequence in
19 # and References headers, so they will show up as a sequence in
20 # threaded mail and news readers, and in mail archives.
20 # threaded mail and news readers, and in mail archives.
21 #
21 #
22 # For each changeset, you will be prompted with a diffstat summary and
22 # For each changeset, you will be prompted with a diffstat summary and
23 # the changeset summary, so you can be sure you are sending the right
23 # the changeset summary, so you can be sure you are sending the right
24 # changes.
24 # changes.
25 #
25 #
26 # To enable this extension:
26 # To enable this extension:
27 #
27 #
28 # [extensions]
28 # [extensions]
29 # hgext.patchbomb =
29 # hgext.patchbomb =
30 #
30 #
31 # To configure other defaults, add a section like this to your hgrc
31 # To configure other defaults, add a section like this to your hgrc
32 # file:
32 # file:
33 #
33 #
34 # [email]
34 # [email]
35 # from = My Name <my@email>
35 # from = My Name <my@email>
36 # to = recipient1, recipient2, ...
36 # to = recipient1, recipient2, ...
37 # cc = cc1, cc2, ...
37 # cc = cc1, cc2, ...
38 # bcc = bcc1, bcc2, ...
38 # bcc = bcc1, bcc2, ...
39 #
39 #
40 # Then you can use the "hg email" command to mail a series of changesets
40 # Then you can use the "hg email" command to mail a series of changesets
41 # as a patchbomb.
41 # as a patchbomb.
42 #
42 #
43 # To avoid sending patches prematurely, it is a good idea to first run
43 # To avoid sending patches prematurely, it is a good idea to first run
44 # the "email" command with the "-n" option (test only). You will be
44 # the "email" command with the "-n" option (test only). You will be
45 # prompted for an email recipient address, a subject an an introductory
45 # prompted for an email recipient address, a subject an an introductory
46 # message describing the patches of your patchbomb. Then when all is
46 # message describing the patches of your patchbomb. Then when all is
47 # done, your pager will be fired up once for each patchbomb message, so
47 # done, your pager will be fired up once for each patchbomb message, so
48 # you can verify everything is alright.
48 # you can verify everything is alright.
49 #
49 #
50 # The "-m" (mbox) option is also very useful. Instead of previewing
50 # The "-m" (mbox) option is also very useful. Instead of previewing
51 # each patchbomb message in a pager or sending the messages directly,
51 # each patchbomb message in a pager or sending the messages directly,
52 # it will create a UNIX mailbox file with the patch emails. This
52 # it will create a UNIX mailbox file with the patch emails. This
53 # mailbox file can be previewed with any mail user agent which supports
53 # mailbox file can be previewed with any mail user agent which supports
54 # UNIX mbox files, i.e. with mutt:
54 # UNIX mbox files, i.e. with mutt:
55 #
55 #
56 # % mutt -R -f mbox
56 # % mutt -R -f mbox
57 #
57 #
58 # When you are previewing the patchbomb messages, you can use `formail'
58 # When you are previewing the patchbomb messages, you can use `formail'
59 # (a utility that is commonly installed as part of the procmail package),
59 # (a utility that is commonly installed as part of the procmail package),
60 # to send each message out:
60 # to send each message out:
61 #
61 #
62 # % formail -s sendmail -bm -t < mbox
62 # % formail -s sendmail -bm -t < mbox
63 #
63 #
64 # That should be all. Now your patchbomb is on its way out.
64 # That should be all. Now your patchbomb is on its way out.
65
65
66 import os, errno, socket
66 import os, errno, socket
67 import email.MIMEMultipart, email.MIMEText, email.Utils
67 import email.MIMEMultipart, email.MIMEText, email.Utils
68 from mercurial import cmdutil, commands, hg, mail, ui, patch, util
68 from mercurial import cmdutil, commands, hg, mail, ui, patch, util
69 from mercurial.i18n import _
69 from mercurial.i18n import _
70 from mercurial.node import *
70 from mercurial.node import *
71
71
72 try:
72 try:
73 # readline gives raw_input editing capabilities, but is not
73 # readline gives raw_input editing capabilities, but is not
74 # present on windows
74 # present on windows
75 import readline
75 import readline
76 except ImportError: pass
76 except ImportError: pass
77
77
78 def patchbomb(ui, repo, *revs, **opts):
78 def patchbomb(ui, repo, *revs, **opts):
79 '''send changesets as a series of patch emails
79 '''send changesets as a series of patch emails
80
80
81 The series starts with a "[PATCH 0 of N]" introduction, which
81 The series starts with a "[PATCH 0 of N]" introduction, which
82 describes the series as a whole.
82 describes the series as a whole.
83
83
84 Each patch email has a Subject line of "[PATCH M of N] ...", using
84 Each patch email has a Subject line of "[PATCH M of N] ...", using
85 the first line of the changeset description as the subject text.
85 the first line of the changeset description as the subject text.
86 The message contains two or three body parts. First, the rest of
86 The message contains two or three body parts. First, the rest of
87 the changeset description. Next, (optionally) if the diffstat
87 the changeset description. Next, (optionally) if the diffstat
88 program is installed, the result of running diffstat on the patch.
88 program is installed, the result of running diffstat on the patch.
89 Finally, the patch itself, as generated by "hg export".'''
89 Finally, the patch itself, as generated by "hg export".
90
91 With --outgoing, emails will be generated for patches not
92 found in the target repository (or only those which are
93 ancestors of the specified revisions if any are provided)
94 '''
95
90 def prompt(prompt, default = None, rest = ': ', empty_ok = False):
96 def prompt(prompt, default = None, rest = ': ', empty_ok = False):
91 if default: prompt += ' [%s]' % default
97 if default: prompt += ' [%s]' % default
92 prompt += rest
98 prompt += rest
93 while True:
99 while True:
94 r = raw_input(prompt)
100 r = raw_input(prompt)
95 if r: return r
101 if r: return r
96 if default is not None: return default
102 if default is not None: return default
97 if empty_ok: return r
103 if empty_ok: return r
98 ui.warn(_('Please enter a valid value.\n'))
104 ui.warn(_('Please enter a valid value.\n'))
99
105
100 def confirm(s):
106 def confirm(s):
101 if not prompt(s, default = 'y', rest = '? ').lower().startswith('y'):
107 if not prompt(s, default = 'y', rest = '? ').lower().startswith('y'):
102 raise ValueError
108 raise ValueError
103
109
104 def cdiffstat(summary, patchlines):
110 def cdiffstat(summary, patchlines):
105 s = patch.diffstat(patchlines)
111 s = patch.diffstat(patchlines)
106 if s:
112 if s:
107 if summary:
113 if summary:
108 ui.write(summary, '\n')
114 ui.write(summary, '\n')
109 ui.write(s, '\n')
115 ui.write(s, '\n')
110 confirm(_('Does the diffstat above look okay'))
116 confirm(_('Does the diffstat above look okay'))
111 return s
117 return s
112
118
113 def makepatch(patch, idx, total):
119 def makepatch(patch, idx, total):
114 desc = []
120 desc = []
115 node = None
121 node = None
116 body = ''
122 body = ''
117 for line in patch:
123 for line in patch:
118 if line.startswith('#'):
124 if line.startswith('#'):
119 if line.startswith('# Node ID'): node = line.split()[-1]
125 if line.startswith('# Node ID'): node = line.split()[-1]
120 continue
126 continue
121 if (line.startswith('diff -r')
127 if (line.startswith('diff -r')
122 or line.startswith('diff --git')):
128 or line.startswith('diff --git')):
123 break
129 break
124 desc.append(line)
130 desc.append(line)
125 if not node: raise ValueError
131 if not node: raise ValueError
126
132
127 #body = ('\n'.join(desc[1:]).strip() or
133 #body = ('\n'.join(desc[1:]).strip() or
128 # 'Patch subject is complete summary.')
134 # 'Patch subject is complete summary.')
129 #body += '\n\n\n'
135 #body += '\n\n\n'
130
136
131 if opts['plain']:
137 if opts['plain']:
132 while patch and patch[0].startswith('# '): patch.pop(0)
138 while patch and patch[0].startswith('# '): patch.pop(0)
133 if patch: patch.pop(0)
139 if patch: patch.pop(0)
134 while patch and not patch[0].strip(): patch.pop(0)
140 while patch and not patch[0].strip(): patch.pop(0)
135 if opts['diffstat']:
141 if opts['diffstat']:
136 body += cdiffstat('\n'.join(desc), patch) + '\n\n'
142 body += cdiffstat('\n'.join(desc), patch) + '\n\n'
137 if opts['attach']:
143 if opts['attach']:
138 msg = email.MIMEMultipart.MIMEMultipart()
144 msg = email.MIMEMultipart.MIMEMultipart()
139 if body: msg.attach(email.MIMEText.MIMEText(body, 'plain'))
145 if body: msg.attach(email.MIMEText.MIMEText(body, 'plain'))
140 p = email.MIMEText.MIMEText('\n'.join(patch), 'x-patch')
146 p = email.MIMEText.MIMEText('\n'.join(patch), 'x-patch')
141 binnode = bin(node)
147 binnode = bin(node)
142 # if node is mq patch, it will have patch file name as tag
148 # if node is mq patch, it will have patch file name as tag
143 patchname = [t for t in repo.nodetags(binnode)
149 patchname = [t for t in repo.nodetags(binnode)
144 if t.endswith('.patch') or t.endswith('.diff')]
150 if t.endswith('.patch') or t.endswith('.diff')]
145 if patchname:
151 if patchname:
146 patchname = patchname[0]
152 patchname = patchname[0]
147 elif total > 1:
153 elif total > 1:
148 patchname = cmdutil.make_filename(repo, '%b-%n.patch',
154 patchname = cmdutil.make_filename(repo, '%b-%n.patch',
149 binnode, idx, total)
155 binnode, idx, total)
150 else:
156 else:
151 patchname = cmdutil.make_filename(repo, '%b.patch', binnode)
157 patchname = cmdutil.make_filename(repo, '%b.patch', binnode)
152 p['Content-Disposition'] = 'inline; filename=' + patchname
158 p['Content-Disposition'] = 'inline; filename=' + patchname
153 msg.attach(p)
159 msg.attach(p)
154 else:
160 else:
155 body += '\n'.join(patch)
161 body += '\n'.join(patch)
156 msg = email.MIMEText.MIMEText(body)
162 msg = email.MIMEText.MIMEText(body)
157
163
158 subj = desc[0].strip().rstrip('. ')
164 subj = desc[0].strip().rstrip('. ')
159 if total == 1:
165 if total == 1:
160 subj = '[PATCH] ' + (opts['subject'] or subj)
166 subj = '[PATCH] ' + (opts['subject'] or subj)
161 else:
167 else:
162 tlen = len(str(total))
168 tlen = len(str(total))
163 subj = '[PATCH %0*d of %d] %s' % (tlen, idx, total, subj)
169 subj = '[PATCH %0*d of %d] %s' % (tlen, idx, total, subj)
164 msg['Subject'] = subj
170 msg['Subject'] = subj
165 msg['X-Mercurial-Node'] = node
171 msg['X-Mercurial-Node'] = node
166 return msg
172 return msg
167
173
174 def outgoing(dest, revs):
175 '''Return the revisions present locally but not in dest'''
176 dest = ui.expandpath(dest or 'default-push', dest or 'default')
177 revs = [repo.lookup(rev) for rev in revs]
178 other = hg.repository(ui, dest)
179 ui.status(_('comparing with %s\n') % dest)
180 o = repo.findoutgoing(other)
181 if not o:
182 ui.status(_("no changes found\n"))
183 return []
184 o = repo.changelog.nodesbetween(o, revs or None)[0]
185 return [str(repo.changelog.rev(r)) for r in o]
186
187 # option handling
188 commands.setremoteconfig(ui, opts)
189 if opts.get('outgoing'):
190 if len(revs) > 1:
191 raise util.Abort(_("too many destinations"))
192 dest = revs and revs[0] or None
193 revs = []
194
195 if opts.get('rev'):
196 if revs:
197 raise util.Abort(_('use only one form to specify the revision'))
198 revs = opts.get('rev')
199
200 if opts.get('outgoing'):
201 revs = outgoing(dest, opts.get('rev'))
202
203 # start
168 start_time = util.makedate()
204 start_time = util.makedate()
169
205
170 def genmsgid(id):
206 def genmsgid(id):
171 return '<%s.%s@%s>' % (id[:20], int(start_time[0]), socket.getfqdn())
207 return '<%s.%s@%s>' % (id[:20], int(start_time[0]), socket.getfqdn())
172
208
173 patches = []
209 patches = []
174
210
175 class exportee:
211 class exportee:
176 def __init__(self, container):
212 def __init__(self, container):
177 self.lines = []
213 self.lines = []
178 self.container = container
214 self.container = container
179 self.name = 'email'
215 self.name = 'email'
180
216
181 def write(self, data):
217 def write(self, data):
182 self.lines.append(data)
218 self.lines.append(data)
183
219
184 def close(self):
220 def close(self):
185 self.container.append(''.join(self.lines).split('\n'))
221 self.container.append(''.join(self.lines).split('\n'))
186 self.lines = []
222 self.lines = []
187
223
188 commands.export(ui, repo, *revs, **{'output': exportee(patches),
224 commands.export(ui, repo, *revs, **{'output': exportee(patches),
189 'switch_parent': False,
225 'switch_parent': False,
190 'text': None,
226 'text': None,
191 'git': opts.get('git')})
227 'git': opts.get('git')})
192
228
193 jumbo = []
229 jumbo = []
194 msgs = []
230 msgs = []
195
231
196 ui.write(_('This patch series consists of %d patches.\n\n') % len(patches))
232 ui.write(_('This patch series consists of %d patches.\n\n') % len(patches))
197
233
198 for p, i in zip(patches, xrange(len(patches))):
234 for p, i in zip(patches, xrange(len(patches))):
199 jumbo.extend(p)
235 jumbo.extend(p)
200 msgs.append(makepatch(p, i + 1, len(patches)))
236 msgs.append(makepatch(p, i + 1, len(patches)))
201
237
202 sender = (opts['from'] or ui.config('email', 'from') or
238 sender = (opts['from'] or ui.config('email', 'from') or
203 ui.config('patchbomb', 'from') or
239 ui.config('patchbomb', 'from') or
204 prompt('From', ui.username()))
240 prompt('From', ui.username()))
205
241
206 def getaddrs(opt, prpt, default = None):
242 def getaddrs(opt, prpt, default = None):
207 addrs = opts[opt] or (ui.config('email', opt) or
243 addrs = opts[opt] or (ui.config('email', opt) or
208 ui.config('patchbomb', opt) or
244 ui.config('patchbomb', opt) or
209 prompt(prpt, default = default)).split(',')
245 prompt(prpt, default = default)).split(',')
210 return [a.strip() for a in addrs if a.strip()]
246 return [a.strip() for a in addrs if a.strip()]
211 to = getaddrs('to', 'To')
247 to = getaddrs('to', 'To')
212 cc = getaddrs('cc', 'Cc', '')
248 cc = getaddrs('cc', 'Cc', '')
213
249
214 bcc = opts['bcc'] or (ui.config('email', 'bcc') or
250 bcc = opts['bcc'] or (ui.config('email', 'bcc') or
215 ui.config('patchbomb', 'bcc') or '').split(',')
251 ui.config('patchbomb', 'bcc') or '').split(',')
216 bcc = [a.strip() for a in bcc if a.strip()]
252 bcc = [a.strip() for a in bcc if a.strip()]
217
253
218 if len(patches) > 1:
254 if len(patches) > 1:
219 tlen = len(str(len(patches)))
255 tlen = len(str(len(patches)))
220
256
221 subj = '[PATCH %0*d of %d] %s' % (
257 subj = '[PATCH %0*d of %d] %s' % (
222 tlen, 0,
258 tlen, 0,
223 len(patches),
259 len(patches),
224 opts['subject'] or
260 opts['subject'] or
225 prompt('Subject:', rest = ' [PATCH %0*d of %d] ' % (tlen, 0,
261 prompt('Subject:', rest = ' [PATCH %0*d of %d] ' % (tlen, 0,
226 len(patches))))
262 len(patches))))
227
263
228 body = ''
264 body = ''
229 if opts['diffstat']:
265 if opts['diffstat']:
230 d = cdiffstat(_('Final summary:\n'), jumbo)
266 d = cdiffstat(_('Final summary:\n'), jumbo)
231 if d: body = '\n' + d
267 if d: body = '\n' + d
232
268
233 ui.write(_('\nWrite the introductory message for the patch series.\n\n'))
269 ui.write(_('\nWrite the introductory message for the patch series.\n\n'))
234 body = ui.edit(body, sender)
270 body = ui.edit(body, sender)
235
271
236 msg = email.MIMEText.MIMEText(body)
272 msg = email.MIMEText.MIMEText(body)
237 msg['Subject'] = subj
273 msg['Subject'] = subj
238
274
239 msgs.insert(0, msg)
275 msgs.insert(0, msg)
240
276
241 ui.write('\n')
277 ui.write('\n')
242
278
243 if not opts['test'] and not opts['mbox']:
279 if not opts['test'] and not opts['mbox']:
244 mailer = mail.connect(ui)
280 mailer = mail.connect(ui)
245 parent = None
281 parent = None
246
282
247 sender_addr = email.Utils.parseaddr(sender)[1]
283 sender_addr = email.Utils.parseaddr(sender)[1]
248 for m in msgs:
284 for m in msgs:
249 try:
285 try:
250 m['Message-Id'] = genmsgid(m['X-Mercurial-Node'])
286 m['Message-Id'] = genmsgid(m['X-Mercurial-Node'])
251 except TypeError:
287 except TypeError:
252 m['Message-Id'] = genmsgid('patchbomb')
288 m['Message-Id'] = genmsgid('patchbomb')
253 if parent:
289 if parent:
254 m['In-Reply-To'] = parent
290 m['In-Reply-To'] = parent
255 else:
291 else:
256 parent = m['Message-Id']
292 parent = m['Message-Id']
257 m['Date'] = util.datestr(date=start_time,
293 m['Date'] = util.datestr(date=start_time,
258 format="%a, %d %b %Y %H:%M:%S", timezone=True)
294 format="%a, %d %b %Y %H:%M:%S", timezone=True)
259
295
260 start_time = (start_time[0] + 1, start_time[1])
296 start_time = (start_time[0] + 1, start_time[1])
261 m['From'] = sender
297 m['From'] = sender
262 m['To'] = ', '.join(to)
298 m['To'] = ', '.join(to)
263 if cc: m['Cc'] = ', '.join(cc)
299 if cc: m['Cc'] = ', '.join(cc)
264 if bcc: m['Bcc'] = ', '.join(bcc)
300 if bcc: m['Bcc'] = ', '.join(bcc)
265 if opts['test']:
301 if opts['test']:
266 ui.status('Displaying ', m['Subject'], ' ...\n')
302 ui.status('Displaying ', m['Subject'], ' ...\n')
267 fp = os.popen(os.getenv('PAGER', 'more'), 'w')
303 fp = os.popen(os.getenv('PAGER', 'more'), 'w')
268 try:
304 try:
269 fp.write(m.as_string(0))
305 fp.write(m.as_string(0))
270 fp.write('\n')
306 fp.write('\n')
271 except IOError, inst:
307 except IOError, inst:
272 if inst.errno != errno.EPIPE:
308 if inst.errno != errno.EPIPE:
273 raise
309 raise
274 fp.close()
310 fp.close()
275 elif opts['mbox']:
311 elif opts['mbox']:
276 ui.status('Writing ', m['Subject'], ' ...\n')
312 ui.status('Writing ', m['Subject'], ' ...\n')
277 fp = open(opts['mbox'], m.has_key('In-Reply-To') and 'ab+' or 'wb+')
313 fp = open(opts['mbox'], m.has_key('In-Reply-To') and 'ab+' or 'wb+')
278 date = util.datestr(date=start_time,
314 date = util.datestr(date=start_time,
279 format='%a %b %d %H:%M:%S %Y', timezone=False)
315 format='%a %b %d %H:%M:%S %Y', timezone=False)
280 fp.write('From %s %s\n' % (sender_addr, date))
316 fp.write('From %s %s\n' % (sender_addr, date))
281 fp.write(m.as_string(0))
317 fp.write(m.as_string(0))
282 fp.write('\n\n')
318 fp.write('\n\n')
283 fp.close()
319 fp.close()
284 else:
320 else:
285 ui.status('Sending ', m['Subject'], ' ...\n')
321 ui.status('Sending ', m['Subject'], ' ...\n')
286 # Exim does not remove the Bcc field
322 # Exim does not remove the Bcc field
287 del m['Bcc']
323 del m['Bcc']
288 mailer.sendmail(sender, to + bcc + cc, m.as_string(0))
324 mailer.sendmail(sender, to + bcc + cc, m.as_string(0))
289
325
290 cmdtable = {
326 cmdtable = {
291 'email':
327 'email':
292 (patchbomb,
328 (patchbomb,
293 [('a', 'attach', None, 'send patches as inline attachments'),
329 [('a', 'attach', None, 'send patches as inline attachments'),
294 ('', 'bcc', [], 'email addresses of blind copy recipients'),
330 ('', 'bcc', [], 'email addresses of blind copy recipients'),
295 ('c', 'cc', [], 'email addresses of copy recipients'),
331 ('c', 'cc', [], 'email addresses of copy recipients'),
296 ('d', 'diffstat', None, 'add diffstat output to messages'),
332 ('d', 'diffstat', None, 'add diffstat output to messages'),
297 ('g', 'git', None, _('use git extended diff format')),
333 ('g', 'git', None, _('use git extended diff format')),
298 ('f', 'from', '', 'email address of sender'),
334 ('f', 'from', '', 'email address of sender'),
299 ('', 'plain', None, 'omit hg patch header'),
335 ('', 'plain', None, 'omit hg patch header'),
300 ('n', 'test', None, 'print messages that would be sent'),
336 ('n', 'test', None, 'print messages that would be sent'),
301 ('m', 'mbox', '', 'write messages to mbox file instead of sending them'),
337 ('m', 'mbox', '', 'write messages to mbox file instead of sending them'),
338 ('o', 'outgoing', None, _('send changes not found in the target repository')),
339 ('r', 'rev', [], _('a revision to send')),
302 ('s', 'subject', '', 'subject of first message (intro or single patch)'),
340 ('s', 'subject', '', 'subject of first message (intro or single patch)'),
303 ('t', 'to', [], 'email addresses of recipients')],
341 ('t', 'to', [], 'email addresses of recipients')] + commands.remoteopts,
304 "hg email [OPTION]... [REV]...")
342 "hg email [OPTION]... [DEST]...")
305 }
343 }
General Comments 0
You need to be logged in to leave comments. Login now