##// END OF EJS Templates
Add --git support to hg email
Brendan Cully -
r3030:5a17423f default
parent child Browse files
Show More
@@ -1,332 +1,334
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 from mercurial.demandload import *
66 from mercurial.demandload import *
67 demandload(globals(), '''email.MIMEMultipart email.MIMEText email.Utils
67 demandload(globals(), '''email.MIMEMultipart email.MIMEText email.Utils
68 mercurial:commands,hg,mail,ui
68 mercurial:commands,hg,mail,ui
69 os errno popen2 socket sys tempfile time''')
69 os errno popen2 socket sys tempfile time''')
70 from mercurial.i18n import gettext as _
70 from mercurial.i18n import gettext as _
71 from mercurial.node import *
71 from mercurial.node import *
72
72
73 try:
73 try:
74 # readline gives raw_input editing capabilities, but is not
74 # readline gives raw_input editing capabilities, but is not
75 # present on windows
75 # present on windows
76 import readline
76 import readline
77 except ImportError: pass
77 except ImportError: pass
78
78
79 def diffstat(patch):
79 def diffstat(patch):
80 fd, name = tempfile.mkstemp(prefix="hg-patchbomb-", suffix=".txt")
80 fd, name = tempfile.mkstemp(prefix="hg-patchbomb-", suffix=".txt")
81 try:
81 try:
82 p = popen2.Popen3('diffstat -p1 -w79 2>/dev/null > ' + name)
82 p = popen2.Popen3('diffstat -p1 -w79 2>/dev/null > ' + name)
83 try:
83 try:
84 for line in patch: print >> p.tochild, line
84 for line in patch: print >> p.tochild, line
85 p.tochild.close()
85 p.tochild.close()
86 if p.wait(): return
86 if p.wait(): return
87 fp = os.fdopen(fd, 'r')
87 fp = os.fdopen(fd, 'r')
88 stat = []
88 stat = []
89 for line in fp: stat.append(line.lstrip())
89 for line in fp: stat.append(line.lstrip())
90 last = stat.pop()
90 last = stat.pop()
91 stat.insert(0, last)
91 stat.insert(0, last)
92 stat = ''.join(stat)
92 stat = ''.join(stat)
93 if stat.startswith('0 files'): raise ValueError
93 if stat.startswith('0 files'): raise ValueError
94 return stat
94 return stat
95 except: raise
95 except: raise
96 finally:
96 finally:
97 try: os.unlink(name)
97 try: os.unlink(name)
98 except: pass
98 except: pass
99
99
100 def patchbomb(ui, repo, *revs, **opts):
100 def patchbomb(ui, repo, *revs, **opts):
101 '''send changesets as a series of patch emails
101 '''send changesets as a series of patch emails
102
102
103 The series starts with a "[PATCH 0 of N]" introduction, which
103 The series starts with a "[PATCH 0 of N]" introduction, which
104 describes the series as a whole.
104 describes the series as a whole.
105
105
106 Each patch email has a Subject line of "[PATCH M of N] ...", using
106 Each patch email has a Subject line of "[PATCH M of N] ...", using
107 the first line of the changeset description as the subject text.
107 the first line of the changeset description as the subject text.
108 The message contains two or three body parts. First, the rest of
108 The message contains two or three body parts. First, the rest of
109 the changeset description. Next, (optionally) if the diffstat
109 the changeset description. Next, (optionally) if the diffstat
110 program is installed, the result of running diffstat on the patch.
110 program is installed, the result of running diffstat on the patch.
111 Finally, the patch itself, as generated by "hg export".'''
111 Finally, the patch itself, as generated by "hg export".'''
112 def prompt(prompt, default = None, rest = ': ', empty_ok = False):
112 def prompt(prompt, default = None, rest = ': ', empty_ok = False):
113 if default: prompt += ' [%s]' % default
113 if default: prompt += ' [%s]' % default
114 prompt += rest
114 prompt += rest
115 while True:
115 while True:
116 r = raw_input(prompt)
116 r = raw_input(prompt)
117 if r: return r
117 if r: return r
118 if default is not None: return default
118 if default is not None: return default
119 if empty_ok: return r
119 if empty_ok: return r
120 ui.warn(_('Please enter a valid value.\n'))
120 ui.warn(_('Please enter a valid value.\n'))
121
121
122 def confirm(s):
122 def confirm(s):
123 if not prompt(s, default = 'y', rest = '? ').lower().startswith('y'):
123 if not prompt(s, default = 'y', rest = '? ').lower().startswith('y'):
124 raise ValueError
124 raise ValueError
125
125
126 def cdiffstat(summary, patch):
126 def cdiffstat(summary, patch):
127 s = diffstat(patch)
127 s = diffstat(patch)
128 if s:
128 if s:
129 if summary:
129 if summary:
130 ui.write(summary, '\n')
130 ui.write(summary, '\n')
131 ui.write(s, '\n')
131 ui.write(s, '\n')
132 confirm(_('Does the diffstat above look okay'))
132 confirm(_('Does the diffstat above look okay'))
133 return s
133 return s
134
134
135 def makepatch(patch, idx, total):
135 def makepatch(patch, idx, total):
136 desc = []
136 desc = []
137 node = None
137 node = None
138 body = ''
138 body = ''
139 for line in patch:
139 for line in patch:
140 if line.startswith('#'):
140 if line.startswith('#'):
141 if line.startswith('# Node ID'): node = line.split()[-1]
141 if line.startswith('# Node ID'): node = line.split()[-1]
142 continue
142 continue
143 if line.startswith('diff -r'): break
143 if line.startswith('diff -r'): break
144 desc.append(line)
144 desc.append(line)
145 if not node: raise ValueError
145 if not node: raise ValueError
146
146
147 #body = ('\n'.join(desc[1:]).strip() or
147 #body = ('\n'.join(desc[1:]).strip() or
148 # 'Patch subject is complete summary.')
148 # 'Patch subject is complete summary.')
149 #body += '\n\n\n'
149 #body += '\n\n\n'
150
150
151 if opts['plain']:
151 if opts['plain']:
152 while patch and patch[0].startswith('# '): patch.pop(0)
152 while patch and patch[0].startswith('# '): patch.pop(0)
153 if patch: patch.pop(0)
153 if patch: patch.pop(0)
154 while patch and not patch[0].strip(): patch.pop(0)
154 while patch and not patch[0].strip(): patch.pop(0)
155 if opts['diffstat']:
155 if opts['diffstat']:
156 body += cdiffstat('\n'.join(desc), patch) + '\n\n'
156 body += cdiffstat('\n'.join(desc), patch) + '\n\n'
157 if opts['attach']:
157 if opts['attach']:
158 msg = email.MIMEMultipart.MIMEMultipart()
158 msg = email.MIMEMultipart.MIMEMultipart()
159 if body: msg.attach(email.MIMEText.MIMEText(body, 'plain'))
159 if body: msg.attach(email.MIMEText.MIMEText(body, 'plain'))
160 p = email.MIMEText.MIMEText('\n'.join(patch), 'x-patch')
160 p = email.MIMEText.MIMEText('\n'.join(patch), 'x-patch')
161 binnode = bin(node)
161 binnode = bin(node)
162 # if node is mq patch, it will have patch file name as tag
162 # if node is mq patch, it will have patch file name as tag
163 patchname = [t for t in repo.nodetags(binnode)
163 patchname = [t for t in repo.nodetags(binnode)
164 if t.endswith('.patch') or t.endswith('.diff')]
164 if t.endswith('.patch') or t.endswith('.diff')]
165 if patchname:
165 if patchname:
166 patchname = patchname[0]
166 patchname = patchname[0]
167 elif total > 1:
167 elif total > 1:
168 patchname = commands.make_filename(repo, '%b-%n.patch',
168 patchname = commands.make_filename(repo, '%b-%n.patch',
169 binnode, idx, total)
169 binnode, idx, total)
170 else:
170 else:
171 patchname = commands.make_filename(repo, '%b.patch', binnode)
171 patchname = commands.make_filename(repo, '%b.patch', binnode)
172 p['Content-Disposition'] = 'inline; filename=' + patchname
172 p['Content-Disposition'] = 'inline; filename=' + patchname
173 msg.attach(p)
173 msg.attach(p)
174 else:
174 else:
175 body += '\n'.join(patch)
175 body += '\n'.join(patch)
176 msg = email.MIMEText.MIMEText(body)
176 msg = email.MIMEText.MIMEText(body)
177 if total == 1:
177 if total == 1:
178 subj = '[PATCH] ' + desc[0].strip()
178 subj = '[PATCH] ' + desc[0].strip()
179 else:
179 else:
180 subj = '[PATCH %d of %d] %s' % (idx, total, desc[0].strip())
180 subj = '[PATCH %d of %d] %s' % (idx, total, desc[0].strip())
181 if subj.endswith('.'): subj = subj[:-1]
181 if subj.endswith('.'): subj = subj[:-1]
182 msg['Subject'] = subj
182 msg['Subject'] = subj
183 msg['X-Mercurial-Node'] = node
183 msg['X-Mercurial-Node'] = node
184 return msg
184 return msg
185
185
186 start_time = int(time.time())
186 start_time = int(time.time())
187
187
188 def genmsgid(id):
188 def genmsgid(id):
189 return '<%s.%s@%s>' % (id[:20], start_time, socket.getfqdn())
189 return '<%s.%s@%s>' % (id[:20], start_time, socket.getfqdn())
190
190
191 patches = []
191 patches = []
192
192
193 class exportee:
193 class exportee:
194 def __init__(self, container):
194 def __init__(self, container):
195 self.lines = []
195 self.lines = []
196 self.container = container
196 self.container = container
197 self.name = 'email'
197 self.name = 'email'
198
198
199 def write(self, data):
199 def write(self, data):
200 self.lines.append(data)
200 self.lines.append(data)
201
201
202 def close(self):
202 def close(self):
203 self.container.append(''.join(self.lines).split('\n'))
203 self.container.append(''.join(self.lines).split('\n'))
204 self.lines = []
204 self.lines = []
205
205
206 commands.export(ui, repo, *revs, **{'output': exportee(patches),
206 commands.export(ui, repo, *revs, **{'output': exportee(patches),
207 'switch_parent': False,
207 'switch_parent': False,
208 'text': None})
208 'text': None,
209 'git': opts.get('git')})
209
210
210 jumbo = []
211 jumbo = []
211 msgs = []
212 msgs = []
212
213
213 ui.write(_('This patch series consists of %d patches.\n\n') % len(patches))
214 ui.write(_('This patch series consists of %d patches.\n\n') % len(patches))
214
215
215 for p, i in zip(patches, range(len(patches))):
216 for p, i in zip(patches, range(len(patches))):
216 jumbo.extend(p)
217 jumbo.extend(p)
217 msgs.append(makepatch(p, i + 1, len(patches)))
218 msgs.append(makepatch(p, i + 1, len(patches)))
218
219
219 sender = (opts['from'] or ui.config('email', 'from') or
220 sender = (opts['from'] or ui.config('email', 'from') or
220 ui.config('patchbomb', 'from') or
221 ui.config('patchbomb', 'from') or
221 prompt('From', ui.username()))
222 prompt('From', ui.username()))
222
223
223 def getaddrs(opt, prpt, default = None):
224 def getaddrs(opt, prpt, default = None):
224 addrs = opts[opt] or (ui.config('email', opt) or
225 addrs = opts[opt] or (ui.config('email', opt) or
225 ui.config('patchbomb', opt) or
226 ui.config('patchbomb', opt) or
226 prompt(prpt, default = default)).split(',')
227 prompt(prpt, default = default)).split(',')
227 return [a.strip() for a in addrs if a.strip()]
228 return [a.strip() for a in addrs if a.strip()]
228 to = getaddrs('to', 'To')
229 to = getaddrs('to', 'To')
229 cc = getaddrs('cc', 'Cc', '')
230 cc = getaddrs('cc', 'Cc', '')
230
231
231 bcc = opts['bcc'] or (ui.config('email', 'bcc') or
232 bcc = opts['bcc'] or (ui.config('email', 'bcc') or
232 ui.config('patchbomb', 'bcc') or '').split(',')
233 ui.config('patchbomb', 'bcc') or '').split(',')
233 bcc = [a.strip() for a in bcc if a.strip()]
234 bcc = [a.strip() for a in bcc if a.strip()]
234
235
235 if len(patches) > 1:
236 if len(patches) > 1:
236 ui.write(_('\nWrite the introductory message for the patch series.\n\n'))
237 ui.write(_('\nWrite the introductory message for the patch series.\n\n'))
237
238
238 subj = '[PATCH 0 of %d] %s' % (
239 subj = '[PATCH 0 of %d] %s' % (
239 len(patches),
240 len(patches),
240 opts['subject'] or
241 opts['subject'] or
241 prompt('Subject:', rest = ' [PATCH 0 of %d] ' % len(patches)))
242 prompt('Subject:', rest = ' [PATCH 0 of %d] ' % len(patches)))
242
243
243 ui.write(_('Finish with ^D or a dot on a line by itself.\n\n'))
244 ui.write(_('Finish with ^D or a dot on a line by itself.\n\n'))
244
245
245 body = []
246 body = []
246
247
247 while True:
248 while True:
248 try: l = raw_input()
249 try: l = raw_input()
249 except EOFError: break
250 except EOFError: break
250 if l == '.': break
251 if l == '.': break
251 body.append(l)
252 body.append(l)
252
253
253 if opts['diffstat']:
254 if opts['diffstat']:
254 d = cdiffstat(_('Final summary:\n'), jumbo)
255 d = cdiffstat(_('Final summary:\n'), jumbo)
255 if d: body.append('\n' + d)
256 if d: body.append('\n' + d)
256
257
257 body = '\n'.join(body) + '\n'
258 body = '\n'.join(body) + '\n'
258
259
259 msg = email.MIMEText.MIMEText(body)
260 msg = email.MIMEText.MIMEText(body)
260 msg['Subject'] = subj
261 msg['Subject'] = subj
261
262
262 msgs.insert(0, msg)
263 msgs.insert(0, msg)
263
264
264 ui.write('\n')
265 ui.write('\n')
265
266
266 if not opts['test'] and not opts['mbox']:
267 if not opts['test'] and not opts['mbox']:
267 mailer = mail.connect(ui)
268 mailer = mail.connect(ui)
268 parent = None
269 parent = None
269
270
270 # Calculate UTC offset
271 # Calculate UTC offset
271 if time.daylight: offset = time.altzone
272 if time.daylight: offset = time.altzone
272 else: offset = time.timezone
273 else: offset = time.timezone
273 if offset <= 0: sign, offset = '+', -offset
274 if offset <= 0: sign, offset = '+', -offset
274 else: sign = '-'
275 else: sign = '-'
275 offset = '%s%02d%02d' % (sign, offset / 3600, (offset % 3600) / 60)
276 offset = '%s%02d%02d' % (sign, offset / 3600, (offset % 3600) / 60)
276
277
277 sender_addr = email.Utils.parseaddr(sender)[1]
278 sender_addr = email.Utils.parseaddr(sender)[1]
278 for m in msgs:
279 for m in msgs:
279 try:
280 try:
280 m['Message-Id'] = genmsgid(m['X-Mercurial-Node'])
281 m['Message-Id'] = genmsgid(m['X-Mercurial-Node'])
281 except TypeError:
282 except TypeError:
282 m['Message-Id'] = genmsgid('patchbomb')
283 m['Message-Id'] = genmsgid('patchbomb')
283 if parent:
284 if parent:
284 m['In-Reply-To'] = parent
285 m['In-Reply-To'] = parent
285 else:
286 else:
286 parent = m['Message-Id']
287 parent = m['Message-Id']
287 m['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime(start_time)) + ' ' + offset
288 m['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime(start_time)) + ' ' + offset
288
289
289 start_time += 1
290 start_time += 1
290 m['From'] = sender
291 m['From'] = sender
291 m['To'] = ', '.join(to)
292 m['To'] = ', '.join(to)
292 if cc: m['Cc'] = ', '.join(cc)
293 if cc: m['Cc'] = ', '.join(cc)
293 if bcc: m['Bcc'] = ', '.join(bcc)
294 if bcc: m['Bcc'] = ', '.join(bcc)
294 if opts['test']:
295 if opts['test']:
295 ui.status('Displaying ', m['Subject'], ' ...\n')
296 ui.status('Displaying ', m['Subject'], ' ...\n')
296 fp = os.popen(os.getenv('PAGER', 'more'), 'w')
297 fp = os.popen(os.getenv('PAGER', 'more'), 'w')
297 try:
298 try:
298 fp.write(m.as_string(0))
299 fp.write(m.as_string(0))
299 fp.write('\n')
300 fp.write('\n')
300 except IOError, inst:
301 except IOError, inst:
301 if inst.errno != errno.EPIPE:
302 if inst.errno != errno.EPIPE:
302 raise
303 raise
303 fp.close()
304 fp.close()
304 elif opts['mbox']:
305 elif opts['mbox']:
305 ui.status('Writing ', m['Subject'], ' ...\n')
306 ui.status('Writing ', m['Subject'], ' ...\n')
306 fp = open(opts['mbox'], m.has_key('In-Reply-To') and 'ab+' or 'wb+')
307 fp = open(opts['mbox'], m.has_key('In-Reply-To') and 'ab+' or 'wb+')
307 date = time.asctime(time.localtime(start_time))
308 date = time.asctime(time.localtime(start_time))
308 fp.write('From %s %s\n' % (sender_addr, date))
309 fp.write('From %s %s\n' % (sender_addr, date))
309 fp.write(m.as_string(0))
310 fp.write(m.as_string(0))
310 fp.write('\n\n')
311 fp.write('\n\n')
311 fp.close()
312 fp.close()
312 else:
313 else:
313 ui.status('Sending ', m['Subject'], ' ...\n')
314 ui.status('Sending ', m['Subject'], ' ...\n')
314 # Exim does not remove the Bcc field
315 # Exim does not remove the Bcc field
315 del m['Bcc']
316 del m['Bcc']
316 mailer.sendmail(sender, to + bcc + cc, m.as_string(0))
317 mailer.sendmail(sender, to + bcc + cc, m.as_string(0))
317
318
318 cmdtable = {
319 cmdtable = {
319 'email':
320 'email':
320 (patchbomb,
321 (patchbomb,
321 [('a', 'attach', None, 'send patches as inline attachments'),
322 [('a', 'attach', None, 'send patches as inline attachments'),
322 ('', 'bcc', [], 'email addresses of blind copy recipients'),
323 ('', 'bcc', [], 'email addresses of blind copy recipients'),
323 ('c', 'cc', [], 'email addresses of copy recipients'),
324 ('c', 'cc', [], 'email addresses of copy recipients'),
324 ('d', 'diffstat', None, 'add diffstat output to messages'),
325 ('d', 'diffstat', None, 'add diffstat output to messages'),
326 ('g', 'git', None, _('use git extended diff format')),
325 ('f', 'from', '', 'email address of sender'),
327 ('f', 'from', '', 'email address of sender'),
326 ('', 'plain', None, 'omit hg patch header'),
328 ('', 'plain', None, 'omit hg patch header'),
327 ('n', 'test', None, 'print messages that would be sent'),
329 ('n', 'test', None, 'print messages that would be sent'),
328 ('m', 'mbox', '', 'write messages to mbox file instead of sending them'),
330 ('m', 'mbox', '', 'write messages to mbox file instead of sending them'),
329 ('s', 'subject', '', 'subject of introductory message'),
331 ('s', 'subject', '', 'subject of introductory message'),
330 ('t', 'to', [], 'email addresses of recipients')],
332 ('t', 'to', [], 'email addresses of recipients')],
331 "hg email [OPTION]... [REV]...")
333 "hg email [OPTION]... [REV]...")
332 }
334 }
General Comments 0
You need to be logged in to leave comments. Login now