Add `stg import --message-id` option
[stgit.git] / stgit / commands / imprt.py
blob6da528c81dc70f89889cda8a380e8d9789a7d980
1 import os
2 import re
3 import sys
4 import tempfile
6 from stgit import argparse
7 from stgit.argparse import opt
8 from stgit.commands.common import (
9 CmdException,
10 DirectoryHasRepository,
11 check_conflicts,
12 check_head_top_equal,
13 check_local_changes,
14 parse_patch,
15 update_commit_data,
17 from stgit.compat import decode_utf8_with_latin1
18 from stgit.config import config
19 from stgit.lib.git import CommitData, Date, Person
20 from stgit.lib.transaction import StackTransaction, TransactionHalted
21 from stgit.out import out
22 from stgit.run import Run
24 __copyright__ = """
25 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
27 This program is free software; you can redistribute it and/or modify
28 it under the terms of the GNU General Public License version 2 as
29 published by the Free Software Foundation.
31 This program is distributed in the hope that it will be useful,
32 but WITHOUT ANY WARRANTY; without even the implied warranty of
33 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
34 GNU General Public License for more details.
36 You should have received a copy of the GNU General Public License
37 along with this program; if not, see http://www.gnu.org/licenses/.
38 """
40 name = 'import'
41 help = 'Import a GNU diff file as a new patch'
42 kind = 'patch'
43 usage = ['[options] [--] [<file>|<url>]']
44 description = """
45 Create a new patch and apply the given GNU diff file (or the standard
46 input). By default, the file name is used as the patch name but this
47 can be overridden with the '--name' option. The patch can either be a
48 normal file with the description at the top or it can have standard
49 mail format, the Subject, From and Date headers being used for
50 generating the patch information. The command can also read series and
51 mbox files.
53 If a patch does not apply cleanly, the failed diff is written to the
54 .stgit-failed.patch file and an empty StGit patch is added to the
55 stack.
57 The patch description has to be separated from the data with a '---'
58 line."""
60 args = ['files']
61 options = [
62 opt(
63 '-m',
64 '--mail',
65 action='store_true',
66 short='Import the patch from a standard e-mail file',
68 opt(
69 '-M',
70 '--mbox',
71 action='store_true',
72 short='Import a series of patches from an mbox file',
74 opt(
75 '-s',
76 '--series',
77 action='store_true',
78 short='Import a series of patches',
79 long="Import a series of patches from a series file or a tar archive.",
81 opt(
82 '-u',
83 '--url',
84 action='store_true',
85 short='Import a patch from a URL',
87 opt(
88 '-n',
89 '--name',
90 short='Use NAME as the patch name',
92 opt(
93 '-p',
94 '--strip',
95 type='int',
96 metavar='N',
97 short='Remove N leading slashes from diff paths (default 1)',
99 opt(
100 '-t',
101 '--stripname',
102 action='store_true',
103 short='Strip numbering and extension from patch name',
105 opt(
106 '-C',
107 dest='context_lines',
108 type='int',
109 metavar='N',
110 short='Ensure N lines of surrounding context for each change',
112 opt(
113 '-i',
114 '--ignore',
115 action='store_true',
116 short='Ignore the applied patches in the series',
118 opt(
119 '--replace',
120 action='store_true',
121 short='Replace the unapplied patches in the series',
123 opt(
124 '-b',
125 '--base',
126 args=['commit'],
127 short='Use BASE instead of HEAD for file importing',
129 opt(
130 '--reject',
131 action='store_true',
132 short='Leave the rejected hunks in corresponding *.rej files',
134 opt(
135 '--keep-cr',
136 action='store_true',
137 short='Do not remove "\\r" from email lines ending with "\\r\\n"',
139 opt(
140 '--message-id',
141 action='store_true',
142 short='Create Message-Id trailer from Message-ID header',
143 long=(
144 "Create Message-Id trailer in patch description based on the "
145 "Message-ID email header. This option is applicable when importing "
146 "with --mail or --mbox. This behavior may also be enabled via the "
147 "'stgit.import.message-id' configuration option."
150 opt(
151 '-e',
152 '--edit',
153 action='store_true',
154 short='Invoke an editor for the patch description',
156 opt(
157 '-d',
158 '--showdiff',
159 action='store_true',
160 short='Show the patch content in the editor buffer',
163 options.extend(argparse.author_options())
164 options.extend(argparse.trailer_options())
167 directory = DirectoryHasRepository()
170 def __create_patch(
171 filename, message, patch_name, author_name, author_email, author_date, diff, options
173 """Create a new patch on the stack"""
174 stack = directory.repository.current_stack
176 if patch_name:
177 name = patch_name
178 elif options.name:
179 name = options.name
180 elif filename:
181 name = os.path.basename(filename)
182 else:
183 name = ''
185 if options.stripname:
186 # Removing leading numbers and trailing extension
187 name = re.sub(
188 r'''^
189 (?:[0-9]+-)? # Optional leading patch number
190 (.*?) # Patch name group (non-greedy)
191 (?:\.(?:diff|patch))? # Optional .diff or .patch extension
193 ''',
194 r'\g<1>',
195 name,
196 flags=re.VERBOSE,
199 need_unique = not (options.ignore or options.replace)
201 if name:
202 name = stack.patches.make_name(name, unique=need_unique, lower=False)
203 else:
204 name = stack.patches.make_name(message, unique=need_unique, lower=True)
206 if options.ignore and name in stack.patchorder.applied:
207 out.info('Ignoring already applied patch "%s"' % name)
208 return
210 out.start('Importing patch "%s"' % name)
212 author = options.author(
213 Person(
214 author_name,
215 author_email,
216 Date.maybe(author_date),
220 try:
221 if not diff:
222 out.warn('No diff found, creating empty patch')
223 tree = stack.head.data.tree
224 else:
225 iw = stack.repository.default_iw
226 iw.apply(
227 diff,
228 quiet=False,
229 reject=options.reject,
230 strip=options.strip,
231 context_lines=options.context_lines,
233 tree = iw.index.write_tree()
235 cd = CommitData(
236 tree=tree,
237 parents=[stack.head],
238 author=author,
239 message=message,
241 cd = update_commit_data(
242 stack.repository,
244 message=None,
245 author=None,
246 trailers=options.trailers,
247 edit=options.edit,
249 commit = stack.repository.commit(cd)
251 trans = StackTransaction(stack, 'import: %s' % name)
253 try:
254 if options.replace and name in stack.patchorder.unapplied:
255 trans.delete_patches(lambda pn: pn == name, quiet=True)
257 trans.patches[name] = commit
258 trans.applied.append(name)
259 except TransactionHalted:
260 pass
261 trans.run()
262 finally:
263 out.done()
266 def __get_handle_and_name(filename):
267 """Return a file object and a patch name derived from filename"""
268 import bz2
269 import gzip
271 # see if it's a gzip'ed or bzip2'ed patch
272 for copen, ext in [(gzip.open, '.gz'), (bz2.BZ2File, '.bz2')]:
273 try:
274 f = copen(filename)
275 f.read(1)
276 f.seek(0)
277 if filename.lower().endswith(ext):
278 filename = filename[: -len(ext)]
279 return (f, filename)
280 except IOError:
281 pass
283 # plain old file...
284 return (open(filename, 'rb'), filename)
287 def __import_file(filename, options):
288 """Import a patch from a file or standard input"""
289 if filename:
290 f, filename = __get_handle_and_name(filename)
291 else:
292 f = os.fdopen(sys.__stdin__.fileno(), 'rb')
294 patch_data = f.read()
296 if filename:
297 f.close()
299 message, patch_name, author_name, author_email, author_date, diff = parse_patch(
300 patch_data, contains_diff=True, fail_on_empty_description=False
303 __create_patch(
304 filename,
305 message,
306 patch_name,
307 author_name,
308 author_email,
309 author_date,
310 diff,
311 options,
315 def __import_series(filename, options):
316 """Import a series of patches"""
317 import tarfile
319 if filename and tarfile.is_tarfile(filename):
320 __import_tarfile(filename, options)
321 return
322 elif filename:
323 f = open(filename)
324 patchdir = os.path.dirname(filename)
325 else:
326 f = sys.stdin
327 patchdir = ''
329 for line in f:
330 patch = re.sub('#.*$', '', line).strip()
331 if not patch:
332 continue
333 # Quilt can have "-p0", "-p1" or "-pab" patches stacked in the
334 # series but as strip level default to 1, only "-p0" can actually
335 # be found in the series file, the other ones are implicit
336 m = re.match(
337 r'(?P<patchfilename>.*)\s+-p\s*(?P<striplevel>(\d+|ab)?)\s*$', patch
339 if m:
340 patch = m.group('patchfilename')
341 if m.group('striplevel') != '0':
342 raise CmdException(
343 "error importing quilt series, patch '%s'"
344 " has unsupported strip level: '-p%s'"
345 % (patch, m.group('striplevel'))
347 options.strip = 0
348 else:
349 options.strip = 1
350 patchfile = os.path.join(patchdir, patch)
352 __import_file(patchfile, options)
354 if filename:
355 f.close()
358 def __import_mail(filename, options):
359 """Import a patch from an email file or mbox"""
360 with tempfile.TemporaryDirectory('.stg') as tmpdir:
361 mail_paths = __mailsplit(tmpdir, filename, options)
362 for mail_path in mail_paths:
363 __import_mail_path(mail_path, filename, options)
366 def __mailsplit(tmpdir, filename, options):
367 mailsplit_cmd = ['git', 'mailsplit', '-d4', '-o' + tmpdir]
368 if options.mail:
369 mailsplit_cmd.append('-b')
370 if options.keep_cr:
371 mailsplit_cmd.append('--keep-cr')
373 if filename:
374 mailsplit_cmd.extend(['--', filename])
375 r = Run(*mailsplit_cmd)
376 else:
377 stdin = os.fdopen(sys.__stdin__.fileno(), 'rb')
378 r = Run(*mailsplit_cmd).encoding(None).raw_input(stdin.read())
380 num_patches = int(r.output_one_line())
382 mail_paths = [os.path.join(tmpdir, '%04d' % n) for n in range(1, num_patches + 1)]
384 return mail_paths
387 def __import_mail_path(mail_path, filename, options):
388 with open(mail_path, 'rb') as f:
389 mail = f.read()
391 msg_path = mail_path + '-msg'
392 patch_path = mail_path + '-patch'
394 mailinfo_cmd = ['git', 'mailinfo']
395 if options.message_id or config.getbool('stgit.import.message-id'):
396 mailinfo_cmd.append('--message-id')
397 mailinfo_cmd.extend([msg_path, patch_path])
399 mailinfo_lines = (
400 Run(*mailinfo_cmd)
401 .encoding(None)
402 .decoding(None)
403 .raw_input(mail)
404 .output_lines(b'\n')
407 mailinfo = dict(line.split(b': ', 1) for line in mailinfo_lines if line)
409 with open(msg_path, 'rb') as f:
410 msg_body = f.read()
412 msg_bytes = mailinfo[b'Subject'] + b'\n\n' + msg_body
414 with open(patch_path, 'rb') as f:
415 diff = f.read()
417 __create_patch(
418 None if options.mbox else filename,
419 decode_utf8_with_latin1(msg_bytes),
420 None,
421 mailinfo[b'Author'].decode('utf-8'),
422 mailinfo[b'Email'].decode('utf-8'),
423 mailinfo[b'Date'].decode('utf-8'),
424 diff,
425 options,
429 def __import_url(url, options):
430 """Import a patch from a URL"""
431 from urllib.parse import unquote
432 from urllib.request import urlretrieve
434 with tempfile.TemporaryDirectory('.stg') as tmpdir:
435 base = os.path.basename(unquote(url))
436 filename = os.path.join(tmpdir, base)
437 urlretrieve(url, filename)
438 if options.series:
439 __import_series(filename, options)
440 elif options.mail or options.mbox:
441 __import_mail(filename, options)
442 else:
443 __import_file(filename, options)
446 def __import_tarfile(tarpath, options):
447 """Import patch series from a tar archive"""
448 import tarfile
450 assert tarfile.is_tarfile(tarpath)
452 tar = tarfile.open(tarpath, 'r')
453 names = tar.getnames()
455 # verify paths in the tarfile are safe
456 for n in names:
457 if n.startswith('/'):
458 raise CmdException("Absolute path found in %s" % tarpath)
459 if n.find("..") > -1:
460 raise CmdException("Relative path found in %s" % tarpath)
462 # find the series file
463 for seriesfile in names:
464 if seriesfile.endswith('/series') or seriesfile == 'series':
465 break
466 else:
467 raise CmdException("no 'series' file found in %s" % tarpath)
469 # unpack into a tmp dir
470 with tempfile.TemporaryDirectory('.stg') as tmpdir:
471 tar.extractall(tmpdir)
472 __import_series(os.path.join(tmpdir, seriesfile), options)
475 def func(parser, options, args):
476 if len(args) > 1:
477 parser.error('incorrect number of arguments')
478 elif len(args) == 1:
479 filename = args[0]
480 elif options.url:
481 raise CmdException('URL argument required')
482 else:
483 filename = None
485 if not options.url and filename:
486 filename = os.path.abspath(filename)
488 directory.cd_to_topdir()
490 repository = directory.repository
491 stack = repository.current_stack
493 check_local_changes(repository)
494 check_conflicts(repository.default_iw)
495 check_head_top_equal(stack)
497 if options.url:
498 __import_url(filename, options)
499 elif options.series:
500 __import_series(filename, options)
501 elif options.mail or options.mbox:
502 __import_mail(filename, options)
503 else:
504 __import_file(filename, options)