3 # Copyright (C) 2005 Fredrik Kuivinen
7 sys
.path
.append('''@@GIT_PYTHON_PATH@@''')
9 import math
, random
, os
, re
, signal
, tempfile
, stat
, errno
, traceback
10 from heapq
import heappush
, heappop
13 from gitMergeCommon
import *
17 sys
.stdout
.write(' '*outputIndent
)
20 originalIndexFile
= os
.environ
.get('GIT_INDEX_FILE',
21 os
.environ
.get('GIT_DIR', '.git') + '/index')
22 temporaryIndexFile
= os
.environ
.get('GIT_DIR', '.git') + \
23 '/merge-recursive-tmp-index'
24 def setupIndex(temporary
):
26 os
.unlink(temporaryIndexFile
)
30 newIndex
= temporaryIndexFile
32 newIndex
= originalIndexFile
33 os
.environ
['GIT_INDEX_FILE'] = newIndex
35 # This is a global variable which is used in a number of places but
36 # only written to in the 'merge' function.
38 # cacheOnly == True => Don't leave any non-stage 0 entries in the cache and
39 # don't update the working directory.
40 # False => Leave unmerged entries in the cache and update
41 # the working directory.
45 # The entry point to the merge code
46 # ---------------------------------
48 def merge(h1
, h2
, branch1Name
, branch2Name
, graph
, callDepth
=0):
49 '''Merge the commits h1 and h2, return the resulting virtual
50 commit object and a flag indicating the cleaness of the merge.'''
51 assert(isinstance(h1
, Commit
) and isinstance(h2
, Commit
))
52 assert(isinstance(graph
, Graph
))
61 ca
= getCommonAncestors(graph
, h1
, h2
)
62 output('found', len(ca
), 'common ancestor(s):')
69 outputIndent
= callDepth
+1
70 [mergedCA
, dummy
] = merge(mergedCA
, h
,
71 'Temporary merge branch 1',
72 'Temporary merge branch 2',
74 outputIndent
= callDepth
75 assert(isinstance(mergedCA
, Commit
))
83 runProgram(['git-read-tree', h1
.tree()])
86 [shaRes
, clean
] = mergeTrees(h1
.tree(), h2
.tree(), mergedCA
.tree(),
87 branch1Name
, branch2Name
)
89 if clean
or cacheOnly
:
90 res
= Commit(None, [h1
, h2
], tree
=shaRes
)
97 getFilesRE
= re
.compile(r
'^([0-7]+) (\S+) ([0-9a-f]{40})\t(.*)$', re
.S
)
98 def getFilesAndDirs(tree
):
101 out
= runProgram(['git-ls-tree', '-r', '-z', tree
])
102 for l
in out
.split('\0'):
103 m
= getFilesRE
.match(l
)
105 if m
.group(2) == 'tree':
107 elif m
.group(2) == 'blob':
108 files
.add(m
.group(4))
112 # Those two global variables are used in a number of places but only
113 # written to in 'mergeTrees' and 'uniquePath'. They keep track of
114 # every file and directory in the two branches that are about to be
116 currentFileSet
= None
117 currentDirectorySet
= None
119 def mergeTrees(head
, merge
, common
, branch1Name
, branch2Name
):
120 '''Merge the trees 'head' and 'merge' with the common ancestor
121 'common'. The name of the head branch is 'branch1Name' and the name of
122 the merge branch is 'branch2Name'. Return a tuple (tree, cleanMerge)
123 where tree is the resulting tree and cleanMerge is True iff the
126 assert(isSha(head
) and isSha(merge
) and isSha(common
))
129 output('Already uptodate!')
137 [out
, code
] = runProgram(['git-read-tree', updateArg
, '-m',
138 common
, head
, merge
], returnCode
= True)
140 die('git-read-tree:', out
)
142 [tree
, code
] = runProgram('git-write-tree', returnCode
=True)
145 global currentFileSet
, currentDirectorySet
146 [currentFileSet
, currentDirectorySet
] = getFilesAndDirs(head
)
147 [filesM
, dirsM
] = getFilesAndDirs(merge
)
148 currentFileSet
.union_update(filesM
)
149 currentDirectorySet
.union_update(dirsM
)
151 entries
= unmergedCacheEntries()
152 renamesHead
= getRenames(head
, common
, head
, merge
, entries
)
153 renamesMerge
= getRenames(merge
, common
, head
, merge
, entries
)
155 cleanMerge
= processRenames(renamesHead
, renamesMerge
,
156 branch1Name
, branch2Name
)
157 for entry
in entries
:
160 if not processEntry(entry
, branch1Name
, branch2Name
):
163 if cleanMerge
or cacheOnly
:
164 tree
= runProgram('git-write-tree').rstrip()
170 return [tree
, cleanMerge
]
172 # Low level file merging, update and removal
173 # ------------------------------------------
175 def mergeFile(oPath
, oSha
, oMode
, aPath
, aSha
, aMode
, bPath
, bSha
, bMode
,
176 branch1Name
, branch2Name
):
181 if stat
.S_IFMT(aMode
) != stat
.S_IFMT(bMode
):
183 if stat
.S_ISREG(aMode
):
190 if aSha
!= oSha
and bSha
!= oSha
:
202 elif stat
.S_ISREG(aMode
):
203 assert(stat
.S_ISREG(bMode
))
205 orig
= runProgram(['git-unpack-file', oSha
]).rstrip()
206 src1
= runProgram(['git-unpack-file', aSha
]).rstrip()
207 src2
= runProgram(['git-unpack-file', bSha
]).rstrip()
208 [out
, code
] = runProgram(['merge',
209 '-L', branch1Name
+ '/' + aPath
,
210 '-L', 'orig/' + oPath
,
211 '-L', branch2Name
+ '/' + bPath
,
212 src1
, orig
, src2
], returnCode
=True)
214 sha
= runProgram(['git-hash-object', '-t', 'blob', '-w',
223 assert(stat
.S_ISLNK(aMode
) and stat
.S_ISLNK(bMode
))
229 return [sha
, mode
, clean
, merge
]
231 def updateFile(clean
, sha
, mode
, path
):
232 updateCache
= cacheOnly
or clean
233 updateWd
= not cacheOnly
235 return updateFileExt(sha
, mode
, path
, updateCache
, updateWd
)
237 def updateFileExt(sha
, mode
, path
, updateCache
, updateWd
):
242 pathComponents
= path
.split('/')
243 for x
in xrange(1, len(pathComponents
)):
244 p
= '/'.join(pathComponents
[0:x
])
247 createDir
= not stat
.S_ISDIR(os
.lstat(p
).st_mode
)
255 die("Couldn't create directory", p
, e
.strerror
)
257 prog
= ['git-cat-file', 'blob', sha
]
258 if stat
.S_ISREG(mode
):
267 fd
= os
.open(path
, os
.O_WRONLY | os
.O_TRUNC | os
.O_CREAT
, mode
)
268 proc
= subprocess
.Popen(prog
, stdout
=fd
)
271 elif stat
.S_ISLNK(mode
):
272 linkTarget
= runProgram(prog
)
273 os
.symlink(linkTarget
, path
)
277 if updateWd
and updateCache
:
278 runProgram(['git-update-index', '--add', '--', path
])
280 runProgram(['git-update-index', '--add', '--cacheinfo',
281 '0%o' % mode
, sha
, path
])
283 def removeFile(clean
, path
):
284 updateCache
= cacheOnly
or clean
285 updateWd
= not cacheOnly
288 runProgram(['git-update-index', '--force-remove', '--', path
])
294 if e
.errno
!= errno
.ENOENT
and e
.errno
!= errno
.EISDIR
:
297 def uniquePath(path
, branch
):
298 def fileExists(path
):
303 if e
.errno
== errno
.ENOENT
:
308 branch
= branch
.replace('/', '_')
309 newPath
= path
+ '~' + branch
311 while newPath
in currentFileSet
or \
312 newPath
in currentDirectorySet
or \
315 newPath
= path
+ '~' + branch
+ '_' + str(suffix
)
316 currentFileSet
.add(newPath
)
319 # Cache entry management
320 # ----------------------
323 def __init__(self
, path
):
329 # Used for debugging only
331 if self
.mode
!= None:
332 m
= '0%o' % self
.mode
340 return 'sha1: ' + sha1
+ ' mode: ' + m
342 self
.stages
= [Stage(), Stage(), Stage(), Stage()]
344 self
.processed
= False
347 return 'path: ' + self
.path
+ ' stages: ' + repr([str(x
) for x
in self
.stages
])
349 class CacheEntryContainer
:
353 def add(self
, entry
):
354 self
.entries
[entry
.path
] = entry
357 return self
.entries
.get(path
)
360 return self
.entries
.itervalues()
362 unmergedRE
= re
.compile(r
'^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
363 def unmergedCacheEntries():
364 '''Create a dictionary mapping file names to CacheEntry
365 objects. The dictionary contains one entry for every path with a
366 non-zero stage entry.'''
368 lines
= runProgram(['git-ls-files', '-z', '--unmerged']).split('\0')
371 res
= CacheEntryContainer()
373 m
= unmergedRE
.match(l
)
375 mode
= int(m
.group(1), 8)
377 stage
= int(m
.group(3))
385 e
.stages
[stage
].mode
= mode
386 e
.stages
[stage
].sha1
= sha1
388 die('Error: Merge program failed: Unexpected output from',
392 lsTreeRE
= re
.compile(r
'^([0-7]+) (\S+) ([0-9a-f]{40})\t(.*)\n$', re
.S
)
393 def getCacheEntry(path
, origTree
, aTree
, bTree
):
394 '''Returns a CacheEntry object which doesn't have to correspond to
395 a real cache entry in Git's index.'''
401 m
= lsTreeRE
.match(out
)
403 die('Unexpected output from git-ls-tree:', out
)
404 elif m
.group(2) == 'blob':
405 return [m
.group(3), int(m
.group(1), 8)]
409 res
= CacheEntry(path
)
411 [oSha
, oMode
] = parse(runProgram(['git-ls-tree', origTree
, '--', path
]))
412 [aSha
, aMode
] = parse(runProgram(['git-ls-tree', aTree
, '--', path
]))
413 [bSha
, bMode
] = parse(runProgram(['git-ls-tree', bTree
, '--', path
]))
415 res
.stages
[1].sha1
= oSha
416 res
.stages
[1].mode
= oMode
417 res
.stages
[2].sha1
= aSha
418 res
.stages
[2].mode
= aMode
419 res
.stages
[3].sha1
= bSha
420 res
.stages
[3].mode
= bMode
424 # Rename detection and handling
425 # -----------------------------
429 src
, srcSha
, srcMode
, srcCacheEntry
,
430 dst
, dstSha
, dstMode
, dstCacheEntry
,
434 self
.srcMode
= srcMode
435 self
.srcCacheEntry
= srcCacheEntry
438 self
.dstMode
= dstMode
439 self
.dstCacheEntry
= dstCacheEntry
442 self
.processed
= False
444 class RenameEntryContainer
:
449 def add(self
, entry
):
450 self
.entriesSrc
[entry
.srcName
] = entry
451 self
.entriesDst
[entry
.dstName
] = entry
453 def getSrc(self
, path
):
454 return self
.entriesSrc
.get(path
)
456 def getDst(self
, path
):
457 return self
.entriesDst
.get(path
)
460 return self
.entriesSrc
.itervalues()
462 parseDiffRenamesRE
= re
.compile('^:([0-7]+) ([0-7]+) ([0-9a-f]{40}) ([0-9a-f]{40}) R([0-9]*)$')
463 def getRenames(tree
, oTree
, aTree
, bTree
, cacheEntries
):
464 '''Get information of all renames which occured between 'oTree' and
465 'tree'. We need the three trees in the merge ('oTree', 'aTree' and
466 'bTree') to be able to associate the correct cache entries with
467 the rename information. 'tree' is always equal to either aTree or bTree.'''
469 assert(tree
== aTree
or tree
== bTree
)
470 inp
= runProgram(['git-diff-tree', '-M', '--diff-filter=R', '-r',
473 ret
= RenameEntryContainer()
475 recs
= inp
.split("\0")
476 recs
.pop() # remove last entry (which is '')
480 m
= parseDiffRenamesRE
.match(rec
)
483 die('Unexpected output from git-diff-tree:', rec
)
485 srcMode
= int(m
.group(1), 8)
486 dstMode
= int(m
.group(2), 8)
493 srcCacheEntry
= cacheEntries
.get(src
)
494 if not srcCacheEntry
:
495 srcCacheEntry
= getCacheEntry(src
, oTree
, aTree
, bTree
)
496 cacheEntries
.add(srcCacheEntry
)
498 dstCacheEntry
= cacheEntries
.get(dst
)
499 if not dstCacheEntry
:
500 dstCacheEntry
= getCacheEntry(dst
, oTree
, aTree
, bTree
)
501 cacheEntries
.add(dstCacheEntry
)
503 ret
.add(RenameEntry(src
, srcSha
, srcMode
, srcCacheEntry
,
504 dst
, dstSha
, dstMode
, dstCacheEntry
,
506 except StopIteration:
510 def fmtRename(src
, dst
):
511 srcPath
= src
.split('/')
512 dstPath
= dst
.split('/')
514 endIndex
= min(len(srcPath
), len(dstPath
)) - 1
515 for x
in range(0, endIndex
):
516 if srcPath
[x
] == dstPath
[x
]:
517 path
.append(srcPath
[x
])
523 return '/'.join(path
) + \
524 '/{' + '/'.join(srcPath
[endIndex
:]) + ' => ' + \
525 '/'.join(dstPath
[endIndex
:]) + '}'
527 return src
+ ' => ' + dst
529 def processRenames(renamesA
, renamesB
, branchNameA
, branchNameB
):
532 srcNames
.add(x
.srcName
)
534 srcNames
.add(x
.srcName
)
537 for path
in srcNames
:
538 if renamesA
.getSrc(path
):
541 branchName1
= branchNameA
542 branchName2
= branchNameB
546 branchName1
= branchNameB
547 branchName2
= branchNameA
549 ren1
= renames1
.getSrc(path
)
550 ren2
= renames2
.getSrc(path
)
552 ren1
.dstCacheEntry
.processed
= True
553 ren1
.srcCacheEntry
.processed
= True
558 ren1
.processed
= True
559 removeFile(True, ren1
.srcName
)
561 # Renamed in 1 and renamed in 2
562 assert(ren1
.srcName
== ren2
.srcName
)
563 ren2
.dstCacheEntry
.processed
= True
564 ren2
.processed
= True
566 if ren1
.dstName
!= ren2
.dstName
:
567 output('CONFLICT (rename/rename): Rename',
568 fmtRename(path
, ren1
.dstName
), 'in branch', branchName1
,
569 'rename', fmtRename(path
, ren2
.dstName
), 'in',
573 if ren1
.dstName
in currentDirectorySet
:
574 dstName1
= uniquePath(ren1
.dstName
, branchName1
)
575 output(ren1
.dstName
, 'is a directory in', branchName2
,
576 'adding as', dstName1
, 'instead.')
577 removeFile(False, ren1
.dstName
)
579 dstName1
= ren1
.dstName
581 if ren2
.dstName
in currentDirectorySet
:
582 dstName2
= uniquePath(ren2
.dstName
, branchName2
)
583 output(ren2
.dstName
, 'is a directory in', branchName1
,
584 'adding as', dstName2
, 'instead.')
585 removeFile(False, ren2
.dstName
)
587 dstName2
= ren1
.dstName
589 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, dstName1
)
590 updateFile(False, ren2
.dstSha
, ren2
.dstMode
, dstName2
)
592 [resSha
, resMode
, clean
, merge
] = \
593 mergeFile(ren1
.srcName
, ren1
.srcSha
, ren1
.srcMode
,
594 ren1
.dstName
, ren1
.dstSha
, ren1
.dstMode
,
595 ren2
.dstName
, ren2
.dstSha
, ren2
.dstMode
,
596 branchName1
, branchName2
)
598 if merge
or not clean
:
599 output('Renaming', fmtRename(path
, ren1
.dstName
))
602 output('Auto-merging', ren1
.dstName
)
605 output('CONFLICT (content): merge conflict in',
610 updateFileExt(ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
,
611 updateCache
=True, updateWd
=False)
612 updateFile(clean
, resSha
, resMode
, ren1
.dstName
)
614 # Renamed in 1, maybe changed in 2
615 if renamesA
== renames1
:
620 srcShaOtherBranch
= ren1
.srcCacheEntry
.stages
[stage
].sha1
621 srcModeOtherBranch
= ren1
.srcCacheEntry
.stages
[stage
].mode
623 dstShaOtherBranch
= ren1
.dstCacheEntry
.stages
[stage
].sha1
624 dstModeOtherBranch
= ren1
.dstCacheEntry
.stages
[stage
].mode
628 if ren1
.dstName
in currentDirectorySet
:
629 newPath
= uniquePath(ren1
.dstName
, branchName1
)
630 output('CONFLICT (rename/directory): Rename',
631 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', branchName1
,
632 'directory', ren1
.dstName
, 'added in', branchName2
)
633 output('Renaming', ren1
.srcName
, 'to', newPath
, 'instead')
635 removeFile(False, ren1
.dstName
)
636 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, newPath
)
637 elif srcShaOtherBranch
== None:
638 output('CONFLICT (rename/delete): Rename',
639 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in',
640 branchName1
, 'and deleted in', branchName2
)
642 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
)
643 elif dstShaOtherBranch
:
644 newPath
= uniquePath(ren1
.dstName
, branchName2
)
645 output('CONFLICT (rename/add): Rename',
646 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in',
647 branchName1
+ '.', ren1
.dstName
, 'added in', branchName2
)
648 output('Adding as', newPath
, 'instead')
649 updateFile(False, dstShaOtherBranch
, dstModeOtherBranch
, newPath
)
652 elif renames2
.getDst(ren1
.dstName
):
653 dst2
= renames2
.getDst(ren1
.dstName
)
654 newPath1
= uniquePath(ren1
.dstName
, branchName1
)
655 newPath2
= uniquePath(dst2
.dstName
, branchName2
)
656 output('CONFLICT (rename/rename): Rename',
657 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in',
658 branchName1
+'. Rename',
659 fmtRename(dst2
.srcName
, dst2
.dstName
), 'in', branchName2
)
660 output('Renaming', ren1
.srcName
, 'to', newPath1
, 'and',
661 dst2
.srcName
, 'to', newPath2
, 'instead')
662 removeFile(False, ren1
.dstName
)
663 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, newPath1
)
664 updateFile(False, dst2
.dstSha
, dst2
.dstMode
, newPath2
)
665 dst2
.processed
= True
671 [resSha
, resMode
, clean
, merge
] = \
672 mergeFile(ren1
.srcName
, ren1
.srcSha
, ren1
.srcMode
,
673 ren1
.dstName
, ren1
.dstSha
, ren1
.dstMode
,
674 ren1
.srcName
, srcShaOtherBranch
, srcModeOtherBranch
,
675 branchName1
, branchName2
)
677 if merge
or not clean
:
678 output('Renaming', fmtRename(ren1
.srcName
, ren1
.dstName
))
681 output('Auto-merging', ren1
.dstName
)
684 output('CONFLICT (rename/modify): Merge conflict in',
689 updateFileExt(ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
,
690 updateCache
=True, updateWd
=False)
691 updateFile(clean
, resSha
, resMode
, ren1
.dstName
)
695 # Per entry merge function
696 # ------------------------
698 def processEntry(entry
, branch1Name
, branch2Name
):
699 '''Merge one cache entry.'''
701 debug('processing', entry
.path
, 'clean cache:', cacheOnly
)
706 oSha
= entry
.stages
[1].sha1
707 oMode
= entry
.stages
[1].mode
708 aSha
= entry
.stages
[2].sha1
709 aMode
= entry
.stages
[2].mode
710 bSha
= entry
.stages
[3].sha1
711 bMode
= entry
.stages
[3].mode
713 assert(oSha
== None or isSha(oSha
))
714 assert(aSha
== None or isSha(aSha
))
715 assert(bSha
== None or isSha(bSha
))
717 assert(oMode
== None or type(oMode
) is int)
718 assert(aMode
== None or type(aMode
) is int)
719 assert(bMode
== None or type(bMode
) is int)
721 if (oSha
and (not aSha
or not bSha
)):
723 # Case A: Deleted in one
725 if (not aSha
and not bSha
) or \
726 (aSha
== oSha
and not bSha
) or \
727 (not aSha
and bSha
== oSha
):
728 # Deleted in both or deleted in one and unchanged in the other
730 output('Removing', path
)
731 removeFile(True, path
)
733 # Deleted in one and changed in the other
736 output('CONFLICT (delete/modify):', path
, 'deleted in',
737 branch1Name
, 'and modified in', branch2Name
+ '.',
738 'Version', branch2Name
, 'of', path
, 'left in tree.')
742 output('CONFLICT (modify/delete):', path
, 'deleted in',
743 branch2Name
, 'and modified in', branch1Name
+ '.',
744 'Version', branch1Name
, 'of', path
, 'left in tree.')
748 updateFile(False, sha
, mode
, path
)
750 elif (not oSha
and aSha
and not bSha
) or \
751 (not oSha
and not aSha
and bSha
):
753 # Case B: Added in one.
756 addBranch
= branch1Name
757 otherBranch
= branch2Name
760 conf
= 'file/directory'
762 addBranch
= branch2Name
763 otherBranch
= branch1Name
766 conf
= 'directory/file'
768 if path
in currentDirectorySet
:
770 newPath
= uniquePath(path
, addBranch
)
771 output('CONFLICT (' + conf
+ '):',
772 'There is a directory with name', path
, 'in',
773 otherBranch
+ '. Adding', path
, 'as', newPath
)
775 removeFile(False, path
)
776 updateFile(False, sha
, mode
, newPath
)
778 output('Adding', path
)
779 updateFile(True, sha
, mode
, path
)
781 elif not oSha
and aSha
and bSha
:
783 # Case C: Added in both (check for same permissions).
788 output('CONFLICT: File', path
,
789 'added identically in both branches, but permissions',
790 'conflict', '0%o' % aMode
, '->', '0%o' % bMode
)
791 output('CONFLICT: adding with permission:', '0%o' % aMode
)
793 updateFile(False, aSha
, aMode
, path
)
795 # This case is handled by git-read-tree
799 newPath1
= uniquePath(path
, branch1Name
)
800 newPath2
= uniquePath(path
, branch2Name
)
801 output('CONFLICT (add/add): File', path
,
802 'added non-identically in both branches. Adding as',
803 newPath1
, 'and', newPath2
, 'instead.')
804 removeFile(False, path
)
805 updateFile(False, aSha
, aMode
, newPath1
)
806 updateFile(False, bSha
, bMode
, newPath2
)
808 elif oSha
and aSha
and bSha
:
810 # case D: Modified in both, but differently.
812 output('Auto-merging', path
)
813 [sha
, mode
, clean
, dummy
] = \
814 mergeFile(path
, oSha
, oMode
,
817 branch1Name
, branch2Name
)
819 updateFile(True, sha
, mode
, path
)
822 output('CONFLICT (content): Merge conflict in', path
)
825 updateFile(False, sha
, mode
, path
)
827 updateFileExt(aSha
, aMode
, path
,
828 updateCache
=True, updateWd
=False)
829 updateFileExt(sha
, mode
, path
, updateCache
=False, updateWd
=True)
831 die("ERROR: Fatal merge failure, shouldn't happen.")
836 die('Usage:', sys
.argv
[0], ' <base>... -- <head> <remote>..')
838 # main entry point as merge strategy module
839 # The first parameters up to -- are merge bases, and the rest are heads.
840 # This strategy module figures out merge bases itself, so we only
843 if len(sys
.argv
) < 4:
846 for nextArg
in xrange(1, len(sys
.argv
)):
847 if sys
.argv
[nextArg
] == '--':
848 if len(sys
.argv
) != nextArg
+ 3:
849 die('Not handling anything other than two heads merge.')
851 h1
= firstBranch
= sys
.argv
[nextArg
+ 1]
852 h2
= secondBranch
= sys
.argv
[nextArg
+ 2]
857 print 'Merging', h1
, 'with', h2
860 h1
= runProgram(['git-rev-parse', '--verify', h1
+ '^0']).rstrip()
861 h2
= runProgram(['git-rev-parse', '--verify', h2
+ '^0']).rstrip()
863 graph
= buildGraph([h1
, h2
])
865 [dummy
, clean
] = merge(graph
.shaMap
[h1
], graph
.shaMap
[h2
],
866 firstBranch
, secondBranch
, graph
)
870 if isinstance(sys
.exc_info()[1], SystemExit):
873 traceback
.print_exc(None, sys
.stderr
)