Fix the import --url command
[stgit] / stgit / commands / imprt.py
index 9001639..8067beb 100644 (file)
@@ -15,191 +15,122 @@ along with this program; if not, write to the Free Software
 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 """
 
-import sys, os
-from optparse import OptionParser, make_option
-
+import sys, os, re, email, tarfile
+from mailbox import UnixMailbox
+from StringIO import StringIO
+from stgit.argparse import opt
 from stgit.commands.common import *
 from stgit.utils import *
-from stgit import stack, git
-
-
-help = 'import a GNU diff file as a new patch'
-usage = """%prog [options] [<file>|<commit>]
-
-Create a new patch and import the given GNU diff file (defaulting to
-the standard input) or a given commit object into it. By default, the
-file name is used as the patch name but this can be overriden with the
-'--name' option.
-
-The patch file can either be a normal file with the description at the
-top or it can have standard mail format, the Subject, From and Date
-headers being used for generating the patch information. The patch
-description has to be separated from the data with a '---' line. For a
-normal file, if no author information is given, the first
-'Signed-off-by:' line is used.
-
-When a commit object is imported, the log and author information are
-those of the commit object. Passing the '--reverse' option will cancel
-an existing commit object."""
-
-options = [make_option('-m', '--mail',
-                       help = 'import the patch from a standard e-mail file',
-                       action = 'store_true'),
-           make_option('-c', '--commit',
-                       help = 'import a commit object as a patch',
-                       action = 'store_true'),
-           make_option('--reverse',
-                       help = 'reverse the commit object before importing',
-                       action = 'store_true'),
-           make_option('-n', '--name',
-                       help = 'use NAME as the patch name'),
-           make_option('--base',
-                       help = 'use BASE instead of HEAD for file importing'),
-           make_option('-e', '--edit',
-                       help = 'invoke an editor for the patch description',
-                       action = 'store_true'),
-           make_option('-s', '--showpatch',
-                       help = 'show the patch content in the editor buffer',
-                       action = 'store_true'),
-           make_option('-a', '--author', metavar = '"NAME <EMAIL>"',
-                       help = 'use "NAME <EMAIL>" as the author details'),
-           make_option('--authname',
-                       help = 'use AUTHNAME as the author name'),
-           make_option('--authemail',
-                       help = 'use AUTHEMAIL as the author e-mail'),
-           make_option('--authdate',
-                       help = 'use AUTHDATE as the author date'),
-           make_option('--commname',
-                       help = 'use COMMNAME as the committer name'),
-           make_option('--commemail',
-                       help = 'use COMMEMAIL as the committer e-mail')]
-
-
-def __parse_mail(filename = None):
-    """Parse the input file in a mail format and return (description,
-    authname, authemail, authdate)
-    """
-    if filename:
-        f = file(filename)
-    else:
-        f = sys.stdin
-
-    descr = authname = authemail = authdate = None
-
-    # parse the headers
-    while True:
-        line = f.readline()
-        if not line:
-            break
-        line = line.strip()
-        if re.match('from:\s+', line, re.I):
-            auth = re.findall('^.*?:\s+(.*)$', line)[0]
-            authname, authemail = name_email(auth)
-        elif re.match('date:\s+', line, re.I):
-            authdate = re.findall('^.*?:\s+(.*)$', line)[0]
-        elif re.match('subject:\s+', line, re.I):
-            descr = re.findall('^.*?:\s+(.*)$', line)[0]
-        elif line == '':
-            # end of headers
-            break
-
-    # remove the '[*PATCH*]' expression in the subject
-    if descr:
-        descr = re.findall('^(\[[^\s]*[Pp][Aa][Tt][Cc][Hh].*?\])?\s*(.*)$',
-                           descr)[0][1]
-        descr += '\n\n'
-    else:
-        raise CmdException, 'Subject: line not found'
-
-    # the rest of the patch description
-    while True:
-        line = f.readline()
-        if not line:
-            break
-        if re.match('---\s*$', line) or re.match('diff -', line) or \
-                re.match('^Index: ', line):
-            break
-        else:
-            descr += line
-    descr.rstrip()
-
-    if filename:
-        f.close()
-
-    return (descr, authname, authemail, authdate)
-
-def __parse_patch(filename = None):
-    """Parse the input file and return (description, authname,
-    authemail, authdate)
-    """
-    if filename:
-        f = file(filename)
-    else:
-        f = sys.stdin
-
-    authname = authemail = authdate = None
-
-    descr = ''
-    while True:
-        line = f.readline()
-        if not line:
-            break
-
-        # the first 'Signed-of-by:' is the author
-        if not authname and re.match('signed-off-by:\s+', line, re.I):
-            auth = re.findall('^.*?:\s+(.*)$', line)[0]
-            authname, authemail = name_email(auth)
-
-        if re.match('---\s*$', line) or re.match('diff -', line):
-            break
-        else:
-            descr += line
-    descr.rstrip()
-
-    if descr == '':
-        descr = None
-
-    if filename:
-        f.close()
-
-    return (descr, authname, authemail, authdate)
-
-def import_file(parser, options, args):
-    """Import a GNU diff file as a new patch
+from stgit.out import *
+from stgit import argparse, stack, git
+
+name = 'import'
+help = 'Import a GNU diff file as a new patch'
+kind = 'patch'
+usage = ['[options] [<file>|<url>]']
+description = """
+Create a new patch and apply the given GNU diff file (or the standard
+input). By default, the file name is used as the patch name but this
+can be overridden with the '--name' option. The patch can either be a
+normal file with the description at the top or it can have standard
+mail format, the Subject, From and Date headers being used for
+generating the patch information. The command can also read series and
+mbox files.
+
+If a patch does not apply cleanly, the failed diff is written to the
+.stgit-failed.patch file and an empty StGIT patch is added to the
+stack.
+
+The patch description has to be separated from the data with a '---'
+line."""
+
+args = [argparse.files]
+options = [
+    opt('-m', '--mail', action = 'store_true',
+        short = 'Import the patch from a standard e-mail file'),
+    opt('-M', '--mbox', action = 'store_true',
+        short = 'Import a series of patches from an mbox file'),
+    opt('-s', '--series', action = 'store_true',
+        short = 'Import a series of patches', long = """
+        Import a series of patches from a series file or a tar archive."""),
+    opt('-u', '--url', action = 'store_true',
+        short = 'Import a patch from a URL'),
+    opt('-n', '--name',
+        short = 'Use NAME as the patch name'),
+    opt('-t', '--strip', action = 'store_true',
+        short = 'Strip numbering and extension from patch name'),
+    opt('-i', '--ignore', action = 'store_true',
+        short = 'Ignore the applied patches in the series'),
+    opt('--replace', action = 'store_true',
+        short = 'Replace the unapplied patches in the series'),
+    opt('-b', '--base', args = [argparse.commit],
+        short = 'Use BASE instead of HEAD for file importing'),
+    opt('--reject', action = 'store_true',
+        short = 'leave the rejected hunks in corresponding *.rej files'),
+    opt('-e', '--edit', action = 'store_true',
+        short = 'Invoke an editor for the patch description'),
+    opt('-p', '--showpatch', action = 'store_true',
+        short = 'Show the patch content in the editor buffer'),
+    opt('-a', '--author', metavar = '"NAME <EMAIL>"',
+        short = 'Use "NAME <EMAIL>" as the author details'),
+    opt('--authname',
+        short = 'Use AUTHNAME as the author name'),
+    opt('--authemail',
+        short = 'Use AUTHEMAIL as the author e-mail'),
+    opt('--authdate',
+        short = 'Use AUTHDATE as the author date'),
+    ] + argparse.sign_options()
+
+directory = DirectoryHasRepository(log = True)
+
+def __strip_patch_name(name):
+    stripped = re.sub('^[0-9]+-(.*)$', '\g<1>', name)
+    stripped = re.sub('^(.*)\.(diff|patch)$', '\g<1>', stripped)
+
+    return stripped
+
+def __replace_slashes_with_dashes(name):
+    stripped = name.replace('/', '-')
+
+    return stripped
+
+def __create_patch(filename, message, author_name, author_email,
+                   author_date, diff, options):
+    """Create a new patch on the stack
     """
