[PATCH] Teach the recursive merge strategy about renames.
authorFredrik Kuivinen <freku045@student.liu.se>
Sun, 2 Oct 2005 15:43:07 +0000 (17:43 +0200)
committerJunio C Hamano <junkio@cox.net>
Sun, 2 Oct 2005 17:03:00 +0000 (10:03 -0700)
It will now merge cases where a file was renamed in one branch and
modified in the other branch cleanly. We also detect a couple of
conflict cases now that wasn't detected before.

Signed-off-by: Fredrik Kuivinen <freku045@student.liu.se>
Signed-off-by: Junio C Hamano <junkio@cox.net>
git-merge-recursive.py

index 689f914..b80a860 100755 (executable)
@@ -7,9 +7,6 @@ from sets import Set
 sys.path.append('@@GIT_PYTHON_PATH@@')
 from gitMergeCommon import *
 
-# The actual merge code
-# ---------------------
-
 originalIndexFile = os.environ.get('GIT_INDEX_FILE',
                                    os.environ.get('GIT_DIR', '.git') + '/index')
 temporaryIndexFile = os.environ.get('GIT_DIR', '.git') + \
@@ -21,11 +18,23 @@ def setupIndex(temporary):
         pass
     if temporary:
         newIndex = temporaryIndexFile
-        os.environ
     else:
         newIndex = originalIndexFile
     os.environ['GIT_INDEX_FILE'] = newIndex
 
+# This is a global variable which is used in a number of places but
+# only written to in the 'merge' function.
+
+# cacheOnly == True  => Don't leave any non-stage 0 entries in the cache and
+#                       don't update the working directory.
+#              False => Leave unmerged entries in the cache and update
+#                       the working directory.
+
+cacheOnly = False
+
+# The entry point to the merge code
+# ---------------------------------
+
 def merge(h1, h2, branch1Name, branch2Name, graph, callDepth=0):
     '''Merge the commits h1 and h2, return the resulting virtual
     commit object and a flag indicating the cleaness of the merge.'''
@@ -35,6 +44,7 @@ def merge(h1, h2, branch1Name, branch2Name, graph, callDepth=0):
     def infoMsg(*args):
         sys.stdout.write('  '*callDepth)
         printList(args)
+
     infoMsg('Merging:')
     infoMsg(h1)
     infoMsg(h2)
@@ -46,27 +56,27 @@ def merge(h1, h2, branch1Name, branch2Name, graph, callDepth=0):
         infoMsg(x)
     sys.stdout.flush()
 
-    Ms = ca[0]
+    mergedCA = ca[0]
     for h in ca[1:]:
-        [Ms, ignore] = merge(Ms, h,
-                             'Temporary shared merge branch 1',
-                             'Temporary shared merge branch 2',
-                             graph, callDepth+1)
-        assert(isinstance(Ms, Commit))
+        [mergedCA, dummy] = merge(mergedCA, h,
+                                  'Temporary shared merge branch 1',
+                                  'Temporary shared merge branch 2',
+                                  graph, callDepth+1)
+        assert(isinstance(mergedCA, Commit))
 
+    global cacheOnly
     if callDepth == 0:
         setupIndex(False)
-        cleanCache = False
+        cacheOnly = False
     else:
         setupIndex(True)
         runProgram(['git-read-tree', h1.tree()])
-        cleanCache = True
+        cacheOnly = True
 
-    [shaRes, clean] = mergeTrees(h1.tree(), h2.tree(), Ms.tree(),
-                                 branch1Name, branch2Name,
-                                 cleanCache)
+    [shaRes, clean] = mergeTrees(h1.tree(), h2.tree(), mergedCA.tree(),
+                                 branch1Name, branch2Name)
 
-    if clean or cleanCache:
+    if clean or cacheOnly:
         res = Commit(None, [h1, h2], tree=shaRes)
         graph.addNode(res)
     else:
@@ -89,16 +99,255 @@ def getFilesAndDirs(tree):
 
     return [files, dirs]
 
