Add mbox support to "import"
[stgit] / stgit / commands / imprt.py
index de735c4..9c97498 100644 (file)
@@ -15,7 +15,9 @@ along with this program; if not, write to the Free Software
 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 """
 
 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 """
 
-import sys, os
+import sys, os, re, email
+from email.Header import decode_header, make_header
+from mailbox import UnixMailbox
 from optparse import OptionParser, make_option
 
 from stgit.commands.common import *
 from optparse import OptionParser, make_option
 
 from stgit.commands.common import *
@@ -28,20 +30,47 @@ usage = """%prog [options] [<file>]
 
 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
 
 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 overriden with the '--name' option. The patch can either be a
+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
 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.
+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 '---'
 
 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."""
+line."""
 
 options = [make_option('-m', '--mail',
                        help = 'import the patch from a standard e-mail file',
                        action = 'store_true'),
 
 options = [make_option('-m', '--mail',
                        help = 'import the patch from a standard e-mail file',
                        action = 'store_true'),
+           make_option('-M', '--mbox',
+                       help = 'import a series of patches from an mbox file',
+                       action = 'store_true'),
+           make_option('-s', '--series',
+                       help = 'import a series of patches',
+                       action = 'store_true'),
            make_option('-n', '--name',
                        help = 'use NAME as the patch name'),
            make_option('-n', '--name',
                        help = 'use NAME as the patch name'),
+           make_option('-t', '--strip',
+                       help = 'strip numbering and extension from patch name',
+                       action = 'store_true'),
+           make_option('-i', '--ignore',
+                       help = 'ignore the applied patches in the series',
+                       action = 'store_true'),
+           make_option('--replace',
+                       help = 'replace the unapplied patches in the series',
+                       action = 'store_true'),
+           make_option('-b', '--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('-p', '--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',
            make_option('-a', '--author', metavar = '"NAME <EMAIL>"',
                        help = 'use "NAME <EMAIL>" as the author details'),
            make_option('--authname',
@@ -56,116 +85,173 @@ options = [make_option('-m', '--mail',
                        help = 'use COMMEMAIL as the committer e-mail')]
 
 
                        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)
+def __end_descr(line):
+    return re.match('---\s*$', line) or re.match('diff -', line) or \
+            re.match('Index: ', line)
+
+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 __parse_description(descr):
+    """Parse the patch description and return the new description and
+    author information (if any).
     """
     """
-    if filename:
-        f = file(filename)
-    else:
-        f = sys.stdin
+    subject = body = ''
+    authname = authemail = authdate = None
 
 
-    descr = authname = authemail = authdate = None
+    descr_lines = [line.rstrip() for line in  descr.split('\n')]
+    if not descr_lines:
+        raise CmdException, "Empty patch description"
 
 
-    # parse the headers
-    for line in f:
-        line = line.strip()
-        if re.match('from:\s+', line, re.I):
-            auth = re.findall('^.*?:\s+(.*)$', line)[0]
+    lasthdr = 0
+    end = len(descr_lines)
+
+    # Parse the patch header
+    for pos in range(0, end):
+        if not descr_lines[pos]:
+           continue
+        # check for a "From|Author:" line
+        if re.match('\s*(?:from|author):\s+', descr_lines[pos], re.I):
+            auth = re.findall('^.*?:\s+(.*)$', descr_lines[pos])[0]
             authname, authemail = name_email(auth)
             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
+            lasthdr = pos + 1
+            continue
+        # check for a "Date:" line
+        if re.match('\s*date:\s+', descr_lines[pos], re.I):
+            authdate = re.findall('^.*?:\s+(.*)$', descr_lines[pos])[0]
+            lasthdr = pos + 1
+            continue
+        if subject:
             break
             break
