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 # ------------------------------------------
168 def mergeFile(oPath
, oSha
, oMode
, aPath
, aSha
, aMode
, bPath
, bSha
, bMode
,
169 branch1Name
, branch2Name
):
174 if stat
.S_IFMT(aMode
) != stat
.S_IFMT(bMode
):
176 if stat
.S_ISREG(aMode
):
183 if aSha
!= oSha
and bSha
!= oSha
:
184 merge
= MERGE_TRIVIAL
195 elif stat
.S_ISREG(aMode
):
196 assert(stat
.S_ISREG(bMode
))
198 orig
= runProgram(['git-unpack-file', oSha
]).rstrip()
199 src1
= runProgram(['git-unpack-file', aSha
]).rstrip()
200 src2
= runProgram(['git-unpack-file', bSha
]).rstrip()
201 [out
, code
] = runProgram(['merge',
202 '-L', branch1Name
+ '/' + aPath
,
203 '-L', 'orig/' + oPath
,
204 '-L', branch2Name
+ '/' + bPath
,
205 src1
, orig
, src2
], returnCode
=True)
207 sha
= runProgram(['git-hash-object', '-t', 'blob', '-w',
217 assert(stat
.S_ISLNK(aMode
) and stat
.S_ISLNK(bMode
))
223 return [sha
, mode
, clean
, merge
]
225 def updateFile(clean
, sha
, mode
, path
):
226 updateCache
= cacheOnly
or clean
227 updateWd
= not cacheOnly
229 return updateFileExt(sha
, mode
, path
, updateCache
, updateWd
)
231 def updateFileExt(sha
, mode
, path
, updateCache
, updateWd
):
236 pathComponents
= path
.split('/')
237 for x
in xrange(1, len(pathComponents
)):
238 p
= '/'.join(pathComponents
[0:x
])
241 createDir
= not stat
.S_ISDIR(os
.lstat(p
).st_mode
)
249 die("Couldn't create directory", p
, e
.strerror
)
251 prog
= ['git-cat-file', 'blob', sha
]
252 if stat
.S_ISREG(mode
):
261 fd
= os
.open(path
, os
.O_WRONLY | os
.O_TRUNC | os
.O_CREAT
, mode
)
262 proc
= subprocess
.Popen(prog
, stdout
=fd
)
265 elif stat
.S_ISLNK(mode
):
266 linkTarget
= runProgram(prog
)
267 os
.symlink(linkTarget
, path
)
271 if updateWd
and updateCache
:
272 runProgram(['git-update-index', '--add', '--', path
])
274 runProgram(['git-update-index', '--add', '--cacheinfo',
275 '0%o' % mode
, sha
, path
])
277 def removeFile(clean
, path
):
278 updateCache
= cacheOnly
or clean
279 updateWd
= not cacheOnly
282 runProgram(['git-update-index', '--force-remove', '--', path
])
288 if e
.errno
!= errno
.ENOENT
and e
.errno
!= errno
.EISDIR
:
291 def uniquePath(path
, branch
):
292 def fileExists(path
):
297 if e
.errno
== errno
.ENOENT
:
302 newPath
= path
+ '_' + branch
304 while newPath
in currentFileSet
or \
305 newPath
in currentDirectorySet
or \
308 newPath
= path
+ '_' + branch
+ '_' + str(suffix
)
309 currentFileSet
.add(newPath
)
312 # Cache entry management
313 # ----------------------
316 def __init__(self
, path
):
322 # Used for debugging only
324 if self
.mode
!= None:
325 m
= '0%o' % self
.mode
333 return 'sha1: ' + sha1
+ ' mode: ' + m
335 self
.stages
= [Stage(), Stage(), Stage(), Stage()]
337 self
.processed
= False
340 return 'path: ' + self
.path
+ ' stages: ' + repr([str(x
) for x
in self
.stages
])
342 class CacheEntryContainer
:
346 def add(self
, entry
):
347 self
.entries
[entry
.path
] = entry
350 return self
.entries
.get(path
)
353 return self
.entries
.itervalues()
355 unmergedRE
= re
.compile(r
'^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
356 def unmergedCacheEntries():
357 '''Create a dictionary mapping file names to CacheEntry
358 objects. The dictionary contains one entry for every path with a
359 non-zero stage entry.'''
361 lines
= runProgram(['git-ls-files', '-z', '--unmerged']).split('\0')
364 res
= CacheEntryContainer()
366 m
= unmergedRE
.match(l
)
368 mode
= int(m
.group(1), 8)
370 stage
= int(m
.group(3))
378 e
.stages
[stage
].mode
= mode
379 e
.stages
[stage
].sha1
= sha1
381 die('Error: Merge program failed: Unexpected output from',
385 lsTreeRE
= re
.compile(r
'^([0-7]+) (\S+) ([0-9a-f]{40})\t(.*)\n$', re
.S
)
386 def getCacheEntry(path
, origTree
, aTree
, bTree
):
387 '''Returns a CacheEntry object which doesn't have to correspond to
388 a real cache entry in Git's index.'''
394 m
= lsTreeRE
.match(out
)
396 die('Unexpected output from git-ls-tree:', out
)
397 elif m
.group(2) == 'blob':
398 return [m
.group(3), int(m
.group(1), 8)]
402 res
= CacheEntry(path
)
404 [oSha
, oMode
] = parse(runProgram(['git-ls-tree', origTree
, '--', path
]))
405 [aSha
, aMode
] = parse(runProgram(['git-ls-tree', aTree
, '--', path
]))
406 [bSha
, bMode
] = parse(runProgram(['git-ls-tree', bTree
, '--', path
]))
408 res
.stages
[1].sha1
= oSha
409 res
.stages
[1].mode
= oMode
410 res
.stages
[2].sha1
= aSha
411 res
.stages
[2].mode
= aMode
412 res
.stages
[3].sha1
= bSha
413 res
.stages
[3].mode
= bMode
417 # Rename detection and handling
418 # -----------------------------
422 src
, srcSha
, srcMode
, srcCacheEntry
,
423 dst
, dstSha
, dstMode
, dstCacheEntry
,
427 self
.srcMode
= srcMode
428 self
.srcCacheEntry
= srcCacheEntry
431 self
.dstMode
= dstMode
432 self
.dstCacheEntry
= dstCacheEntry
435 self
.processed
= False
437 class RenameEntryContainer
:
442 def add(self
, entry
):
443 self
.entriesSrc
[entry
.srcName
] = entry
444 self
.entriesDst
[entry
.dstName
] = entry
446 def getSrc(self
, path
):
447 return self
.entriesSrc
.get(path
)
449 def getDst(self
, path
):
450 return self
.entriesDst
.get(path
)
453 return self
.entriesSrc
.itervalues()
455 parseDiffRenamesRE
= re
.compile('^:([0-7]+) ([0-7]+) ([0-9a-f]{40}) ([0-9a-f]{40}) R([0-9]*)$')
456 def getRenames(tree
, oTree
, aTree
, bTree
, cacheEntries
):
457 '''Get information of all renames which occured between 'oTree' and
458 'tree'. We need the three trees in the merge ('oTree', 'aTree' and
459 'bTree') to be able to associate the correct cache entries with
460 the rename information. 'tree' is always equal to either aTree or bTree.'''
462 assert(tree
== aTree
or tree
== bTree
)
463 inp
= runProgram(['git-diff-tree', '-M', '--diff-filter=R', '-r',
466 ret
= RenameEntryContainer()
468 recs
= inp
.split("\0")
469 recs
.pop() # remove last entry (which is '')
473 m
= parseDiffRenamesRE
.match(rec
)
476 die('Unexpected output from git-diff-tree:', rec
)
478 srcMode
= int(m
.group(1), 8)
479 dstMode
= int(m
.group(2), 8)
486 srcCacheEntry
= cacheEntries
.get(src
)
487 if not srcCacheEntry
:
488 srcCacheEntry
= getCacheEntry(src
, oTree
, aTree
, bTree
)
489 cacheEntries
.add(srcCacheEntry
)
491 dstCacheEntry
= cacheEntries
.get(dst
)
492 if not dstCacheEntry
:
493 dstCacheEntry
= getCacheEntry(dst
, oTree
, aTree
, bTree
)
494 cacheEntries
.add(dstCacheEntry
)
496 ret
.add(RenameEntry(src
, srcSha
, srcMode
, srcCacheEntry
,
497 dst
, dstSha
, dstMode
, dstCacheEntry
,
499 except StopIteration:
503 def fmtRename(src
, dst
):
504 srcPath
= src
.split('/')
505 dstPath
= dst
.split('/')
507 endIndex
= min(len(srcPath
), len(dstPath
)) - 1
508 for x
in range(0, endIndex
):
509 if srcPath
[x
] == dstPath
[x
]:
510 path
.append(srcPath
[x
])
516 return '/'.join(path
) + \
517 '/{' + '/'.join(srcPath
[endIndex
:]) + ' => ' + \
518 '/'.join(dstPath
[endIndex
:]) + '}'
520 return src
+ ' => ' + dst
522 def processRenames(renamesA
, renamesB
, branchNameA
, branchNameB
):
525 srcNames
.add(x
.srcName
)
527 srcNames
.add(x
.srcName
)
530 for path
in srcNames
:
531 if renamesA
.getSrc(path
):
534 branchName1
= branchNameA
535 branchName2
= branchNameB
539 branchName1
= branchNameB
540 branchName2
= branchNameA
542 ren1
= renames1
.getSrc(path
)
543 ren2
= renames2
.getSrc(path
)
545 ren1
.dstCacheEntry
.processed
= True
546 ren1
.srcCacheEntry
.processed
= True
551 ren1
.processed
= True
552 removeFile(True, ren1
.srcName
)
554 # Renamed in 1 and renamed in 2
555 assert(ren1
.srcName
== ren2
.srcName
)
556 ren2
.dstCacheEntry
.processed
= True
557 ren2
.processed
= True
559 if ren1
.dstName
!= ren2
.dstName
:
560 print 'CONFLICT (rename/rename): Rename', \
561 fmtRename(path
, ren1
.dstName
), 'in branch', branchName1
, \
562 'rename', fmtRename(path
, ren2
.dstName
), 'in', branchName2
565 if ren1
.dstName
in currentDirectorySet
:
566 dstName1
= uniquePath(ren1
.dstName
, branchName1
)
567 print ren1
.dstName
, 'is a directory in', branchName2
, \
568 'adding as', dstName1
, 'instead.'
569 removeFile(False, ren1
.dstName
)
571 dstName1
= ren1
.dstName
573 if ren2
.dstName
in currentDirectorySet
:
574 dstName2
= uniquePath(ren2
.dstName
, branchName2
)
575 print ren2
.dstName
, 'is a directory in', branchName1
, \
576 'adding as', dstName2
, 'instead.'
577 removeFile(False, ren2
.dstName
)
579 dstName2
= ren1
.dstName
581 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, dstName1
)
582 updateFile(False, ren2
.dstSha
, ren2
.dstMode
, dstName2
)
584 [resSha
, resMode
, clean
, merge
] = \
585 mergeFile(ren1
.srcName
, ren1
.srcSha
, ren1
.srcMode
,
586 ren1
.dstName
, ren1
.dstSha
, ren1
.dstMode
,
587 ren2
.dstName
, ren2
.dstSha
, ren2
.dstMode
,
588 branchName1
, branchName2
)
590 if merge
or not clean
:
591 print 'Renaming', fmtRename(path
, ren1
.dstName
)
593 if merge
== MERGE_3WAY
:
594 print 'Auto-merging', ren1
.dstName
597 print 'CONFLICT (content): merge conflict in', ren1
.dstName
601 updateFileExt(ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
,
602 updateCache
=True, updateWd
=False)
603 updateFile(clean
, resSha
, resMode
, ren1
.dstName
)
605 # Renamed in 1, maybe changed in 2
606 if renamesA
== renames1
:
611 srcShaOtherBranch
= ren1
.srcCacheEntry
.stages
[stage
].sha1
612 srcModeOtherBranch
= ren1
.srcCacheEntry
.stages
[stage
].mode
614 dstShaOtherBranch
= ren1
.dstCacheEntry
.stages
[stage
].sha1
615 dstModeOtherBranch
= ren1
.dstCacheEntry
.stages
[stage
].mode
619 if ren1
.dstName
in currentDirectorySet
:
620 newPath
= uniquePath(ren1
.dstName
, branchName1
)
621 print 'CONFLICT (rename/directory): Rename', \
622 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', branchName1
,\
623 'directory', ren1
.dstName
, 'added in', branchName2
624 print 'Renaming', ren1
.srcName
, 'to', newPath
, 'instead'
626 removeFile(False, ren1
.dstName
)
627 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, newPath
)
628 elif srcShaOtherBranch
== None:
629 print 'CONFLICT (rename/delete): Rename', \
630 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', \
631 branchName1
, 'and deleted in', branchName2
633 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
)
634 elif dstShaOtherBranch
:
635 newPath
= uniquePath(ren1
.dstName
, branchName2
)
636 print 'CONFLICT (rename/add): Rename', \
637 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', \
638 branchName1
+ '.', ren1
.dstName
, 'added in', branchName2
639 print 'Adding as', newPath
, 'instead'
640 updateFile(False, dstShaOtherBranch
, dstModeOtherBranch
, newPath
)
643 elif renames2
.getDst(ren1
.dstName
):
644 dst2
= renames2
.getDst(ren1
.dstName
)
645 newPath1
= uniquePath(ren1
.dstName
, branchName1
)
646 newPath2
= uniquePath(dst2
.dstName
, branchName2
)
647 print 'CONFLICT (rename/rename): Rename', \
648 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', \
649 branchName1
+'. Rename', \
650 fmtRename(dst2
.srcName
, dst2
.dstName
), 'in', branchName2
651 print 'Renaming', ren1
.srcName
, 'to', newPath1
, 'and', \
652 dst2
.srcName
, 'to', newPath2
, 'instead'
653 removeFile(False, ren1
.dstName
)
654 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, newPath1
)
655 updateFile(False, dst2
.dstSha
, dst2
.dstMode
, newPath2
)
656 dst2
.processed
= True
662 [resSha
, resMode
, clean
, merge
] = \
663 mergeFile(ren1
.srcName
, ren1
.srcSha
, ren1
.srcMode
,
664 ren1
.dstName
, ren1
.dstSha
, ren1
.dstMode
,
665 ren1
.srcName
, srcShaOtherBranch
, srcModeOtherBranch
,
666 branchName1
, branchName2
)
668 if merge
or not clean
:
669 print 'Renaming', fmtRename(ren1
.srcName
, ren1
.dstName
)
671 if merge
== MERGE_3WAY
:
672 print 'Auto-merging', ren1
.dstName
675 print 'CONFLICT (rename/modify): Merge conflict in', ren1
.dstName
679 updateFileExt(ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
,
680 updateCache
=True, updateWd
=False)
681 updateFile(clean
, resSha
, resMode
, ren1
.dstName
)
685 # Per entry merge function
686 # ------------------------
688 def processEntry(entry
, branch1Name
, branch2Name
):
689 '''Merge one cache entry.'''
691 debug('processing', entry
.path
, 'clean cache:', cacheOnly
)
696 oSha
= entry
.stages
[1].sha1
697 oMode
= entry
.stages
[1].mode
698 aSha
= entry
.stages
[2].sha1
699 aMode
= entry
.stages
[2].mode
700 bSha
= entry
.stages
[3].sha1
701 bMode
= entry
.stages
[3].mode
703 assert(oSha
== None or isSha(oSha
))
704 assert(aSha
== None or isSha(aSha
))
705 assert(bSha
== None or isSha(bSha
))
707 assert(oMode
== None or type(oMode
) is int)
708 assert(aMode
== None or type(aMode
) is int)
709 assert(bMode
== None or type(bMode
) is int)
711 if (oSha
and (not aSha
or not bSha
)):
713 # Case A: Deleted in one
715 if (not aSha
and not bSha
) or \
716 (aSha
== oSha
and not bSha
) or \
717 (not aSha
and bSha
== oSha
):
718 # Deleted in both or deleted in one and unchanged in the other
720 print 'Removing', path
721 removeFile(True, path
)
723 # Deleted in one and changed in the other
726 print 'CONFLICT (delete/modify):', path
, 'deleted in', \
727 branch1Name
, 'and modified in', branch2Name
+ '.', \
728 'Version', branch2Name
, 'of', path
, 'left in tree.'
732 print 'CONFLICT (modify/delete):', path
, 'deleted in', \
733 branch2Name
, 'and modified in', branch1Name
+ '.', \
734 'Version', branch1Name
, 'of', path
, 'left in tree.'
738 updateFile(False, sha
, mode
, path
)
740 elif (not oSha
and aSha
and not bSha
) or \
741 (not oSha
and not aSha
and bSha
):
743 # Case B: Added in one.
746 addBranch
= branch1Name
747 otherBranch
= branch2Name
750 conf
= 'file/directory'
752 addBranch
= branch2Name
753 otherBranch
= branch1Name
756 conf
= 'directory/file'
758 if path
in currentDirectorySet
:
760 newPath
= uniquePath(path
, addBranch
)
761 print 'CONFLICT (' + conf
+ '):', \
762 'There is a directory with name', path
, 'in', \
763 otherBranch
+ '. Adding', path
, 'as', newPath
765 removeFile(False, path
)
766 updateFile(False, sha
, mode
, newPath
)
769 updateFile(True, sha
, mode
, path
)
771 elif not oSha
and aSha
and bSha
:
773 # Case C: Added in both (check for same permissions).
778 print 'CONFLICT: File', path
, \
779 'added identically in both branches, but permissions', \
780 'conflict', '0%o' % aMode
, '->', '0%o' % bMode
781 print 'CONFLICT: adding with permission:', '0%o' % aMode
783 updateFile(False, aSha
, aMode
, path
)
785 # This case is handled by git-read-tree
789 newPath1
= uniquePath(path
, branch1Name
)
790 newPath2
= uniquePath(path
, branch2Name
)
791 print 'CONFLICT (add/add): File', path
, \
792 'added non-identically in both branches. Adding as', \
793 newPath1
, 'and', newPath2
, 'instead.'
794 removeFile(False, path
)
795 updateFile(False, aSha
, aMode
, newPath1
)
796 updateFile(False, bSha
, bMode
, newPath2
)
798 elif oSha
and aSha
and bSha
:
800 # case D: Modified in both, but differently.
802 print 'Auto-merging', path
803 [sha
, mode
, clean
, dummy
] = \
804 mergeFile(path
, oSha
, oMode
,
807 branch1Name
, branch2Name
)
809 updateFile(True, sha
, mode
, path
)
812 print 'CONFLICT (content): Merge conflict in', path
815 updateFile(False, sha
, mode
, path
)
817 updateFileExt(aSha
, aMode
, path
,
818 updateCache
=True, updateWd
=False)
819 updateFileExt(sha
, mode
, path
, updateCache
=False, updateWd
=True)
821 die("ERROR: Fatal merge failure, shouldn't happen.")
826 die('Usage:', sys
.argv
[0], ' <base>... -- <head> <remote>..')
828 # main entry point as merge strategy module
829 # The first parameters up to -- are merge bases, and the rest are heads.
830 # This strategy module figures out merge bases itself, so we only
833 if len(sys
.argv
) < 4:
836 for nextArg
in xrange(1, len(sys
.argv
)):
837 if sys
.argv
[nextArg
] == '--':
838 if len(sys
.argv
) != nextArg
+ 3:
839 die('Not handling anything other than two heads merge.')
841 h1
= firstBranch
= sys
.argv
[nextArg
+ 1]
842 h2
= secondBranch
= sys
.argv
[nextArg
+ 2]
847 print 'Merging', h1
, 'with', h2
850 h1
= runProgram(['git-rev-parse', '--verify', h1
+ '^0']).rstrip()
851 h2
= runProgram(['git-rev-parse', '--verify', h2
+ '^0']).rstrip()
853 graph
= buildGraph([h1
, h2
])
855 [dummy
, clean
] = merge(graph
.shaMap
[h1
], graph
.shaMap
[h2
],
856 firstBranch
, secondBranch
, graph
)
860 if isinstance(sys
.exc_info()[1], SystemExit):
863 traceback
.print_exc(None, sys
.stderr
)