+# Those two global variables are used in a number of places but only
+# written to in 'mergeTrees' and 'uniquePath'. They keep track of
+# every file and directory in the two branches that are about to be
+# merged.
+currentFileSet = None
+currentDirectorySet = None
+
+def mergeTrees(head, merge, common, branch1Name, branch2Name):
+    '''Merge the trees 'head' and 'merge' with the common ancestor
+    'common'. The name of the head branch is 'branch1Name' and the name of
+    the merge branch is 'branch2Name'. Return a tuple (tree, cleanMerge)
+    where tree is the resulting tree and cleanMerge is True iff the
+    merge was clean.'''
+    
+    assert(isSha(head) and isSha(merge) and isSha(common))
+
+    if common == merge:
+        print 'Already uptodate!'
+        return [head, True]
+
+    if cacheOnly:
+        updateArg = '-i'
+    else:
+        updateArg = '-u'
+
+    [out, code] = runProgram(['git-read-tree', updateArg, '-m',
+                                common, head, merge], returnCode = True)
+    if code != 0:
+        die('git-read-tree:', out)
+
+    [tree, code] = runProgram('git-write-tree', returnCode=True)
+    tree = tree.rstrip()
+    if code != 0:
+        global currentFileSet, currentDirectorySet
+        [currentFileSet, currentDirectorySet] = getFilesAndDirs(head)
+        [filesM, dirsM] = getFilesAndDirs(merge)
+        currentFileSet.union_update(filesM)
+        currentDirectorySet.union_update(dirsM)
+
+        entries = unmergedCacheEntries()
+        renamesHead =  getRenames(head, common, head, merge, entries)
+        renamesMerge = getRenames(merge, common, head, merge, entries)
+
+        cleanMerge = processRenames(renamesHead, renamesMerge,
+                                    branch1Name, branch2Name)
+        for entry in entries:
+            if entry.processed:
+                continue
+            if not processEntry(entry, branch1Name, branch2Name):
+                cleanMerge = False
+                
+        if cleanMerge or cacheOnly:
+            tree = runProgram('git-write-tree').rstrip()
+        else:
+            tree = None
+    else:
+        cleanMerge = True
+
+    return [tree, cleanMerge]
+
+# Low level file merging, update and removal
+# ------------------------------------------
+
+def mergeFile(oPath, oSha, oMode, aPath, aSha, aMode, bPath, bSha, bMode,
+              branch1Name, branch2Name):
+
+    merge = False
+    clean = True
+
+    if stat.S_IFMT(aMode) != stat.S_IFMT(bMode):
+        clean = False
+        if stat.S_ISREG(aMode):
+            mode = aMode
+            sha = aSha
+        else:
+            mode = bMode
+            sha = bSha
+    else:
+        if aSha != oSha and bSha != oSha:
+            merge = True
+
+        if aMode == oMode:
+            mode = bMode
+        else:
+            mode = aMode
+
+        if aSha == oSha:
+            sha = bSha
+        elif bSha == oSha:
+            sha = aSha
+        elif stat.S_ISREG(aMode):
+            assert(stat.S_ISREG(bMode))
+
+            orig = runProgram(['git-unpack-file', oSha]).rstrip()
+            src1 = runProgram(['git-unpack-file', aSha]).rstrip()
+            src2 = runProgram(['git-unpack-file', bSha]).rstrip()
+            [out, code] = runProgram(['merge',
+                                      '-L', branch1Name + '/' + aPath,
+                                      '-L', 'orig/' + oPath,
+                                      '-L', branch2Name + '/' + bPath,
+                                      src1, orig, src2], returnCode=True)
+
+            sha = runProgram(['git-hash-object', '-t', 'blob', '-w',
+                              src1]).rstrip()
+
+            os.unlink(orig)
+            os.unlink(src1)
+            os.unlink(src2)
+            
+            clean = (code == 0)
+        else:
+            assert(stat.S_ISLNK(aMode) and stat.S_ISLNK(bMode))
+            sha = aSha
+
+            if aSha != bSha:
+                clean = False
+
+    return [sha, mode, clean, merge]
+
+def updateFile(clean, sha, mode, path):
+    updateCache = cacheOnly or clean
+    updateWd = not cacheOnly
+
+    return updateFileExt(sha, mode, path, updateCache, updateWd)
+
+def updateFileExt(sha, mode, path, updateCache, updateWd):
+    if cacheOnly:
+        updateWd = False
+
+    if updateWd:
+        pathComponents = path.split('/')
+        for x in xrange(1, len(pathComponents)):
+            p = '/'.join(pathComponents[0:x])
+
+            try:
+                createDir = not stat.S_ISDIR(os.lstat(p).st_mode)
+            except: 
+                createDir = True
+            
+            if createDir:
+                try:
+                    os.mkdir(p)
+                except OSError, e:
+                    die("Couldn't create directory", p, e.strerror)
+
+        prog = ['git-cat-file', 'blob', sha]
+        if stat.S_ISREG(mode):
+            try:
+                os.unlink(path)
+            except OSError:
+                pass
+            if mode & 0100:
+                mode = 0777
+            else:
+                mode = 0666
+            fd = os.open(path, os.O_WRONLY | os.O_TRUNC | os.O_CREAT, mode)
+            proc = subprocess.Popen(prog, stdout=fd)
+            proc.wait()
+            os.close(fd)
+        elif stat.S_ISLNK(mode):
+            linkTarget = runProgram(prog)
+            os.symlink(linkTarget, path)
+        else:
+            assert(False)
+
+    if updateWd and updateCache:
+        runProgram(['git-update-index', '--add', '--', path])
+    elif updateCache:
+        runProgram(['git-update-index', '--add', '--cacheinfo',
+                    '0%o' % mode, sha, path])
+
+def removeFile(clean, path):
+    updateCache = cacheOnly or clean
+    updateWd = not cacheOnly
+
+    if updateCache:
+        runProgram(['git-update-index', '--force-remove', '--', path])
+
+    if updateWd:
+        try:
+            os.unlink(path)
+        except OSError, e:
+            if e.errno != errno.ENOENT and e.errno != errno.EISDIR:
+                raise
+
+def uniquePath(path, branch):
+    def fileExists(path):
+        try:
+            os.lstat(path)
+            return True
+        except OSError, e:
+            if e.errno == errno.ENOENT:
+                return False
+            else:
+                raise
+
+    newPath = path + '_' + branch
+    suffix = 0
+    while newPath in currentFileSet or \
+          newPath in currentDirectorySet  or \
+          fileExists(newPath):
+        suffix += 1
+        newPath = path + '_' + branch + '_' + str(suffix)
+    currentFileSet.add(newPath)
+    return newPath
+
+# Cache entry management
+# ----------------------
+
 class CacheEntry:
     def __init__(self, path):
         class Stage:
             def __init__(self):
                 self.sha1 = None
                 self.mode = None