+        # get the subject
+        subject = descr_lines[pos]
+        lasthdr = pos + 1
+
+    # get the body
+    if lasthdr < end:
+        body = reduce(lambda x, y: x + '\n' + y, descr_lines[lasthdr:], '')
+
+    return (subject + body, authname, authemail, authdate)
+
+def __parse_mail(msg):
+    """Parse the message object and return (description, authname,
+    authemail, authdate, diff)
+    """
+    def __decode_header(header):
+        """Decode a qp-encoded e-mail header as per rfc2047"""
+        try:
+            words_enc = decode_header(header)
+            hobj = make_header(words_enc)
+        except Exception, ex:
+            raise CmdException, 'header decoding error: %s' % str(ex)
+        return unicode(hobj).encode('utf-8')
+
+    # parse the headers
+    if msg.has_key('from'):
+        authname, authemail = name_email(__decode_header(msg['from']))
+    else:
+        authname = authemail = None
+
+    # '\n\t' can be found on multi-line headers
+    descr = __decode_header(msg['subject']).replace('\n\t', ' ')
+    authdate = msg['date']
 
     # remove the '[*PATCH*]' expression in the subject
     if descr:
 
     # remove the '[*PATCH*]' expression in the subject
     if descr:
-        descr = re.findall('^(\[[^\s]*PATCH.*?\])?\s*(.*)$', descr)[0][1]
+        descr = re.findall('^(\[[^\s]*[Pp][Aa][Tt][Cc][Hh].*?\])?\s*(.*)$',
+                           descr)[0][1]
         descr += '\n\n'
     else:
         raise CmdException, 'Subject: line not found'
 
         descr += '\n\n'
     else:
         raise CmdException, 'Subject: line not found'
 
-    # the rest of the patch description
-    for line in f:
-        if re.match('----*\s*$', line) or re.match('diff -', line):
-            break
-        else:
-            descr += line
+    # the rest of the message
+    if msg.is_multipart():
+        # this is assuming that the first part is the patch
+        # description and the second part is the attached patch
+        descr += msg.get_payload(0).get_payload(decode = True)
+        diff = msg.get_payload(1).get_payload(decode = True)
+    else:
+        diff = msg.get_payload(decode = True)
+
+        for line in diff.split('\n'):
+            if __end_descr(line):
+                break
+            descr += line + '\n'
+
     descr.rstrip()
 
     descr.rstrip()
 
-    if filename:
-        f.close()
+    # parse the description for author information
+    descr, descr_authname, descr_authemail, descr_authdate = \
+           __parse_description(descr)
+    if descr_authname:
+        authname = descr_authname
+    if descr_authemail:
+        authemail = descr_authemail
+    if descr_authdate:
+       authdate = descr_authdate
 
 
-    return (descr, authname, authemail, authdate)
+    return (descr, authname, authemail, authdate, diff)
 
 
-def __parse_patch(filename = None):
+def __parse_patch(fobj):
     """Parse the input file and return (description, authname,
     """Parse the input file and return (description, authname,
-    authemail, authdate)
+    authemail, authdate, diff)
     """
     """
-    if filename:
-        f = file(filename)
-    else:
-        f = sys.stdin
-
-    authname = authemail = authdate = None
-
     descr = ''
     descr = ''
-    for line in f:
-        # 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)
+    while True:
+        line = fobj.readline()
+        if not line:
+            break
 
 
-        if re.match('----*\s*$', line) or re.match('diff -', line):
+        if __end_descr(line):
             break
         else:
             descr += line
     descr.rstrip()
 
             break
         else:
             descr += line
     descr.rstrip()
 