-    if len(args) > 1:
-        parser.error('incorrect number of arguments')
-    elif len(args) == 1:
-        filename = args[0]
-    else:
-        filename = None
-
     if options.name:
         patch = options.name
     elif filename:
         patch = os.path.basename(filename)
     else:
-        raise CmdException, 'Unkown patch name'
+        patch = ''
+    if options.strip:
+        patch = __strip_patch_name(patch)
 
-    # the defaults
-    message = author_name = author_email = author_date = committer_name = \
-              committer_email = None
-
-    if options.author:
-        options.authname, options.authemail = name_email(options.author)
-
-    if options.mail:
-        message, author_name, author_email, author_date = \
-                 __parse_mail(filename)
+    if not patch:
+        if options.ignore or options.replace:
+            unacceptable_name = lambda name: False
+        else:
+            unacceptable_name = crt_series.patch_exists
+        patch = make_patch_name(message, unacceptable_name)
     else:
-        message, author_name, author_email, author_date = \
-                 __parse_patch(filename)
+        # fix possible invalid characters in the patch name
+        patch = re.sub('[^\w.]+', '-', patch).strip('-')
+
+    if options.ignore and patch in crt_series.get_applied():
+        out.info('Ignoring already applied patch "%s"' % patch)
+        return
+    if options.replace and patch in crt_series.get_unapplied():
+        crt_series.delete_patch(patch, keep_log = True)
 
     # refresh_patch() will invoke the editor in this case, with correct
     # patch content
     if not message:
         can_edit = False
 