+
+            # Used for debugging only
+            def __str__(self):
+                if self.mode != None:
+                    m = '0%o' % self.mode
+                else:
+                    m = 'None'
+
+                if self.sha1:
+                    sha1 = self.sha1
+                else:
+                    sha1 = 'None'
+                return 'sha1: ' + sha1 + ' mode: ' + m
         
-        self.stages = [Stage(), Stage(), Stage()]
+        self.stages = [Stage(), Stage(), Stage(), Stage()]
         self.path = path
+        self.processed = False
+
+    def __str__(self):
+        return 'path: ' + self.path + ' stages: ' + repr([str(x) for x in self.stages])
 
+class CacheEntryContainer:
+    def __init__(self):
+        self.entries = {}
+
+    def add(self, entry):
+        self.entries[entry.path] = entry
+
+    def get(self, path):
+        return self.entries.get(path)
+
+    def __iter__(self):
+        return self.entries.itervalues()
+    
 unmergedRE = re.compile(r'^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
 def unmergedCacheEntries():
     '''Create a dictionary mapping file names to CacheEntry
@@ -108,155 +357,340 @@ def unmergedCacheEntries():
     lines = runProgram(['git-ls-files', '-z', '--unmerged']).split('\0')
     lines.pop()
 
-    res = {}
+    res = CacheEntryContainer()
     for l in lines:
         m = unmergedRE.match(l)
         if m:
             mode = int(m.group(1), 8)
             sha1 = m.group(2)
-            stage = int(m.group(3)) - 1
+            stage = int(m.group(3))
             path = m.group(4)
 
-            if res.has_key(path):
-                e = res[path]
-            else:
+            e = res.get(path)
+            if not e:
                 e = CacheEntry(path)
-                res[path] = e
-                
+                res.add(e)
+
             e.stages[stage].mode = mode
             e.stages[stage].sha1 = sha1
         else:
-            die('Error: Merge program failed: Unexpected output from', \
+            die('Error: Merge program failed: Unexpected output from',
                 'git-ls-files:', l)
     return res
 
-def mergeTrees(head, merge, common, branch1Name, branch2Name,
-               cleanCache):
-    '''Merge the trees 'head' and 'merge' with the common ancestor
-    'common'. The name of the head branch is 'branch1Name' and the name of
-    the merge branch is 'branch2Name'. Return a tuple (tree, cleanMerge)
-    where tree is the resulting tree and cleanMerge is True iff the
-    merge was clean.'''
+lsTreeRE = re.compile(r'^([0-7]+) (\S+) ([0-9a-f]{40})\t(.*)\n$', re.S)
+def getCacheEntry(path, origTree, aTree, bTree):
+    '''Returns a CacheEntry object which doesn't have to correspond to
+    a real cache entry in Git's index.'''
     
-    assert(isSha(head) and isSha(merge) and isSha(common))
+    def parse(out):
+        if out == '':
+            return [None, None]
+        else:
+            m = lsTreeRE.match(out)
+            if not m:
+                die('Unexpected output from git-ls-tree:', out)
+            elif m.group(2) == 'blob':
+                return [m.group(3), int(m.group(1), 8)]
+            else:
+                return [None, None]
 
-    if common == merge:
-        print 'Already uptodate!'
-        return [head, True]
+    res = CacheEntry(path)
 
-    if cleanCache:
-        updateArg = '-i'
-    else:
-        updateArg = '-u'
+    [oSha, oMode] = parse(runProgram(['git-ls-tree', origTree, '--', path]))
+    [aSha, aMode] = parse(runProgram(['git-ls-tree', aTree, '--', path]))
+    [bSha, bMode] = parse(runProgram(['git-ls-tree', bTree, '--', path]))
 
-    [out, code] = runProgram(['git-read-tree', updateArg, '-m', common, head, merge], returnCode = True)
-    if code != 0:
-        die('git-read-tree:', out)
+    res.stages[1].sha1 = oSha
+    res.stages[1].mode = oMode
+    res.stages[2].sha1 = aSha
+    res.stages[2].mode = aMode
+    res.stages[3].sha1 = bSha
+    res.stages[3].mode = bMode
 
-    cleanMerge = True
+    return res
 
-    [tree, code] = runProgram('git-write-tree', returnCode=True)
-    tree = tree.rstrip()
-    if code != 0:
-        [files, dirs] = getFilesAndDirs(head)
-        [filesM, dirsM] = getFilesAndDirs(merge)
-        files.union_update(filesM)
-        dirs.union_update(dirsM)
-        
-        cleanMerge = True
-        entries = unmergedCacheEntries()
-        for name in entries:
-            if not processEntry(entries[name], branch1Name, branch2Name,
-                                files, dirs, cleanCache):
-                cleanMerge = False
-                
-        if cleanMerge or cleanCache:
-            tree = runProgram('git-write-tree').rstrip()
+# Rename detection and handling
+# -----------------------------
+
+class RenameEntry:
+    def __init__(self,
+                 src, srcSha, srcMode, srcCacheEntry,
+                 dst, dstSha, dstMode, dstCacheEntry,
+                 score):
+        self.srcName = src
+        self.srcSha = srcSha
+        self.srcMode = srcMode
+        self.srcCacheEntry = srcCacheEntry
+        self.dstName = dst
+        self.dstSha = dstSha
+        self.dstMode = dstMode
+        self.dstCacheEntry = dstCacheEntry
+        self.score = score
+
+        self.processed = False
+
+class RenameEntryContainer:
+    def __init__(self):
+        self.entriesSrc = {}
+        self.entriesDst = {}
+
+    def add(self, entry):
+        self.entriesSrc[entry.srcName] = entry
+        self.entriesDst[entry.dstName] = entry
+
+    def getSrc(self, path):
+        return self.entriesSrc.get(path)
+
+    def getDst(self, path):
+        return self.entriesDst.get(path)
+
+    def __iter__(self):
+        return self.entriesSrc.itervalues()
+
+parseDiffRenamesRE = re.compile('^:([0-7]+) ([0-7]+) ([0-9a-f]{40}) ([0-9a-f]{40}) R([0-9]*)$')
+def getRenames(tree, oTree, aTree, bTree, cacheEntries):
+    '''Get information of all renames which occured between 'oTree' and
+    'tree'. We need the three trees in the merge ('oTree', 'aTree' and
+    'bTree') to be able to associate the correct cache entries with
+    the rename information. 'tree' is always equal to either aTree or bTree.'''
+
+    assert(tree == aTree or tree == bTree)
+    inp = runProgram(['git-diff-tree', '-M', '--diff-filter=R', '-r',
+                      '-z', oTree, tree])
+
+    ret = RenameEntryContainer()
+    try:
+        recs = inp.split("\0")
+        recs.pop() # remove last entry (which is '')
+        it = recs.__iter__()
+        while True:
+            rec = it.next()
+            m = parseDiffRenamesRE.match(rec)
+
+            if not m:
+                die('Unexpected output from git-diff-tree:', rec)
+
+            srcMode = int(m.group(1), 8)
+            dstMode = int(m.group(2), 8)
+            srcSha = m.group(3)
+            dstSha = m.group(4)
+            score = m.group(5)
+            src = it.next()
+            dst = it.next()
+
+            srcCacheEntry = cacheEntries.get(src)
+            if not srcCacheEntry:
+                srcCacheEntry = getCacheEntry(src, oTree, aTree, bTree)
+                cacheEntries.add(srcCacheEntry)
+
+            dstCacheEntry = cacheEntries.get(dst)
+            if not dstCacheEntry:
+                dstCacheEntry = getCacheEntry(dst, oTree, aTree, bTree)
+                cacheEntries.add(dstCacheEntry)
+
+            ret.add(RenameEntry(src, srcSha, srcMode, srcCacheEntry,
+                                dst, dstSha, dstMode, dstCacheEntry,
+                                score))
+    except StopIteration:
+        pass
+    return ret
+
+def fmtRename(src, dst):
+    srcPath = src.split('/')
+    dstPath = dst.split('/')
+    path = []
+    endIndex = min(len(srcPath), len(dstPath)) - 1
+    for x in range(0, endIndex):
+        if srcPath[x] == dstPath[x]:
+            path.append(srcPath[x])
         else:
-            tree = None
+            endIndex = x
+            break
+
+    if len(path) > 0:
+        return '/'.join(path) + \
+               '/{' + '/'.join(srcPath[endIndex:]) + ' => ' + \
+               '/'.join(dstPath[endIndex:]) + '}'
     else:
-        cleanMerge = True
+        return src + ' => ' + dst
 
-    return [tree, cleanMerge]
+def processRenames(renamesA, renamesB, branchNameA, branchNameB):
+    srcNames = Set()
+    for x in renamesA:
+        srcNames.add(x.srcName)
+    for x in renamesB:
+        srcNames.add(x.srcName)
 
-def processEntry(entry, branch1Name, branch2Name, files, dirs, cleanCache):
-    '''Merge one cache entry. 'files' is a Set with the files in both of
-    the heads that we are going to merge. 'dirs' contains the
-    corresponding data for directories. If 'cleanCache' is True no
-    non-zero stages will be left in the cache for the path
-    corresponding to the entry 'entry'.'''
+    cleanMerge = True
+    for path in srcNames:
+        if renamesA.getSrc(path):
+            renames1 = renamesA
+            renames2 = renamesB
+            branchName1 = branchNameA
+            branchName2 = branchNameB
+        else:
+            renames1 = renamesB
+            renames2 = renamesA
+            branchName1 = branchNameB
+            branchName2 = branchNameA
+        
+        ren1 = renames1.getSrc(path)
+        ren2 = renames2.getSrc(path)
+
+        ren1.dstCacheEntry.processed = True
+        ren1.srcCacheEntry.processed = True
+
+        if ren1.processed:
+            continue
+
+        ren1.processed = True
+        removeFile(True, ren1.srcName)
+        if ren2:
+            # Renamed in 1 and renamed in 2
+            assert(ren1.srcName == ren2.srcName)
+            ren2.dstCacheEntry.processed = True
+            ren2.processed = True
+
+            if ren1.dstName != ren2.dstName:
+                print 'CONFLICT (rename/rename): Rename', \
+                      fmtRename(path, ren1.dstName), 'in branch', branchName1, \
+                      'rename', fmtRename(path, ren2.dstName), 'in', branchName2
+                cleanMerge = False
 
-# cleanCache == True  => Don't leave any non-stage 0 entries in the cache and
-#                        don't update the working directory
-#               False => Leave unmerged entries and update the working directory
+                if ren1.dstName in currentDirectorySet:
+                    dstName1 = uniquePath(ren1.dstName, branchName1)
+                    print ren1.dstName, 'is a directory in', branchName2, \
+                          'adding as', dstName1, 'instead.'
+                    removeFile(False, ren1.dstName)
+                else:
+                    dstName1 = ren1.dstName
 
-# clean     == True  => non-conflict case
-#              False => conflict case
+                if ren2.dstName in currentDirectorySet:
+                    dstName2 = uniquePath(ren2.dstName, branchName2)
+                    print ren2.dstName, 'is a directory in', branchName1, \
+                          'adding as', dstName2, 'instead.'
+                    removeFile(False, ren2.dstName)
+                else:
+                    dstName2 = ren1.dstName
 
-# If cleanCache == False then the cache shouldn't be updated if clean == False
+                updateFile(False, ren1.dstSha, ren1.dstMode, dstName1)
+                updateFile(False, ren2.dstSha, ren2.dstMode, dstName2)
+            else:
+                print 'Renaming', fmtRename(path, ren1.dstName)
+                [resSha, resMode, clean, merge] = \
+                         mergeFile(ren1.srcName, ren1.srcSha, ren1.srcMode,
+                                   ren1.dstName, ren1.dstSha, ren1.dstMode,
+                                   ren2.dstName, ren2.dstSha, ren2.dstMode,
+                                   branchName1, branchName2)
+
+                if merge:
+                    print 'Auto-merging', ren1.dstName
+
+                if not clean:
+                    print 'CONFLICT (content): merge conflict in', ren1.dstName
+                    cleanMerge = False
+
+                    if not cacheOnly:
+                        updateFileExt(ren1.dstSha, ren1.dstMode, ren1.dstName,
+                                      updateCache=True, updateWd=False)
+                updateFile(clean, resSha, resMode, ren1.dstName)
+        else:
+            # Renamed in 1, maybe changed in 2
+            if renamesA == renames1:
+                stage = 3
+            else:
+                stage = 2
+                
+            srcShaOtherBranch  = ren1.srcCacheEntry.stages[stage].sha1
+            srcModeOtherBranch = ren1.srcCacheEntry.stages[stage].mode
+
+            dstShaOtherBranch  = ren1.dstCacheEntry.stages[stage].sha1
+            dstModeOtherBranch = ren1.dstCacheEntry.stages[stage].mode
+
+            tryMerge = False
+            
+            if ren1.dstName in currentDirectorySet:
+                newPath = uniquePath(ren1.dstName, branchName1)
+                print 'CONFLICT (rename/directory): Rename', \
+                      fmtRename(ren1.srcName, ren1.dstName), 'in', branchName1,\
+                      'directory', ren1.dstName, 'added in', branchName2
+                print 'Renaming', ren1.srcName, 'to', newPath, 'instead'
+                cleanMerge = False
+                removeFile(False, ren1.dstName)
+                updateFile(False, ren1.dstSha, ren1.dstMode, newPath)
+            elif srcShaOtherBranch == None:
+                print 'CONFLICT (rename/delete): Rename', \
+                      fmtRename(ren1.srcName, ren1.dstName), 'in', \
+                      branchName1, 'and deleted in', branchName2
+                cleanMerge = False
+                updateFile(False, ren1.dstSha, ren1.dstMode, ren1.dstName)
+            elif dstShaOtherBranch:
+                newPath = uniquePath(ren1.dstName, branchName2)
+                print 'CONFLICT (rename/add): Rename', \
+                      fmtRename(ren1.srcName, ren1.dstName), 'in', \
+                      branchName1 + '.', ren1.dstName, 'added in', branchName2
+                print 'Adding as', newPath, 'instead'
+                updateFile(False, dstShaOtherBranch, dstModeOtherBranch, newPath)
+                cleanMerge = False
+                tryMerge = True
+            elif renames2.getDst(ren1.dstName):
+                dst2 = renames2.getDst(ren1.dstName)
+                newPath1 = uniquePath(ren1.dstName, branchName1)
+                newPath2 = uniquePath(dst2.dstName, branchName2)
+                print 'CONFLICT (rename/rename): Rename', \
+                      fmtRename(ren1.srcName, ren1.dstName), 'in', \
+                      branchName1+'. Rename', \
+                      fmtRename(dst2.srcName, dst2.dstName), 'in', branchName2
+                print 'Renaming', ren1.srcName, 'to', newPath1, 'and', \
+                      dst2.srcName, 'to', newPath2, 'instead'
+                removeFile(False, ren1.dstName)
+                updateFile(False, ren1.dstSha, ren1.dstMode, newPath1)
+                updateFile(False, dst2.dstSha, dst2.dstMode, newPath2)
+                dst2.processed = True
+                cleanMerge = False
+            else:
+                tryMerge = True
 
-    def updateFile(clean, sha, mode, path, onlyWd=False):
-        updateCache = not onlyWd and (cleanCache or (not cleanCache and clean))
-        updateWd = onlyWd or (not cleanCache and clean)
+            if tryMerge:
+                print 'Renaming', fmtRename(ren1.srcName, ren1.dstName)
+                [resSha, resMode, clean, merge] = \
+                         mergeFile(ren1.srcName, ren1.srcSha, ren1.srcMode,
+                                   ren1.dstName, ren1.dstSha, ren1.dstMode,
+                                   ren1.srcName, srcShaOtherBranch, srcModeOtherBranch,
+                                   branchName1, branchName2)
 
-        if updateWd:
-            prog = ['git-cat-file', 'blob', sha]
-            if stat.S_ISREG(mode):
-                try:
-                    os.unlink(path)
-                except OSError:
-                    pass
-                if mode & 0100:
-                    mode = 0777
-                else:
-                    mode = 0666
-                fd = os.open(path, os.O_WRONLY | os.O_TRUNC | os.O_CREAT, mode)
-                proc = subprocess.Popen(prog, stdout=fd)
-                proc.wait()
-                os.close(fd)
-            elif stat.S_ISLNK(mode):
-                linkTarget = runProgram(prog)
-                os.symlink(linkTarget, path)
-            else:
-                assert(False)
+                if merge:
+                    print 'Auto-merging', ren1.dstName
 
-        if updateWd and updateCache:
-            runProgram(['git-update-index', '--add', '--', path])
-        elif updateCache:
-            runProgram(['git-update-index', '--add', '--cacheinfo',
-                        '0%o' % mode, sha, path])
+                if not clean:
+                    print 'CONFLICT (rename/modify): Merge conflict in', ren1.dstName
+                    cleanMerge = False
 
-    def removeFile(clean, path):
-        if cleanCache or (not cleanCache and clean):
-            runProgram(['git-update-index', '--force-remove', '--', path])
+                    if not cacheOnly:
+                        updateFileExt(ren1.dstSha, ren1.dstMode, ren1.dstName,
+                                      updateCache=True, updateWd=False)
+                updateFile(clean, resSha, resMode, ren1.dstName)
 
-        if not cleanCache and clean:
-            try:
-                os.unlink(path)
-            except OSError, e:
-                if e.errno != errno.ENOENT and e.errno != errno.EISDIR:
-                    raise
+    return cleanMerge
 
-    def uniquePath(path, branch):
-        newPath = path + '_' + branch
-        suffix = 0
-        while newPath in files or newPath in dirs:
-            suffix += 1
-            newPath = path + '_' + branch + '_' + str(suffix)
-        files.add(newPath)
-        return newPath
+# Per entry merge function
+# ------------------------
 
-    debug('processing', entry.path, 'clean cache:', cleanCache)
+def processEntry(entry, branch1Name, branch2Name):
+    '''Merge one cache entry.'''
+
+    debug('processing', entry.path, 'clean cache:', cacheOnly)
 
     cleanMerge = True
 
     path = entry.path
-    oSha = entry.stages[0].sha1
-    oMode = entry.stages[0].mode
-    aSha = entry.stages[1].sha1
-    aMode = entry.stages[1].mode
-    bSha = entry.stages[2].sha1
-    bMode = entry.stages[2].mode
+    oSha = entry.stages[1].sha1
+    oMode = entry.stages[1].mode
+    aSha = entry.stages[2].sha1
+    aMode = entry.stages[2].mode
+    bSha = entry.stages[3].sha1
+    bMode = entry.stages[3].mode
 
     assert(oSha == None or isSha(oSha))
     assert(aSha == None or isSha(aSha))
@@ -275,28 +709,26 @@ def processEntry(entry, branch1Name, branch2Name, files, dirs, cleanCache):
            (not aSha     and bSha == oSha):
     # Deleted in both or deleted in one and unchanged in the other
             if aSha:
-                print 'Removing ' + path
+                print 'Removing', path
             removeFile(True, path)
         else:
     # Deleted in one and changed in the other
             cleanMerge = False
             if not aSha:
-                print 'CONFLICT (del/mod): "' + path + '" deleted in', \
-                      branch1Name, 'and modified in', branch2Name, \
-                      '. Version', branch2Name, ' of "' + path + \
-                      '" left in tree'
+                print 'CONFLICT (delete/modify):', path, 'deleted in', \
+                      branch1Name, 'and modified in', branch2Name + '.', \
+                      'Version', branch2Name, 'of', path, 'left in tree.'
                 mode = bMode
                 sha = bSha
             else:
-                print 'CONFLICT (mod/del): "' + path + '" deleted in', \
-                      branch2Name, 'and modified in', branch1Name + \
-                      '. Version', branch1Name, 'of "' + path + \
-                      '" left in tree'
+                print 'CONFLICT (modify/delete):', path, 'deleted in', \
+                      branch2Name, 'and modified in', branch1Name + '.', \
+                      'Version', branch1Name, 'of', path, 'left in tree.'
                 mode = aMode
                 sha = aSha
 
             updateFile(False, sha, mode, path)
-    
+
     elif (not oSha and aSha     and not bSha) or \
          (not oSha and not aSha and bSha):
     #
@@ -307,27 +739,26 @@ def processEntry(entry, branch1Name, branch2Name, files, dirs, cleanCache):
             otherBranch = branch2Name
             mode = aMode
             sha = aSha
-            conf = 'file/dir'
+            conf = 'file/directory'
         else:
             addBranch = branch2Name
             otherBranch = branch1Name
             mode = bMode
             sha = bSha
-            conf = 'dir/file'
+            conf = 'directory/file'
     
-        if path in dirs:
+        if path in currentDirectorySet:
             cleanMerge = False
             newPath = uniquePath(path, addBranch)
-            print 'CONFLICT (' + conf + \
-                  '): There is a directory with name "' + path + '" in', \
-                  otherBranch + '. Adding "' + path + '" as "' + newPath + '"'
+            print 'CONFLICT (' + conf + '):', \
+                  'There is a directory with name', path, 'in', \
+                  otherBranch + '. Adding', path, 'as', newPath
 
             removeFile(False, path)
-            path = newPath
+            updateFile(False, sha, mode, newPath)
         else:
-            print 'Adding "' + path + '"'
-
-        updateFile(True, sha, mode, path)
+            print 'Adding', path
+            updateFile(True, sha, mode, path)
     
     elif not oSha and aSha and bSha:
     #
@@ -336,10 +767,9 @@ def processEntry(entry, branch1Name, branch2Name, files, dirs, cleanCache):
         if aSha == bSha:
             if aMode != bMode:
                 cleanMerge = False
-                print 'CONFLICT: File "' + path + \
-                      '" added identically in both branches,', \
-                      'but permissions conflict', '0%o' % aMode, '->', \
-                      '0%o' % bMode
+                print 'CONFLICT: File', path, \
+                      'added identically in both branches, but permissions', \
+                      'conflict', '0%o' % aMode, '->', '0%o' % bMode
                 print 'CONFLICT: adding with permission:', '0%o' % aMode
 
                 updateFile(False, aSha, aMode, path)
@@ -350,8 +780,9 @@ def processEntry(entry, branch1Name, branch2Name, files, dirs, cleanCache):
             cleanMerge = False
             newPath1 = uniquePath(path, branch1Name)
             newPath2 = uniquePath(path, branch2Name)
-            print 'CONFLICT (add/add): File "' + path + \
-                  '" added non-identically in both branches.'
+            print 'CONFLICT (add/add): File', path, \
+                  'added non-identically in both branches. Adding as', \
+                  newPath1, 'and', newPath2, 'instead.'
             removeFile(False, path)
             updateFile(False, aSha, aMode, newPath1)
             updateFile(False, bSha, bMode, newPath2)
@@ -360,39 +791,24 @@ def processEntry(entry, branch1Name, branch2Name, files, dirs, cleanCache):
     #
     # case D: Modified in both, but differently.
     #
-        print 'Auto-merging', path 
-        orig = runProgram(['git-unpack-file', oSha]).rstrip()
-        src1 = runProgram(['git-unpack-file', aSha]).rstrip()
-        src2 = runProgram(['git-unpack-file', bSha]).rstrip()
-        [out, ret] = runProgram(['merge',
-                                 '-L', branch1Name + '/' + path,
-                                 '-L', 'orig/' + path,
-                                 '-L', branch2Name + '/' + path,
-                                 src1, orig, src2], returnCode=True)
-
-        if aMode == oMode:
-            mode = bMode
+        print 'Auto-merging', path
+        [sha, mode, clean, dummy] = \
+              mergeFile(path, oSha, oMode,
+                        path, aSha, aMode,
+                        path, bSha, bMode,
+                        branch1Name, branch2Name)
+        if clean:
+            updateFile(True, sha, mode, path)
         else:
-            mode = aMode
-
-        sha = runProgram(['git-hash-object', '-t', 'blob', '-w',
-                          src1]).rstrip()
-
-        if ret != 0:
             cleanMerge = False
-            print 'CONFLICT (content): Merge conflict in "' + path + '".'
+            print 'CONFLICT (content): Merge conflict in', path
 
-            if cleanCache:
+            if cacheOnly:
                 updateFile(False, sha, mode, path)
             else:
-                updateFile(True, aSha, aMode, path)
-                updateFile(False, sha, mode, path, True)
-        else:
-            updateFile(True, sha, mode, path)
-
-        os.unlink(orig)
-        os.unlink(src1)
-        os.unlink(src2)
+                updateFileExt(aSha, aMode, path,
+                              updateCache=True, updateWd=False)
+                updateFileExt(sha, mode, path, updateCache=False, updateWd=True)
     else:
         die("ERROR: Fatal merge failure, shouldn't happen.")
 
@@ -416,7 +832,7 @@ for nextArg in xrange(1, len(sys.argv)):
         try:
             h1 = firstBranch = sys.argv[nextArg + 1]
             h2 = secondBranch = sys.argv[nextArg + 2]
-       except IndexError:
+        except IndexError:
             usage()
         break
 
@@ -428,8 +844,8 @@ try:
 
     graph = buildGraph([h1, h2])
 
-    [res, clean] = merge(graph.shaMap[h1], graph.shaMap[h2],
-                         firstBranch, secondBranch, graph)
+    [dummy, clean] = merge(graph.shaMap[h1], graph.shaMap[h2],
+                           firstBranch, secondBranch, graph)
 
     print ''
 except: