Repair importing mail with colon-space in subject
[stgit.git] / stgit / commands / imprt.py
blob6a7213f391aa638b72c2ce4660fc3c1f027bf215
1 # -*- coding: utf-8 -*-
2 from __future__ import (
3 absolute_import,
4 division,
5 print_function,
6 unicode_literals,
9 import os
10 import re
11 import sys
13 from stgit import argparse
14 from stgit.argparse import opt
15 from stgit.commands.common import (
16 CmdException,
17 DirectoryHasRepository,
18 check_conflicts,
19 check_head_top_equal,
20 check_local_changes,
21 parse_patch,
22 update_commit_data,
24 from stgit.compat import decode_utf8_with_latin1
25 from stgit.lib.git import CommitData, Date, Person
26 from stgit.lib.transaction import StackTransaction, TransactionHalted
27 from stgit.out import out
28 from stgit.run import Run
29 from stgit.utils import make_patch_name
31 __copyright__ = """
32 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
34 This program is free software; you can redistribute it and/or modify
35 it under the terms of the GNU General Public License version 2 as
36 published by the Free Software Foundation.
38 This program is distributed in the hope that it will be useful,
39 but WITHOUT ANY WARRANTY; without even the implied warranty of
40 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
41 GNU General Public License for more details.
43 You should have received a copy of the GNU General Public License
44 along with this program; if not, see http://www.gnu.org/licenses/.
45 """
47 name = 'import'
48 help = 'Import a GNU diff file as a new patch'
49 kind = 'patch'
50 usage = ['[options] [--] [<file>|<url>]']
51 description = """
52 Create a new patch and apply the given GNU diff file (or the standard
53 input). By default, the file name is used as the patch name but this
54 can be overridden with the '--name' option. The patch can either be a
55 normal file with the description at the top or it can have standard
56 mail format, the Subject, From and Date headers being used for
57 generating the patch information. The command can also read series and
58 mbox files.
60 If a patch does not apply cleanly, the failed diff is written to the
61 .stgit-failed.patch file and an empty StGIT patch is added to the
62 stack.
64 The patch description has to be separated from the data with a '---'
65 line."""
67 args = ['files']
68 options = [
69 opt(
70 '-m',
71 '--mail',
72 action='store_true',
73 short='Import the patch from a standard e-mail file',
75 opt(
76 '-M',
77 '--mbox',
78 action='store_true',
79 short='Import a series of patches from an mbox file',
81 opt(
82 '-s',
83 '--series',
84 action='store_true',
85 short='Import a series of patches',
86 long="""
87 Import a series of patches from a series file or a tar archive.""",
89 opt(
90 '-u',
91 '--url',
92 action='store_true',
93 short='Import a patch from a URL',
95 opt(
96 '-n',
97 '--name',
98 short='Use NAME as the patch name',
100 opt(
101 '-p',
102 '--strip',
103 type='int',
104 metavar='N',
105 short='Remove N leading slashes from diff paths (default 1)',
107 opt(
108 '-t',
109 '--stripname',
110 action='store_true',
111 short='Strip numbering and extension from patch name',
113 opt(
114 '-i',
115 '--ignore',
116 action='store_true',
117 short='Ignore the applied patches in the series',
119 opt(
120 '--replace',
121 action='store_true',
122 short='Replace the unapplied patches in the series',
124 opt(
125 '-b',
126 '--base',
127 args=['commit'],
128 short='Use BASE instead of HEAD for file importing',
130 opt(
131 '--reject',
132 action='store_true',
133 short='Leave the rejected hunks in corresponding *.rej files',
135 opt(
136 '--keep-cr',
137 action='store_true',
138 short='Do not remove "\\r" from email lines ending with "\\r\\n"',
140 opt(
141 '-e',
142 '--edit',
143 action='store_true',
144 short='Invoke an editor for the patch description',
146 opt(
147 '-d',
148 '--showdiff',
149 action='store_true',
150 short='Show the patch content in the editor buffer',
152 ] + argparse.author_options() + argparse.sign_options()
154 directory = DirectoryHasRepository()
157 def __strip_patch_name(name):
158 stripped = re.sub('^[0-9]+-(.*)$', r'\g<1>', name)
159 stripped = re.sub(r'^(.*)\.(diff|patch)$', r'\g<1>', stripped)
160 return stripped
163 def __replace_slashes_with_dashes(name):
164 stripped = name.replace('/', '-')
165 return stripped
168 def __create_patch(filename, message, author_name, author_email,
169 author_date, diff, options):
170 """Create a new patch on the stack
172 stack = directory.repository.current_stack
174 if options.name:
175 name = options.name
176 if not stack.patches.is_name_valid(name):
177 raise CmdException('Invalid patch name: %s' % name)
178 elif filename:
179 name = os.path.basename(filename)
180 else:
181 name = ''
182 if options.stripname:
183 name = __strip_patch_name(name)
185 if not name:
186 if options.ignore or options.replace:
187 def unacceptable_name(name):
188 return False
189 else:
190 unacceptable_name = stack.patches.exists
191 name = make_patch_name(message, unacceptable_name)
192 else:
193 # fix possible invalid characters in the patch name
194 name = re.sub(r'[^\w.]+', '-', name).strip('-')
196 assert stack.patches.is_name_valid(name)
198 if options.ignore and name in stack.patchorder.applied:
199 out.info('Ignoring already applied patch "%s"' % name)
200 return
202 out.start('Importing patch "%s"' % name)
204 author = Person(
205 author_name,
206 author_email,
207 Date.maybe(author_date),
209 author = options.author(author)
211 try:
212 if not diff:
213 out.warn('No diff found, creating empty patch')
214 tree = stack.head.data.tree
215 else:
216 iw = stack.repository.default_iw
217 iw.apply(
218 diff, quiet=False, reject=options.reject, strip=options.strip
220 tree = iw.index.write_tree()
222 cd = CommitData(
223 tree=tree,
224 parents=[stack.head],
225 author=author,
226 message=message,
228 cd = update_commit_data(
230 message=None,
231 author=None,
232 sign_str=options.sign_str,
233 edit=options.edit,
235 commit = stack.repository.commit(cd)
237 trans = StackTransaction(stack, 'import: %s' % name)
239 try:
240 if options.replace and name in stack.patchorder.unapplied:
241 trans.delete_patches(lambda pn: pn == name, quiet=True)
243 trans.patches[name] = commit
244 trans.applied.append(name)
245 except TransactionHalted:
246 pass
247 trans.run()
248 finally:
249 out.done()
252 def __mkpatchname(name, suffix):
253 if name.lower().endswith(suffix.lower()):
254 return name[:-len(suffix)]
255 return name
258 def __get_handle_and_name(filename):
259 """Return a file object and a patch name derived from filename
261 import bz2
262 import gzip
264 # see if it's a gzip'ed or bzip2'ed patch
265 for copen, ext in [(gzip.open, '.gz'), (bz2.BZ2File, '.bz2')]:
266 try:
267 f = copen(filename)
268 f.read(1)
269 f.seek(0)
270 return (f, __mkpatchname(filename, ext))
271 except IOError:
272 pass
274 # plain old file...
275 return (open(filename, 'rb'), filename)
278 def __import_file(filename, options, patch=None):
279 """Import a patch from a file or standard input
281 pname = None
282 if filename:
283 (f, pname) = __get_handle_and_name(filename)
284 else:
285 f = os.fdopen(sys.__stdin__.fileno(), 'rb')
287 if patch:
288 pname = patch
289 elif not pname:
290 pname = filename
293 message, author_name, author_email, author_date, diff
294 ) = parse_patch(f.read(), contains_diff=True)
296 if filename:
297 f.close()
299 __create_patch(pname, message, author_name, author_email,
300 author_date, diff, options)
303 def __import_series(filename, options):
304 """Import a series of patches
306 import tarfile
308 if filename:
309 if tarfile.is_tarfile(filename):
310 __import_tarfile(filename, options)
311 return
312 f = open(filename)
313 patchdir = os.path.dirname(filename)
314 else:
315 f = sys.stdin
316 patchdir = ''
318 for line in f:
319 patch = re.sub('#.*$', '', line).strip()
320 if not patch:
321 continue
322 # Quilt can have "-p0", "-p1" or "-pab" patches stacked in the
323 # series but as strip level default to 1, only "-p0" can actually
324 # be found in the series file, the other ones are implicit
325 m = re.match(
326 r'(?P<patchfilename>.*)\s+-p\s*(?P<striplevel>(\d+|ab)?)\s*$',
327 patch,
329 options.strip = 1
330 if m:
331 patch = m.group('patchfilename')
332 if m.group('striplevel') != '0':
333 raise CmdException("error importing quilt series, patch '%s'"
334 " has unsupported strip level: '-p%s'" %
335 (patch, m.group('striplevel')))
336 options.strip = 0
337 patchfile = os.path.join(patchdir, patch)
338 patch = __replace_slashes_with_dashes(patch)
340 __import_file(patchfile, options, patch)
342 if filename:
343 f.close()
346 def __import_mail(filename, options):
347 """Import a patch from an email file or mbox"""
348 import tempfile
349 import shutil
351 tmpdir = tempfile.mkdtemp('.stg')
352 try:
353 mail_paths = __mailsplit(tmpdir, filename, options)
354 for mail_path in mail_paths:
355 __import_mail_path(mail_path, filename, options)
356 finally:
357 shutil.rmtree(tmpdir)
360 def __mailsplit(tmpdir, filename, options):
361 mailsplit_cmd = ['git', 'mailsplit', '-d4', '-o' + tmpdir]
362 if options.mail:
363 mailsplit_cmd.append('-b')
364 if options.keep_cr:
365 mailsplit_cmd.append('--keep-cr')
367 if filename:
368 mailsplit_cmd.extend(['--', filename])
369 r = Run(*mailsplit_cmd)
370 else:
371 stdin = os.fdopen(sys.__stdin__.fileno(), 'rb')
372 r = Run(*mailsplit_cmd).encoding(None).raw_input(stdin.read())
374 num_patches = int(r.output_one_line())
376 mail_paths = [
377 os.path.join(tmpdir, '%04d' % n) for n in range(1, num_patches + 1)
380 return mail_paths
383 def __import_mail_path(mail_path, filename, options):
384 with open(mail_path, 'rb') as f:
385 mail = f.read()
387 msg_path = mail_path + '-msg'
388 patch_path = mail_path + '-patch'
390 mailinfo_lines = Run(
391 'git', 'mailinfo', msg_path, patch_path
392 ).encoding(None).decoding(None).raw_input(mail).output_lines(b'\n')
394 mailinfo = dict(line.split(b': ', 1) for line in mailinfo_lines if line)
396 with open(msg_path, 'rb') as f:
397 msg_body = f.read()
399 msg_bytes = mailinfo[b'Subject'] + b'\n\n' + msg_body
401 with open(patch_path, 'rb') as f:
402 diff = f.read()
404 __create_patch(
405 None if options.mbox else filename,
406 decode_utf8_with_latin1(msg_bytes),
407 mailinfo[b'Author'].decode('utf-8'),
408 mailinfo[b'Email'].decode('utf-8'),
409 mailinfo[b'Date'].decode('utf-8'),
410 diff,
411 options,
415 def __import_url(url, options):
416 """Import a patch from a URL
418 try:
419 from urllib.request import urlretrieve
420 from urllib.parse import unquote
421 except ImportError:
422 from urllib import urlretrieve, unquote
423 import tempfile
425 if not url:
426 raise CmdException('URL argument required')
428 patch = os.path.basename(unquote(url))
429 filename = os.path.join(tempfile.gettempdir(), patch)
430 urlretrieve(url, filename)
431 __import_file(filename, options)
434 def __import_tarfile(tar, options):
435 """Import patch series from a tar archive
437 import shutil
438 import tarfile
439 import tempfile
441 if not tarfile.is_tarfile(tar):
442 raise CmdException("%s is not a tarfile!" % tar)
444 t = tarfile.open(tar, 'r')
445 names = t.getnames()
447 # verify paths in the tarfile are safe
448 for n in names:
449 if n.startswith('/'):
450 raise CmdException("Absolute path found in %s" % tar)
451 if n.find("..") > -1:
452 raise CmdException("Relative path found in %s" % tar)
454 # find the series file
455 seriesfile = ''
456 for m in names:
457 if m.endswith('/series') or m == 'series':
458 seriesfile = m
459 break
460 if seriesfile == '':
461 raise CmdException("no 'series' file found in %s" % tar)
463 # unpack into a tmp dir
464 tmpdir = tempfile.mkdtemp('.stg')
465 t.extractall(tmpdir)
467 # apply the series
468 __import_series(os.path.join(tmpdir, seriesfile), options)
470 # cleanup the tmpdir
471 shutil.rmtree(tmpdir)
474 def func(parser, options, args):
475 """Import a GNU diff file as a new patch
477 if len(args) > 1:
478 parser.error('incorrect number of arguments')
480 if len(args) == 1:
481 filename = args[0]
482 else:
483 filename = None
485 if not options.url and filename:
486 filename = os.path.abspath(filename)
487 directory.cd_to_topdir()
489 repository = directory.repository
490 stack = repository.current_stack
491 check_local_changes(repository)
492 check_conflicts(repository.default_iw)
493 check_head_top_equal(stack)
495 if options.series:
496 __import_series(filename, options)
497 elif options.mail or options.mbox:
498 __import_mail(filename, options)
499 elif options.url:
500 __import_url(filename, options)
501 else:
502 __import_file(filename, options)