+    if options.author:
+        options.authname, options.authemail = name_email(options.author)
+
     # override the automatically parsed settings
     if options.authname:
         author_name = options.authname
@@ -207,91 +138,206 @@ def import_file(parser, options, args):
         author_email = options.authemail
     if options.authdate:
         author_date = options.authdate
-    if options.commname:
-        committer_name = options.commname
-    if options.commemail:
-        committer_email = options.commemail
 
     crt_series.new_patch(patch, message = message, can_edit = False,
                          author_name = author_name,
                          author_email = author_email,
-                         author_date = author_date,
-                         committer_name = committer_name,
-                         committer_email = committer_email)
+                         author_date = author_date)
 
-    print 'Importing patch %s...' % patch,
-    sys.stdout.flush()
+    if not diff:
+        out.warn('No diff found, creating empty patch')
+    else:
+        out.start('Importing patch "%s"' % patch)
+        if options.base:
+            base = git_id(crt_series, options.base)
+        else:
+            base = None
+        git.apply_patch(diff = diff, base = base, reject = options.reject)
+        crt_series.refresh_patch(edit = options.edit,
+                                 show_patch = options.showpatch,
+                                 sign_str = options.sign_str,
+                                 backup = False)
+        out.done()
+
+def __mkpatchname(name, suffix):
+    if name.lower().endswith(suffix.lower()):
+        return name[:-len(suffix)]
+    return name
+
+def __get_handle_and_name(filename):
+    """Return a file object and a patch name derived from filename
+    """
+    # see if it's a gzip'ed or bzip2'ed patch
+    import bz2, gzip
+    for copen, ext in [(gzip.open, '.gz'), (bz2.BZ2File, '.bz2')]:
+        try:
+            f = copen(filename)
+            f.read(1)
+            f.seek(0)
+            return (f, __mkpatchname(filename, ext))
+        except IOError, e:
+            pass
+
+    # plain old file...
+    return (open(filename), filename)
+
+def __import_file(filename, options, patch = None):
+    """Import a patch from a file or standard input
+    """
+    pname = None
+    if filename:
+        (f, pname) = __get_handle_and_name(filename)
+    else:
+        f = sys.stdin
 
-    if options.base:
-        orig_head = git.get_head()
-        git.switch(options.base)
+    if patch:
+        pname = patch
+    elif not pname:
+        pname = filename
 
+    if options.mail:
         try:
-            git.apply_patch(filename)
-        except git.GitException, ex:
-            print >> sys.stderr, '"git apply" failed'
-            git.switch(orig_head)
-            raise
-
-        top = crt_series.refresh_patch(commit_only = True)
-        git.switch(orig_head)
-        git.merge(options.base, orig_head, top)
+            msg = email.message_from_file(f)
+        except Exception, ex:
+            raise CmdException, 'error parsing the e-mail file: %s' % str(ex)
+        message, author_name, author_email, author_date, diff = \
+                 parse_mail(msg)
     else:
-        git.apply_patch(filename)
+        message, author_name, author_email, author_date, diff = \
+                 parse_patch(f.read(), contains_diff = True)
 
-    crt_series.refresh_patch(edit = options.edit,
-                             show_patch = options.showpatch)
+    if filename:
+        f.close()
 
-    print 'done'
-    print_crt_patch()
+    __create_patch(pname, message, author_name, author_email,
+                   author_date, diff, options)
 
