3 import sys
, math
, random
, os
, re
, signal
, tempfile
, stat
, errno
, traceback
4 from heapq
import heappush
, heappop
7 sys
.path
.append('''@@GIT_PYTHON_PATH@@''')
8 from gitMergeCommon
import *
10 originalIndexFile
= os
.environ
.get('GIT_INDEX_FILE',
11 os
.environ
.get('GIT_DIR', '.git') + '/index')
12 temporaryIndexFile
= os
.environ
.get('GIT_DIR', '.git') + \
13 '/merge-recursive-tmp-index'
14 def setupIndex(temporary
):
16 os
.unlink(temporaryIndexFile
)
20 newIndex
= temporaryIndexFile
22 newIndex
= originalIndexFile
23 os
.environ
['GIT_INDEX_FILE'] = newIndex
25 # This is a global variable which is used in a number of places but
26 # only written to in the 'merge' function.
28 # cacheOnly == True => Don't leave any non-stage 0 entries in the cache and
29 # don't update the working directory.
30 # False => Leave unmerged entries in the cache and update
31 # the working directory.
35 # The entry point to the merge code
36 # ---------------------------------
38 def merge(h1
, h2
, branch1Name
, branch2Name
, graph
, callDepth
=0):
39 '''Merge the commits h1 and h2, return the resulting virtual
40 commit object and a flag indicating the cleaness of the merge.'''
41 assert(isinstance(h1
, Commit
) and isinstance(h2
, Commit
))
42 assert(isinstance(graph
, Graph
))
45 sys
.stdout
.write(' '*callDepth
)
53 ca
= getCommonAncestors(graph
, h1
, h2
)
54 infoMsg('found', len(ca
), 'common ancestor(s):')
61 [mergedCA
, dummy
] = merge(mergedCA
, h
,
62 'Temporary shared merge branch 1',
63 'Temporary shared merge branch 2',
65 assert(isinstance(mergedCA
, Commit
))
73 runProgram(['git-read-tree', h1
.tree()])
76 [shaRes
, clean
] = mergeTrees(h1
.tree(), h2
.tree(), mergedCA
.tree(),
77 branch1Name
, branch2Name
)
79 if clean
or cacheOnly
:
80 res
= Commit(None, [h1
, h2
], tree
=shaRes
)
87 getFilesRE
= re
.compile(r
'^([0-7]+) (\S+) ([0-9a-f]{40})\t(.*)$', re
.S
)
88 def getFilesAndDirs(tree
):
91 out
= runProgram(['git-ls-tree', '-r', '-z', tree
])
92 for l
in out
.split('\0'):
93 m
= getFilesRE
.match(l
)
95 if m
.group(2) == 'tree':
97 elif m
.group(2) == 'blob':
102 # Those two global variables are used in a number of places but only
103 # written to in 'mergeTrees' and 'uniquePath'. They keep track of
104 # every file and directory in the two branches that are about to be
106 currentFileSet
= None
107 currentDirectorySet
= None
109 def mergeTrees(head
, merge
, common
, branch1Name
, branch2Name
):
110 '''Merge the trees 'head' and 'merge' with the common ancestor
111 'common'. The name of the head branch is 'branch1Name' and the name of
112 the merge branch is 'branch2Name'. Return a tuple (tree, cleanMerge)
113 where tree is the resulting tree and cleanMerge is True iff the
116 assert(isSha(head
) and isSha(merge
) and isSha(common
))
119 print 'Already uptodate!'
127 [out
, code
] = runProgram(['git-read-tree', updateArg
, '-m',
128 common
, head
, merge
], returnCode
= True)
130 die('git-read-tree:', out
)
132 [tree
, code
] = runProgram('git-write-tree', returnCode
=True)
135 global currentFileSet
, currentDirectorySet
136 [currentFileSet
, currentDirectorySet
] = getFilesAndDirs(head
)
137 [filesM
, dirsM
] = getFilesAndDirs(merge
)
138 currentFileSet
.union_update(filesM
)
139 currentDirectorySet
.union_update(dirsM
)
141 entries
= unmergedCacheEntries()
142 renamesHead
= getRenames(head
, common
, head
, merge
, entries
)
143 renamesMerge
= getRenames(merge
, common
, head
, merge
, entries
)
145 cleanMerge
= processRenames(renamesHead
, renamesMerge
,
146 branch1Name
, branch2Name
)
147 for entry
in entries
:
150 if not processEntry(entry
, branch1Name
, branch2Name
):
153 if cleanMerge
or cacheOnly
:
154 tree
= runProgram('git-write-tree').rstrip()
160 return [tree
, cleanMerge
]
162 # Low level file merging, update and removal
163 # ------------------------------------------
165 def mergeFile(oPath
, oSha
, oMode
, aPath
, aSha
, aMode
, bPath
, bSha
, bMode
,
166 branch1Name
, branch2Name
):
171 if stat
.S_IFMT(aMode
) != stat
.S_IFMT(bMode
):
173 if stat
.S_ISREG(aMode
):
180 if aSha
!= oSha
and bSha
!= oSha
:
192 elif stat
.S_ISREG(aMode
):
193 assert(stat
.S_ISREG(bMode
))
195 orig
= runProgram(['git-unpack-file', oSha
]).rstrip()
196 src1
= runProgram(['git-unpack-file', aSha
]).rstrip()
197 src2
= runProgram(['git-unpack-file', bSha
]).rstrip()
198 [out
, code
] = runProgram(['merge',
199 '-L', branch1Name
+ '/' + aPath
,
200 '-L', 'orig/' + oPath
,
201 '-L', branch2Name
+ '/' + bPath
,
202 src1
, orig
, src2
], returnCode
=True)
204 sha
= runProgram(['git-hash-object', '-t', 'blob', '-w',
213 assert(stat
.S_ISLNK(aMode
) and stat
.S_ISLNK(bMode
))
219 return [sha
, mode
, clean
, merge
]
221 def updateFile(clean
, sha
, mode
, path
):
222 updateCache
= cacheOnly
or clean
223 updateWd
= not cacheOnly
225 return updateFileExt(sha
, mode
, path
, updateCache
, updateWd
)
227 def updateFileExt(sha
, mode
, path
, updateCache
, updateWd
):
232 pathComponents
= path
.split('/')
233 for x
in xrange(1, len(pathComponents
)):
234 p
= '/'.join(pathComponents
[0:x
])
237 createDir
= not stat
.S_ISDIR(os
.lstat(p
).st_mode
)
245 die("Couldn't create directory", p
, e
.strerror
)
247 prog
= ['git-cat-file', 'blob', sha
]
248 if stat
.S_ISREG(mode
):
257 fd
= os
.open(path
, os
.O_WRONLY | os
.O_TRUNC | os
.O_CREAT
, mode
)
258 proc
= subprocess
.Popen(prog
, stdout
=fd
)
261 elif stat
.S_ISLNK(mode
):
262 linkTarget
= runProgram(prog
)
263 os
.symlink(linkTarget
, path
)
267 if updateWd
and updateCache
:
268 runProgram(['git-update-index', '--add', '--', path
])
270 runProgram(['git-update-index', '--add', '--cacheinfo',
271 '0%o' % mode
, sha
, path
])
273 def removeFile(clean
, path
):
274 updateCache
= cacheOnly
or clean
275 updateWd
= not cacheOnly
278 runProgram(['git-update-index', '--force-remove', '--', path
])
284 if e
.errno
!= errno
.ENOENT
and e
.errno
!= errno
.EISDIR
:
287 def uniquePath(path
, branch
):
288 def fileExists(path
):
293 if e
.errno
== errno
.ENOENT
:
298 branch
= branch
.replace('/', '_')
299 newPath
= path
+ '_' + branch
301 while newPath
in currentFileSet
or \
302 newPath
in currentDirectorySet
or \
305 newPath
= path
+ '_' + branch
+ '_' + str(suffix
)
306 currentFileSet
.add(newPath
)
309 # Cache entry management
310 # ----------------------
313 def __init__(self
, path
):
319 # Used for debugging only
321 if self
.mode
!= None:
322 m
= '0%o' % self
.mode
330 return 'sha1: ' + sha1
+ ' mode: ' + m
332 self
.stages
= [Stage(), Stage(), Stage(), Stage()]
334 self
.processed
= False
337 return 'path: ' + self
.path
+ ' stages: ' + repr([str(x
) for x
in self
.stages
])
339 class CacheEntryContainer
:
343 def add(self
, entry
):
344 self
.entries
[entry
.path
] = entry
347 return self
.entries
.get(path
)
350 return self
.entries
.itervalues()
352 unmergedRE
= re
.compile(r
'^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
353 def unmergedCacheEntries():
354 '''Create a dictionary mapping file names to CacheEntry
355 objects. The dictionary contains one entry for every path with a
356 non-zero stage entry.'''
358 lines
= runProgram(['git-ls-files', '-z', '--unmerged']).split('\0')
361 res
= CacheEntryContainer()
363 m
= unmergedRE
.match(l
)
365 mode
= int(m
.group(1), 8)
367 stage
= int(m
.group(3))
375 e
.stages
[stage
].mode
= mode
376 e
.stages
[stage
].sha1
= sha1
378 die('Error: Merge program failed: Unexpected output from',
382 lsTreeRE
= re
.compile(r
'^([0-7]+) (\S+) ([0-9a-f]{40})\t(.*)\n$', re
.S
)
383 def getCacheEntry(path
, origTree
, aTree
, bTree
):
384 '''Returns a CacheEntry object which doesn't have to correspond to
385 a real cache entry in Git's index.'''
391 m
= lsTreeRE
.match(out
)
393 die('Unexpected output from git-ls-tree:', out
)
394 elif m
.group(2) == 'blob':
395 return [m
.group(3), int(m
.group(1), 8)]
399 res
= CacheEntry(path
)
401 [oSha
, oMode
] = parse(runProgram(['git-ls-tree', origTree
, '--', path
]))
402 [aSha
, aMode
] = parse(runProgram(['git-ls-tree', aTree
, '--', path
]))
403 [bSha
, bMode
] = parse(runProgram(['git-ls-tree', bTree
, '--', path
]))
405 res
.stages
[1].sha1
= oSha
406 res
.stages
[1].mode
= oMode
407 res
.stages
[2].sha1
= aSha
408 res
.stages
[2].mode
= aMode
409 res
.stages
[3].sha1
= bSha
410 res
.stages
[3].mode
= bMode
414 # Rename detection and handling
415 # -----------------------------
419 src
, srcSha
, srcMode
, srcCacheEntry
,
420 dst
, dstSha
, dstMode
, dstCacheEntry
,
424 self
.srcMode
= srcMode
425 self
.srcCacheEntry
= srcCacheEntry
428 self
.dstMode
= dstMode
429 self
.dstCacheEntry
= dstCacheEntry
432 self
.processed
= False
434 class RenameEntryContainer
:
439 def add(self
, entry
):
440 self
.entriesSrc
[entry
.srcName
] = entry
441 self
.entriesDst
[entry
.dstName
] = entry
443 def getSrc(self
, path
):
444 return self
.entriesSrc
.get(path
)
446 def getDst(self
, path
):
447 return self
.entriesDst
.get(path
)
450 return self
.entriesSrc
.itervalues()
452 parseDiffRenamesRE
= re
.compile('^:([0-7]+) ([0-7]+) ([0-9a-f]{40}) ([0-9a-f]{40}) R([0-9]*)$')
453 def getRenames(tree
, oTree
, aTree
, bTree
, cacheEntries
):
454 '''Get information of all renames which occured between 'oTree' and
455 'tree'. We need the three trees in the merge ('oTree', 'aTree' and
456 'bTree') to be able to associate the correct cache entries with
457 the rename information. 'tree' is always equal to either aTree or bTree.'''
459 assert(tree
== aTree
or tree
== bTree
)
460 inp
= runProgram(['git-diff-tree', '-M', '--diff-filter=R', '-r',
463 ret
= RenameEntryContainer()
465 recs
= inp
.split("\0")
466 recs
.pop() # remove last entry (which is '')
470 m
= parseDiffRenamesRE
.match(rec
)
473 die('Unexpected output from git-diff-tree:', rec
)
475 srcMode
= int(m
.group(1), 8)
476 dstMode
= int(m
.group(2), 8)
483 srcCacheEntry
= cacheEntries
.get(src
)
484 if not srcCacheEntry
:
485 srcCacheEntry
= getCacheEntry(src
, oTree
, aTree
, bTree
)
486 cacheEntries
.add(srcCacheEntry
)
488 dstCacheEntry
= cacheEntries
.get(dst
)
489 if not dstCacheEntry
:
490 dstCacheEntry
= getCacheEntry(dst
, oTree
, aTree
, bTree
)
491 cacheEntries
.add(dstCacheEntry
)
493 ret
.add(RenameEntry(src
, srcSha
, srcMode
, srcCacheEntry
,
494 dst
, dstSha
, dstMode
, dstCacheEntry
,
496 except StopIteration:
500 def fmtRename(src
, dst
):
501 srcPath
= src
.split('/')
502 dstPath
= dst
.split('/')
504 endIndex
= min(len(srcPath
), len(dstPath
)) - 1
505 for x
in range(0, endIndex
):
506 if srcPath
[x
] == dstPath
[x
]:
507 path
.append(srcPath
[x
])
513 return '/'.join(path
) + \
514 '/{' + '/'.join(srcPath
[endIndex
:]) + ' => ' + \
515 '/'.join(dstPath
[endIndex
:]) + '}'
517 return src
+ ' => ' + dst
519 def processRenames(renamesA
, renamesB
, branchNameA
, branchNameB
):
522 srcNames
.add(x
.srcName
)
524 srcNames
.add(x
.srcName
)
527 for path
in srcNames
:
528 if renamesA
.getSrc(path
):
531 branchName1
= branchNameA
532 branchName2
= branchNameB
536 branchName1
= branchNameB
537 branchName2
= branchNameA
539 ren1
= renames1
.getSrc(path
)
540 ren2
= renames2
.getSrc(path
)
542 ren1
.dstCacheEntry
.processed
= True
543 ren1
.srcCacheEntry
.processed
= True
548 ren1
.processed
= True
549 removeFile(True, ren1
.srcName
)
551 # Renamed in 1 and renamed in 2
552 assert(ren1
.srcName
== ren2
.srcName
)
553 ren2
.dstCacheEntry
.processed
= True
554 ren2
.processed
= True
556 if ren1
.dstName
!= ren2
.dstName
:
557 print 'CONFLICT (rename/rename): Rename', \
558 fmtRename(path
, ren1
.dstName
), 'in branch', branchName1
, \
559 'rename', fmtRename(path
, ren2
.dstName
), 'in', branchName2
562 if ren1
.dstName
in currentDirectorySet
:
563 dstName1
= uniquePath(ren1
.dstName
, branchName1
)
564 print ren1
.dstName
, 'is a directory in', branchName2
, \
565 'adding as', dstName1
, 'instead.'
566 removeFile(False, ren1
.dstName
)
568 dstName1
= ren1
.dstName
570 if ren2
.dstName
in currentDirectorySet
:
571 dstName2
= uniquePath(ren2
.dstName
, branchName2
)
572 print ren2
.dstName
, 'is a directory in', branchName1
, \
573 'adding as', dstName2
, 'instead.'
574 removeFile(False, ren2
.dstName
)
576 dstName2
= ren1
.dstName
578 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, dstName1
)
579 updateFile(False, ren2
.dstSha
, ren2
.dstMode
, dstName2
)
581 [resSha
, resMode
, clean
, merge
] = \
582 mergeFile(ren1
.srcName
, ren1
.srcSha
, ren1
.srcMode
,
583 ren1
.dstName
, ren1
.dstSha
, ren1
.dstMode
,
584 ren2
.dstName
, ren2
.dstSha
, ren2
.dstMode
,
585 branchName1
, branchName2
)
587 if merge
or not clean
:
588 print 'Renaming', fmtRename(path
, ren1
.dstName
)
591 print 'Auto-merging', ren1
.dstName
594 print 'CONFLICT (content): merge conflict in', ren1
.dstName
598 updateFileExt(ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
,
599 updateCache
=True, updateWd
=False)
600 updateFile(clean
, resSha
, resMode
, ren1
.dstName
)
602 # Renamed in 1, maybe changed in 2
603 if renamesA
== renames1
:
608 srcShaOtherBranch
= ren1
.srcCacheEntry
.stages
[stage
].sha1
609 srcModeOtherBranch
= ren1
.srcCacheEntry
.stages
[stage
].mode
611 dstShaOtherBranch
= ren1
.dstCacheEntry
.stages
[stage
].sha1
612 dstModeOtherBranch
= ren1
.dstCacheEntry
.stages
[stage
].mode
616 if ren1
.dstName
in currentDirectorySet
:
617 newPath
= uniquePath(ren1
.dstName
, branchName1
)
618 print 'CONFLICT (rename/directory): Rename', \
619 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', branchName1
,\
620 'directory', ren1
.dstName
, 'added in', branchName2
621 print 'Renaming', ren1
.srcName
, 'to', newPath
, 'instead'
623 removeFile(False, ren1
.dstName
)
624 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, newPath
)
625 elif srcShaOtherBranch
== None:
626 print 'CONFLICT (rename/delete): Rename', \
627 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', \
628 branchName1
, 'and deleted in', branchName2
630 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
)
631 elif dstShaOtherBranch
:
632 newPath
= uniquePath(ren1
.dstName
, branchName2
)
633 print 'CONFLICT (rename/add): Rename', \
634 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', \
635 branchName1
+ '.', ren1
.dstName
, 'added in', branchName2
636 print 'Adding as', newPath
, 'instead'
637 updateFile(False, dstShaOtherBranch
, dstModeOtherBranch
, newPath
)
640 elif renames2
.getDst(ren1
.dstName
):
641 dst2
= renames2
.getDst(ren1
.dstName
)
642 newPath1
= uniquePath(ren1
.dstName
, branchName1
)
643 newPath2
= uniquePath(dst2
.dstName
, branchName2
)
644 print 'CONFLICT (rename/rename): Rename', \
645 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', \
646 branchName1
+'. Rename', \
647 fmtRename(dst2
.srcName
, dst2
.dstName
), 'in', branchName2
648 print 'Renaming', ren1
.srcName
, 'to', newPath1
, 'and', \
649 dst2
.srcName
, 'to', newPath2
, 'instead'
650 removeFile(False, ren1
.dstName
)
651 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, newPath1
)
652 updateFile(False, dst2
.dstSha
, dst2
.dstMode
, newPath2
)
653 dst2
.processed
= True
659 [resSha
, resMode
, clean
, merge
] = \
660 mergeFile(ren1
.srcName
, ren1
.srcSha
, ren1
.srcMode
,
661 ren1
.dstName
, ren1
.dstSha
, ren1
.dstMode
,
662 ren1
.srcName
, srcShaOtherBranch
, srcModeOtherBranch
,
663 branchName1
, branchName2
)
665 if merge
or not clean
:
666 print 'Renaming', fmtRename(ren1
.srcName
, ren1
.dstName
)
669 print 'Auto-merging', ren1
.dstName
672 print 'CONFLICT (rename/modify): Merge conflict in', ren1
.dstName
676 updateFileExt(ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
,
677 updateCache
=True, updateWd
=False)
678 updateFile(clean
, resSha
, resMode
, ren1
.dstName
)
682 # Per entry merge function
683 # ------------------------
685 def processEntry(entry
, branch1Name
, branch2Name
):
686 '''Merge one cache entry.'''
688 debug('processing', entry
.path
, 'clean cache:', cacheOnly
)
693 oSha
= entry
.stages
[1].sha1
694 oMode
= entry
.stages
[1].mode
695 aSha
= entry
.stages
[2].sha1
696 aMode
= entry
.stages
[2].mode
697 bSha
= entry
.stages
[3].sha1
698 bMode
= entry
.stages
[3].mode
700 assert(oSha
== None or isSha(oSha
))
701 assert(aSha
== None or isSha(aSha
))
702 assert(bSha
== None or isSha(bSha
))
704 assert(oMode
== None or type(oMode
) is int)
705 assert(aMode
== None or type(aMode
) is int)
706 assert(bMode
== None or type(bMode
) is int)
708 if (oSha
and (not aSha
or not bSha
)):
710 # Case A: Deleted in one
712 if (not aSha
and not bSha
) or \
713 (aSha
== oSha
and not bSha
) or \
714 (not aSha
and bSha
== oSha
):
715 # Deleted in both or deleted in one and unchanged in the other
717 print 'Removing', path
718 removeFile(True, path
)
720 # Deleted in one and changed in the other
723 print 'CONFLICT (delete/modify):', path
, 'deleted in', \
724 branch1Name
, 'and modified in', branch2Name
+ '.', \
725 'Version', branch2Name
, 'of', path
, 'left in tree.'
729 print 'CONFLICT (modify/delete):', path
, 'deleted in', \
730 branch2Name
, 'and modified in', branch1Name
+ '.', \
731 'Version', branch1Name
, 'of', path
, 'left in tree.'
735 updateFile(False, sha
, mode
, path
)
737 elif (not oSha
and aSha
and not bSha
) or \
738 (not oSha
and not aSha
and bSha
):
740 # Case B: Added in one.
743 addBranch
= branch1Name
744 otherBranch
= branch2Name
747 conf
= 'file/directory'
749 addBranch
= branch2Name
750 otherBranch
= branch1Name
753 conf
= 'directory/file'
755 if path
in currentDirectorySet
:
757 newPath
= uniquePath(path
, addBranch
)
758 print 'CONFLICT (' + conf
+ '):', \
759 'There is a directory with name', path
, 'in', \
760 otherBranch
+ '. Adding', path
, 'as', newPath
762 removeFile(False, path
)
763 updateFile(False, sha
, mode
, newPath
)
766 updateFile(True, sha
, mode
, path
)
768 elif not oSha
and aSha
and bSha
:
770 # Case C: Added in both (check for same permissions).
775 print 'CONFLICT: File', path
, \
776 'added identically in both branches, but permissions', \
777 'conflict', '0%o' % aMode
, '->', '0%o' % bMode
778 print 'CONFLICT: adding with permission:', '0%o' % aMode
780 updateFile(False, aSha
, aMode
, path
)
782 # This case is handled by git-read-tree
786 newPath1
= uniquePath(path
, branch1Name
)
787 newPath2
= uniquePath(path
, branch2Name
)
788 print 'CONFLICT (add/add): File', path
, \
789 'added non-identically in both branches. Adding as', \
790 newPath1
, 'and', newPath2
, 'instead.'
791 removeFile(False, path
)
792 updateFile(False, aSha
, aMode
, newPath1
)
793 updateFile(False, bSha
, bMode
, newPath2
)
795 elif oSha
and aSha
and bSha
:
797 # case D: Modified in both, but differently.
799 print 'Auto-merging', path
800 [sha
, mode
, clean
, dummy
] = \
801 mergeFile(path
, oSha
, oMode
,
804 branch1Name
, branch2Name
)
806 updateFile(True, sha
, mode
, path
)
809 print 'CONFLICT (content): Merge conflict in', path
812 updateFile(False, sha
, mode
, path
)
814 updateFileExt(aSha
, aMode
, path
,
815 updateCache
=True, updateWd
=False)
816 updateFileExt(sha
, mode
, path
, updateCache
=False, updateWd
=True)
818 die("ERROR: Fatal merge failure, shouldn't happen.")
823 die('Usage:', sys
.argv
[0], ' <base>... -- <head> <remote>..')
825 # main entry point as merge strategy module
826 # The first parameters up to -- are merge bases, and the rest are heads.
827 # This strategy module figures out merge bases itself, so we only
830 if len(sys
.argv
) < 4:
833 for nextArg
in xrange(1, len(sys
.argv
)):
834 if sys
.argv
[nextArg
] == '--':
835 if len(sys
.argv
) != nextArg
+ 3:
836 die('Not handling anything other than two heads merge.')
838 h1
= firstBranch
= sys
.argv
[nextArg
+ 1]
839 h2
= secondBranch
= sys
.argv
[nextArg
+ 2]
844 print 'Merging', h1
, 'with', h2
847 h1
= runProgram(['git-rev-parse', '--verify', h1
+ '^0']).rstrip()
848 h2
= runProgram(['git-rev-parse', '--verify', h2
+ '^0']).rstrip()
850 graph
= buildGraph([h1
, h2
])
852 [dummy
, clean
] = merge(graph
.shaMap
[h1
], graph
.shaMap
[h2
],
853 firstBranch
, secondBranch
, graph
)
857 if isinstance(sys
.exc_info()[1], SystemExit):
860 traceback
.print_exc(None, sys
.stderr
)