-    if descr == '':
-        descr = None
+    diff = fobj.read()
 
 
-    if filename:
-        f.close()
+    descr, authname, authemail, authdate = __parse_description(descr)
 
 
-    return (descr, authname, authemail, authdate)
+    # we don't yet have an agreed place for the creation date.
+    # Just return None
+    return (descr, authname, authemail, authdate, diff)
 
 
-def func(parser, options, args):
-    """Import a GNU diff file as a new patch
+def __create_patch(patch, 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')
+    if not diff:
+        raise CmdException, 'No diff found inside the patch'
 
 
-    check_local_changes()
-    check_conflicts()
-    check_head_top_equal()
+    if not patch:
+        patch = make_patch_name(message, crt_series.patch_exists,
+                                alternative = not (options.ignore
+                                                   or options.replace))
 
 
-    if len(args) == 1:
-        filename = args[0]
-        patch = os.path.basename(filename)
-    elif options.name:
-        filename = None
-        patch = options.name
-    else:
-        raise CmdException, 'Unkown patch name'
+    if options.ignore and patch in crt_series.get_applied():
+        print 'Ignoring already applied patch "%s"' % patch
+        return
+    if options.replace and patch in crt_series.get_unapplied():
+        crt_series.delete_patch(patch)
+
+    # refresh_patch() will invoke the editor in this case, with correct
+    # patch content
+    if not message:
+        can_edit = False
 
 
-    # the defaults
-    message = author_name = author_email = author_date = committer_name = \
-              committer_email = None
+    committer_name = committer_email = None
 
     if options.author:
         options.authname, options.authemail = name_email(options.author)
 
 
     if options.author:
         options.authname, options.authemail = name_email(options.author)
 
-    if options.mail:
-        message, author_name, author_email, author_date = \
-                 __parse_mail(filename)
-    else:
-        message, author_name, author_email, author_date = \
-                 __parse_patch(filename)
-
     # override the automatically parsed settings
     if options.authname:
         author_name = options.authname
     # override the automatically parsed settings
     if options.authname:
         author_name = options.authname
@@ -178,18 +264,129 @@ def func(parser, options, args):
     if options.commemail:
         committer_email = options.commemail
 
     if options.commemail:
         committer_email = options.commemail
 
-    crt_series.new_patch(patch, message = message,
+    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_name = author_name,
                          author_email = author_email,
                          author_date = author_date,
                          committer_name = committer_name,
                          committer_email = committer_email)
 
-    print 'Importing patch %s...' % patch,
+    print 'Importing patch "%s"...' % patch,
     sys.stdout.flush()
 
     sys.stdout.flush()
 
-    git.apply_patch(filename)
-    crt_series.refresh_patch()
+    if options.base:
+        git.apply_patch(diff = diff, base = git_id(options.base))
+    else:
+        git.apply_patch(diff = diff)
+
+    crt_series.refresh_patch(edit = options.edit,
+                             show_patch = options.showpatch)
+
+    print 'done'    
+
+def __import_file(patch, filename, options):
+    """Import a patch from a file or standard input
+    """
+    if filename:
+        f = file(filename)
+    else:
+        f = sys.stdin
+
+    if options.mail:
+        try:
+            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:
+        message, author_name, author_email, author_date, diff = \
+                 __parse_patch(f)
+
+    if filename:
+        f.close()
+
+    __create_patch(patch, message, author_name, author_email,
+                   author_date, diff, options)
+
+def __import_series(filename, options):
+    """Import a series of patches
+    """
+    applied = crt_series.get_applied()
+
+    if filename:
+        f = file(filename)
+        patchdir = os.path.dirname(filename)
+    else:
+        f = sys.stdin
+        patchdir = ''
+
+    for line in f:
+        patch = re.sub('#.*$', '', line).strip()
+        if not patch:
+            continue
+        patchfile = os.path.join(patchdir, patch)
+
+        if options.strip:
+            patch = __strip_patch_name(patch)
+        patch = __replace_slashes_with_dashes(patch);
+
+        __import_file(patch, patchfile, options)
+
+    if filename:
+        f.close()
+
+def __import_mbox(filename, options):
+    """Import a series from an mbox file
+    """
+    if filename:
+        f = file(filename, 'rb')
+    else:
+        f = sys.stdin
+
+    try:
+        mbox = UnixMailbox(f, email.message_from_file)
+    except Exception, ex:
+        raise CmdException, 'error parsing the mbox file: %s' % str(ex)
+
+    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)
+
+    if filename:
+        f.close()
+
+def func(parser, options, args):
+    """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()
+
+    if len(args) == 1:
+        filename = args[0]
+    else:
+        filename = None
+
+    if options.series:
+        __import_series(filename, options)
+    elif options.mbox:
+        __import_mbox(filename, options)
+    else:
+        if options.name:
+            patch = options.name
+        elif filename:
+            patch = os.path.basename(filename)
+        else:
+            patch = ''
+        if options.strip:
+            patch = __strip_patch_name(patch)
+
+        __import_file(patch, filename, options)
 
 
-    print 'done'
     print_crt_patch()
     print_crt_patch()