-def import_commit(parser, options, args):
-    """Import a commit object as a new patch
+def __import_series(filename, options):
+    """Import a series of patches
     """
-    if len(args) != 1:
-        parser.error('incorrect number of arguments')
+    applied = crt_series.get_applied()
 
-    commit_id = args[0]
-
-    if options.name:
-        patch = options.name
+    if filename:
+        if tarfile.is_tarfile(filename):
+            __import_tarfile(filename, options)
+            return
+        f = file(filename)
+        patchdir = os.path.dirname(filename)
     else:
-        raise CmdException, 'Unkown patch name'
+        f = sys.stdin
+        patchdir = ''
 
-    commit = git.Commit(commit_id)
+    for line in f:
+        patch = re.sub('#.*$', '', line).strip()
+        if not patch:
+            continue
+        patchfile = os.path.join(patchdir, patch)
+        patch = __replace_slashes_with_dashes(patch);
+
+        __import_file(patchfile, options, patch)
+
+    if filename:
+        f.close()
 
-    if not options.reverse:
-        bottom = commit.get_parent()
-        top = commit_id
+def __import_mbox(filename, options):
+    """Import a series from an mbox file
+    """
+    if filename:
+        f = file(filename, 'rb')
     else:
-        bottom = commit_id
-        top = commit.get_parent()
+        f = StringIO(sys.stdin.read())
 
-    message = commit.get_log()
-    author_name, author_email, author_date = \
-                 name_email_date(commit.get_author())
+    try:
+        mbox = UnixMailbox(f, email.message_from_file)
+    except Exception, ex:
+        raise CmdException, 'error parsing the mbox file: %s' % str(ex)
 
-    print 'Importing commit %s...' % commit_id,
-    sys.stdout.flush()
+    for msg in mbox:
+        message, author_name, author_email, author_date, diff = \
+                 parse_mail(msg)
+        __create_patch(None, message, author_name, author_email,
+                       author_date, diff, options)
 
-    crt_series.new_patch(patch, message = message, can_edit = False,
-                         unapplied = True, bottom = bottom, top = top,
-                         author_name = author_name,
-                         author_email = author_email,
-                         author_date = author_date)
-    crt_series.push_patch(patch)
+    f.close()
+
+def __import_url(url, options):
+    """Import a patch from a URL
+    """
+    import urllib
+    import tempfile
+
+    if not url:
+        raise CmdException('URL argument required')
+
+    patch = os.path.basename(urllib.unquote(url))
+    filename = os.path.join(tempfile.gettempdir(), patch)
+    urllib.urlretrieve(url, filename)
+    __import_file(filename, options)
+
+def __import_tarfile(tar, options):
+    """Import patch series from a tar archive
+    """
+    import tempfile
+    import shutil
+
+    if not tarfile.is_tarfile(tar):
+        raise CmdException, "%s is not a tarfile!" % tar
+
+    t = tarfile.open(tar, 'r')
+    names = t.getnames()
+
+    # verify paths in the tarfile are safe
+    for n in names:
+        if n.startswith('/'):
+            raise CmdException, "Absolute path found in %s" % tar
+        if n.find("..") > -1:
+            raise CmdException, "Relative path found in %s" % tar
+
+    # find the series file
+    seriesfile = '';
+    for m in names:
+        if m.endswith('/series') or m == 'series':
+            seriesfile = m
+            break
+    if seriesfile == '':
+        raise CmdException, "no 'series' file found in %s" % tar
+
+    # unpack into a tmp dir
+    tmpdir = tempfile.mkdtemp('.stg')
+    t.extractall(tmpdir)
 
-    print 'done'
-    print_crt_patch()
+    # apply the series
+    __import_series(os.path.join(tmpdir, seriesfile), options)
+
+    # cleanup the tmpdir
+    shutil.rmtree(tmpdir)
 
 def func(parser, options, args):
-    """Import a GNU diff file or a commit object as a new patch
+    """Import a GNU diff file as a new patch
     """
+    if len(args) > 1:
+        parser.error('incorrect number of arguments')
+
     check_local_changes()
     check_conflicts()
-    check_head_top_equal()
+    check_head_top_equal(crt_series)
 
-    if options.commit:
-        import_commit(parser, options, args)
+    if len(args) == 1:
+        filename = args[0]
+    else:
+        filename = None
+
+    if not options.url and filename:
+        filename = os.path.abspath(filename)
+    directory.cd_to_topdir()
+
+    if options.series:
+        __import_series(filename, options)
+    elif options.mbox:
+        __import_mbox(filename, options)
+    elif options.url:
+        __import_url(filename, options)
     else:
-        import_file(parser, options, args)
+        __import_file(filename, options)
+
+    print_crt_patch(crt_series)