#!/usr/bin/env python

# import a patch to subversion
# (C) 2006 Ian Wienand <ianw@ieee.org>
# released to public domain

# todo
#  * make faster
#  * check if directories are empty
import os, sys, re, string, pty
import pysvn, tempfile
from optparse import OptionParser

svnclient = pysvn.Client()

class Patch:
    '''Object to represent and apply a patch to a file'''
    def __init__(self, fromfile, tofile, lines, raw, strip):
        self.strip = strip
        self.orig_fromfile = fromfile
        self.fromfile = self.strip_filename(fromfile, strip)
        if (tofile == "/dev/null"):
            # in this case we are deleting the file.  We actually want
            # to delete the original from file, however, not /dev/null
            self.orig_tofile = fromfile
            self.tofile = self.fromfile
        else:
            # otherwise figure out the file as usual
            self.orig_tofile = tofile
            self.tofile = self.strip_filename(tofile, strip)
        (self.fromstart, self.fromlines, self.tostart, self.tolines) = lines
        self.raw = raw
        self.have_read = False
        #print "%s (%d,%d) -> %s (%d,%d)" % (self.fromfile, self.fromstart, self.fromlines,
        #                                    self.tofile, self.tostart, self.tolines)
        if not self.is_new():
            if not os.path.exists(self.tofile) and not self.orig_tofile == "/dev/null":
                print "Can't see file to patch (patch specified %s -- perhaps wrong -p option?)" % (self.orig_tofile)
                sys.exit(1)

    def strip_filename(self, filename, level):
        "Strip filename a-la patch -p"
        #if no -p option, return the basename, as patch does
        if level == -1:
            return os.path.basename(filename)
        else:
            return string.join(os.path.normpath(filename).split("/")[level:], "/")
    def is_new(self):
        "Does this patch describe a new file"
        return (self.fromstart == 0 and self.fromlines == 0)
    def is_del(self):
        "Does this patch remove a file"
        return (self.tostart == 0 and self.tolines == 0)            
    def __str__(self):
        return self.raw

    def apply_patch(self):
        "Apply patch"
        # it's too hard to get the input into patch via stdin and
        # also spawn a process that can interact with the user, so use
        # a tempfile
        tfd, tfn = tempfile.mkstemp()
        os.write(tfd, self.raw)
        ret = os.spawnv(os.P_WAIT, "/usr/bin/patch",  ["/usr/bin/patch", "-p" , `max(0,self.strip)`, "-i", tfn])
        os.close(tfd)
        os.unlink(tfn)

        if ret == 0 and self.is_new():
            print "adding %s to SVN" % (self.tofile)
            try:
                svnclient.add(self.tofile)
            except pysvn.ClientError,e :
                if "is not a working copy" in e[0]:
                    # if it doesn't work, check parent directories are
                    # registered.  We just do the stupid thing and add
                    # all directories up the tree, already added
                    # directories don't hurt
                    parents = os.path.dirname(self.tofile).split("/")
                    for i in range(0, len(parents)):
                        p = string.join(parents[0:i+1], "/")
                        print "adding parent directory %s" % (p)
                        svnclient.add(p)
                    # re-add the original
                    svnclient.add(self.tofile)
                else:
                    raise
                
        if ret == 0 and self.is_del():
            print "removing %s from SVN" % (self.tofile)
            try:
                s = os.stat(self.tofile)
                if not (s[6] == 0): #size
                    print "WARNING: patch removed file %s, but it is not empty!  It has not been scheduled for removal" % (self.tofile)
                    return False
            except OSError:
                # looks like the file is already gone, good.
                pass
            svnclient.remove(self.tofile, force=True)

        if ret and (self.is_new() or self.is_del()):
            # we have a situtation where a new or deleted file has
            # come back with an error from patch.  This is not great
            # and we just bail out
            print "WARNING: patch failed for new or removed file %s : you need to fix this by hand" % (self.tofile)
            return False

        if ret:
            # in this case, we probably had a .rej or something.  this
            # will stop us commiting automatically.
            print "WARNING: patch failed for %s" % (self.tofile)
            return False

        return True
            
class Diff:
    '''
    A class to describe a diff file.  Essentially sets up a list of
    patch objects.
    '''
    def __init__(self, filename, strip):
        self.patches = []
        readingpatch = 0
        print "Processing %s..." % (filename)
        for line in open(filename, 'r').readlines():
            if (readingpatch == 1):
                # we are only really interested in the first hunk of
                # the patch for a file, to see if it is a new/removed
                # file.  the other hunks we just snarf into the raw
                # patch for the file.
                # why match '\'? -- patch puts in an error like
                # \ No newline at end of file
                # for incomplete lines (i.e. lines without a \n)
                if (line[0] in (' ','+','-','@','\\')):
                    patch["raw"] += line
                    continue
                else:
                    readingpatch = 0
                    self.patches.append(Patch(patch["fromfile"], patch["tofile"],
                                              patch["lines"], patch["raw"],
                                              strip))
                    continue
            #if we got here, we're starting a new file
            if (line[:3] == "---"):
                patch = {}
                r = re.compile("--- (?P<filename>.*?)(\s)")
                m = r.match(line)
                patch["fromfile"] = m.group("filename")
                patch["raw"] = line
                continue
            if (line[:3] == "+++"):
                r = re.compile("\+\+\+ (?P<filename>.*?)(\s)")
                m = r.match(line)
                patch["tofile"] = m.group("filename")
                patch["raw"] += line
                continue
            if (line[:2] == "@@"):
                # match a line like @@ -x,x +y,y @@
                # ,x and ,y are optional, and default to 1 if not there
                r = re.compile('@@ -(?P<fromstart>\d*)((,)(?P<fromlines>\d*))? \+(?P<tostart>\d*)((,)(?P<tolines>\d*))? @@')
                m = r.match(line)
                if m.group("fromlines"):
                    fromlines = int(m.group("fromlines"))
                else:
                    fromlines = 1
                if m.group("tolines"):
                    tolines = int(m.group("tolines"))
                else:
                    tolines = 1
                fromstart = int(m.group("fromstart"))
                tostart   = int(m.group("tostart"))
                patch["lines"] = (fromstart, fromlines, tostart, tolines)
                patch["raw"] += line
                # now we just read in everything until the next file 
                readingpatch = 1
                continue
        # when we fall out, insert the last patch into the list of
        # patches
        self.patches.append(Patch(patch["fromfile"], patch["tofile"],
                            patch["lines"], patch["raw"],
                            strip))

usage = "svnapply.py [-p level] file.diff"

parser = OptionParser(usage, version=".1")

parser.add_option("-p", "--strip", dest="strip", action="store", type="int",
                  default=-1, help="Strip patch")
parser.add_option("-m", "--message", dest="message", action="store", type="string",
                  default="", help="Additional commit message")
parser.add_option("-n", "--no-checkin", dest="nocheckin", action="store_true",
                  default=False, help="Don't check in, even if everything works fine")

(options, args) = parser.parse_args()

for f in args:
    d = Diff(f, options.strip)
    if False in [p.apply_patch() for p in d.patches]:
        print "WARNING: patch reported errors, not commiting"
        sys.exit(1)
    elif not options.nocheckin:
        message = "Import of %s" % (f)
        if options.message:
            message += "\n%s" % (options.message)
        print "Commiting"
        svnclient.checkin(".", message)
