3 # Copyright (C) 2005 Fredrik Kuivinen
6 import sys
, math
, random
, os
, re
, signal
, tempfile
, stat
, errno
, traceback
7 from heapq
import heappush
, heappop
10 sys
.path
.append('''@@GIT_PYTHON_PATH@@''')
11 from gitMergeCommon
import *
15 sys
.stdout
.write(' '*outputIndent
)
18 originalIndexFile
= os
.environ
.get('GIT_INDEX_FILE',
19 os
.environ
.get('GIT_DIR', '.git') + '/index')
20 temporaryIndexFile
= os
.environ
.get('GIT_DIR', '.git') + \
21 '/merge-recursive-tmp-index'
22 def setupIndex(temporary
):
24 os
.unlink(temporaryIndexFile
)
28 newIndex
= temporaryIndexFile
30 newIndex
= originalIndexFile
31 os
.environ
['GIT_INDEX_FILE'] = newIndex
33 # This is a global variable which is used in a number of places but
34 # only written to in the 'merge' function.
36 # cacheOnly == True => Don't leave any non-stage 0 entries in the cache and
37 # don't update the working directory.
38 # False => Leave unmerged entries in the cache and update
39 # the working directory.
43 # The entry point to the merge code
44 # ---------------------------------
46 def merge(h1
, h2
, branch1Name
, branch2Name
, graph
, callDepth
=0):
47 '''Merge the commits h1 and h2, return the resulting virtual
48 commit object and a flag indicating the cleaness of the merge.'''
49 assert(isinstance(h1
, Commit
) and isinstance(h2
, Commit
))
50 assert(isinstance(graph
, Graph
))
59 ca
= getCommonAncestors(graph
, h1
, h2
)
60 output('found', len(ca
), 'common ancestor(s):')
67 outputIndent
= callDepth
+1
68 [mergedCA
, dummy
] = merge(mergedCA
, h
,
69 'Temporary merge branch 1',
70 'Temporary merge branch 2',
72 outputIndent
= callDepth
73 assert(isinstance(mergedCA
, Commit
))
81 runProgram(['git-read-tree', h1
.tree()])
84 [shaRes
, clean
] = mergeTrees(h1
.tree(), h2
.tree(), mergedCA
.tree(),
85 branch1Name
, branch2Name
)
87 if clean
or cacheOnly
:
88 res
= Commit(None, [h1
, h2
], tree
=shaRes
)
95 getFilesRE
= re
.compile(r
'^([0-7]+) (\S+) ([0-9a-f]{40})\t(.*)$', re
.S
)
96 def getFilesAndDirs(tree
):
99 out
= runProgram(['git-ls-tree', '-r', '-z', tree
])
100 for l
in out
.split('\0'):
101 m
= getFilesRE
.match(l
)
103 if m
.group(2) == 'tree':
105 elif m
.group(2) == 'blob':
106 files
.add(m
.group(4))
110 # Those two global variables are used in a number of places but only
111 # written to in 'mergeTrees' and 'uniquePath'. They keep track of
112 # every file and directory in the two branches that are about to be
114 currentFileSet
= None
115 currentDirectorySet
= None
117 def mergeTrees(head
, merge
, common
, branch1Name
, branch2Name
):
118 '''Merge the trees 'head' and 'merge' with the common ancestor
119 'common'. The name of the head branch is 'branch1Name' and the name of
120 the merge branch is 'branch2Name'. Return a tuple (tree, cleanMerge)
121 where tree is the resulting tree and cleanMerge is True iff the
124 assert(isSha(head
) and isSha(merge
) and isSha(common
))
127 output('Already uptodate!')
135 [out
, code
] = runProgram(['git-read-tree', updateArg
, '-m',
136 common
, head
, merge
], returnCode
= True)
138 die('git-read-tree:', out
)
140 [tree
, code
] = runProgram('git-write-tree', returnCode
=True)
143 global currentFileSet
, currentDirectorySet
144 [currentFileSet
, currentDirectorySet
] = getFilesAndDirs(head
)
145 [filesM
, dirsM
] = getFilesAndDirs(merge
)
146 currentFileSet
.union_update(filesM
)
147 currentDirectorySet
.union_update(dirsM
)
149 entries
= unmergedCacheEntries()
150 renamesHead
= getRenames(head
, common
, head
, merge
, entries
)
151 renamesMerge
= getRenames(merge
, common
, head
, merge
, entries
)
153 cleanMerge
= processRenames(renamesHead
, renamesMerge
,
154 branch1Name
, branch2Name
)
155 for entry
in entries
:
158 if not processEntry(entry
, branch1Name
, branch2Name
):
161 if cleanMerge
or cacheOnly
:
162 tree
= runProgram('git-write-tree').rstrip()
168 return [tree
, cleanMerge
]
170 # Low level file merging, update and removal
171 # ------------------------------------------
173 def mergeFile(oPath
, oSha
, oMode
, aPath
, aSha
, aMode
, bPath
, bSha
, bMode
,
174 branch1Name
, branch2Name
):
179 if stat
.S_IFMT(aMode
) != stat
.S_IFMT(bMode
):
181 if stat
.S_ISREG(aMode
):
188 if aSha
!= oSha
and bSha
!= oSha
:
200 elif stat
.S_ISREG(aMode
):
201 assert(stat
.S_ISREG(bMode
))
203 orig
= runProgram(['git-unpack-file', oSha
]).rstrip()
204 src1
= runProgram(['git-unpack-file', aSha
]).rstrip()
205 src2
= runProgram(['git-unpack-file', bSha
]).rstrip()
206 [out
, code
] = runProgram(['merge',
207 '-L', branch1Name
+ '/' + aPath
,
208 '-L', 'orig/' + oPath
,
209 '-L', branch2Name
+ '/' + bPath
,
210 src1
, orig
, src2
], returnCode
=True)
212 sha
= runProgram(['git-hash-object', '-t', 'blob', '-w',
221 assert(stat
.S_ISLNK(aMode
) and stat
.S_ISLNK(bMode
))
227 return [sha
, mode
, clean
, merge
]
229 def updateFile(clean
, sha
, mode
, path
):
230 updateCache
= cacheOnly
or clean
231 updateWd
= not cacheOnly
233 return updateFileExt(sha
, mode
, path
, updateCache
, updateWd
)
235 def updateFileExt(sha
, mode
, path
, updateCache
, updateWd
):
240 pathComponents
= path
.split('/')
241 for x
in xrange(1, len(pathComponents
)):
242 p
= '/'.join(pathComponents
[0:x
])
245 createDir
= not stat
.S_ISDIR(os
.lstat(p
).st_mode
)
253 die("Couldn't create directory", p
, e
.strerror
)
255 prog
= ['git-cat-file', 'blob', sha
]
256 if stat
.S_ISREG(mode
):
265 fd
= os
.open(path
, os
.O_WRONLY | os
.O_TRUNC | os
.O_CREAT
, mode
)
266 proc
= subprocess
.Popen(prog
, stdout
=fd
)
269 elif stat
.S_ISLNK(mode
):
270 linkTarget
= runProgram(prog
)
271 os
.symlink(linkTarget
, path
)
275 if updateWd
and updateCache
:
276 runProgram(['git-update-index', '--add', '--', path
])
278 runProgram(['git-update-index', '--add', '--cacheinfo',
279 '0%o' % mode
, sha
, path
])
281 def removeFile(clean
, path
):
282 updateCache
= cacheOnly
or clean
283 updateWd
= not cacheOnly
286 runProgram(['git-update-index', '--force-remove', '--', path
])
292 if e
.errno
!= errno
.ENOENT
and e
.errno
!= errno
.EISDIR
:
295 def uniquePath(path
, branch
):
296 def fileExists(path
):
301 if e
.errno
== errno
.ENOENT
:
306 branch
= branch
.replace('/', '_')
307 newPath
= path
+ '~' + branch
309 while newPath
in currentFileSet
or \
310 newPath
in currentDirectorySet
or \
313 newPath
= path
+ '~' + branch
+ '_' + str(suffix
)
314 currentFileSet
.add(newPath
)
317 # Cache entry management
318 # ----------------------
321 def __init__(self
, path
):
327 # Used for debugging only
329 if self
.mode
!= None:
330 m
= '0%o' % self
.mode
338 return 'sha1: ' + sha1
+ ' mode: ' + m
340 self
.stages
= [Stage(), Stage(), Stage(), Stage()]
342 self
.processed
= False
345 return 'path: ' + self
.path
+ ' stages: ' + repr([str(x
) for x
in self
.stages
])
347 class CacheEntryContainer
:
351 def add(self
, entry
):
352 self
.entries
[entry
.path
] = entry
355 return self
.entries
.get(path
)
358 return self
.entries
.itervalues()
360 unmergedRE
= re
.compile(r
'^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re
.S
)
361 def unmergedCacheEntries():
362 '''Create a dictionary mapping file names to CacheEntry
363 objects. The dictionary contains one entry for every path with a
364 non-zero stage entry.'''
366 lines
= runProgram(['git-ls-files', '-z', '--unmerged']).split('\0')
369 res
= CacheEntryContainer()
371 m
= unmergedRE
.match(l
)
373 mode
= int(m
.group(1), 8)
375 stage
= int(m
.group(3))
383 e
.stages
[stage
].mode
= mode
384 e
.stages
[stage
].sha1
= sha1
386 die('Error: Merge program failed: Unexpected output from',
390 lsTreeRE
= re
.compile(r
'^([0-7]+) (\S+) ([0-9a-f]{40})\t(.*)\n$', re
.S
)
391 def getCacheEntry(path
, origTree
, aTree
, bTree
):
392 '''Returns a CacheEntry object which doesn't have to correspond to
393 a real cache entry in Git's index.'''
399 m
= lsTreeRE
.match(out
)
401 die('Unexpected output from git-ls-tree:', out
)
402 elif m
.group(2) == 'blob':
403 return [m
.group(3), int(m
.group(1), 8)]
407 res
= CacheEntry(path
)
409 [oSha
, oMode
] = parse(runProgram(['git-ls-tree', origTree
, '--', path
]))
410 [aSha
, aMode
] = parse(runProgram(['git-ls-tree', aTree
, '--', path
]))
411 [bSha
, bMode
] = parse(runProgram(['git-ls-tree', bTree
, '--', path
]))
413 res
.stages
[1].sha1
= oSha
414 res
.stages
[1].mode
= oMode
415 res
.stages
[2].sha1
= aSha
416 res
.stages
[2].mode
= aMode
417 res
.stages
[3].sha1
= bSha
418 res
.stages
[3].mode
= bMode
422 # Rename detection and handling
423 # -----------------------------
427 src
, srcSha
, srcMode
, srcCacheEntry
,
428 dst
, dstSha
, dstMode
, dstCacheEntry
,
432 self
.srcMode
= srcMode
433 self
.srcCacheEntry
= srcCacheEntry
436 self
.dstMode
= dstMode
437 self
.dstCacheEntry
= dstCacheEntry
440 self
.processed
= False
442 class RenameEntryContainer
:
447 def add(self
, entry
):
448 self
.entriesSrc
[entry
.srcName
] = entry
449 self
.entriesDst
[entry
.dstName
] = entry
451 def getSrc(self
, path
):
452 return self
.entriesSrc
.get(path
)
454 def getDst(self
, path
):
455 return self
.entriesDst
.get(path
)
458 return self
.entriesSrc
.itervalues()
460 parseDiffRenamesRE
= re
.compile('^:([0-7]+) ([0-7]+) ([0-9a-f]{40}) ([0-9a-f]{40}) R([0-9]*)$')
461 def getRenames(tree
, oTree
, aTree
, bTree
, cacheEntries
):
462 '''Get information of all renames which occured between 'oTree' and
463 'tree'. We need the three trees in the merge ('oTree', 'aTree' and
464 'bTree') to be able to associate the correct cache entries with
465 the rename information. 'tree' is always equal to either aTree or bTree.'''
467 assert(tree
== aTree
or tree
== bTree
)
468 inp
= runProgram(['git-diff-tree', '-M', '--diff-filter=R', '-r',
471 ret
= RenameEntryContainer()
473 recs
= inp
.split("\0")
474 recs
.pop() # remove last entry (which is '')
478 m
= parseDiffRenamesRE
.match(rec
)
481 die('Unexpected output from git-diff-tree:', rec
)
483 srcMode
= int(m
.group(1), 8)
484 dstMode
= int(m
.group(2), 8)
491 srcCacheEntry
= cacheEntries
.get(src
)
492 if not srcCacheEntry
:
493 srcCacheEntry
= getCacheEntry(src
, oTree
, aTree
, bTree
)
494 cacheEntries
.add(srcCacheEntry
)
496 dstCacheEntry
= cacheEntries
.get(dst
)
497 if not dstCacheEntry
:
498 dstCacheEntry
= getCacheEntry(dst
, oTree
, aTree
, bTree
)
499 cacheEntries
.add(dstCacheEntry
)
501 ret
.add(RenameEntry(src
, srcSha
, srcMode
, srcCacheEntry
,
502 dst
, dstSha
, dstMode
, dstCacheEntry
,
504 except StopIteration:
508 def fmtRename(src
, dst
):
509 srcPath
= src
.split('/')
510 dstPath
= dst
.split('/')
512 endIndex
= min(len(srcPath
), len(dstPath
)) - 1
513 for x
in range(0, endIndex
):
514 if srcPath
[x
] == dstPath
[x
]:
515 path
.append(srcPath
[x
])
521 return '/'.join(path
) + \
522 '/{' + '/'.join(srcPath
[endIndex
:]) + ' => ' + \
523 '/'.join(dstPath
[endIndex
:]) + '}'
525 return src
+ ' => ' + dst
527 def processRenames(renamesA
, renamesB
, branchNameA
, branchNameB
):
530 srcNames
.add(x
.srcName
)
532 srcNames
.add(x
.srcName
)
535 for path
in srcNames
:
536 if renamesA
.getSrc(path
):
539 branchName1
= branchNameA
540 branchName2
= branchNameB
544 branchName1
= branchNameB
545 branchName2
= branchNameA
547 ren1
= renames1
.getSrc(path
)
548 ren2
= renames2
.getSrc(path
)
550 ren1
.dstCacheEntry
.processed
= True
551 ren1
.srcCacheEntry
.processed
= True
556 ren1
.processed
= True
557 removeFile(True, ren1
.srcName
)
559 # Renamed in 1 and renamed in 2
560 assert(ren1
.srcName
== ren2
.srcName
)
561 ren2
.dstCacheEntry
.processed
= True
562 ren2
.processed
= True
564 if ren1
.dstName
!= ren2
.dstName
:
565 output('CONFLICT (rename/rename): Rename',
566 fmtRename(path
, ren1
.dstName
), 'in branch', branchName1
,
567 'rename', fmtRename(path
, ren2
.dstName
), 'in',
571 if ren1
.dstName
in currentDirectorySet
:
572 dstName1
= uniquePath(ren1
.dstName
, branchName1
)
573 output(ren1
.dstName
, 'is a directory in', branchName2
,
574 'adding as', dstName1
, 'instead.')
575 removeFile(False, ren1
.dstName
)
577 dstName1
= ren1
.dstName
579 if ren2
.dstName
in currentDirectorySet
:
580 dstName2
= uniquePath(ren2
.dstName
, branchName2
)
581 output(ren2
.dstName
, 'is a directory in', branchName1
,
582 'adding as', dstName2
, 'instead.')
583 removeFile(False, ren2
.dstName
)
585 dstName2
= ren1
.dstName
587 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, dstName1
)
588 updateFile(False, ren2
.dstSha
, ren2
.dstMode
, dstName2
)
590 [resSha
, resMode
, clean
, merge
] = \
591 mergeFile(ren1
.srcName
, ren1
.srcSha
, ren1
.srcMode
,
592 ren1
.dstName
, ren1
.dstSha
, ren1
.dstMode
,
593 ren2
.dstName
, ren2
.dstSha
, ren2
.dstMode
,
594 branchName1
, branchName2
)
596 if merge
or not clean
:
597 output('Renaming', fmtRename(path
, ren1
.dstName
))
600 output('Auto-merging', ren1
.dstName
)
603 output('CONFLICT (content): merge conflict in',
608 updateFileExt(ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
,
609 updateCache
=True, updateWd
=False)
610 updateFile(clean
, resSha
, resMode
, ren1
.dstName
)
612 # Renamed in 1, maybe changed in 2
613 if renamesA
== renames1
:
618 srcShaOtherBranch
= ren1
.srcCacheEntry
.stages
[stage
].sha1
619 srcModeOtherBranch
= ren1
.srcCacheEntry
.stages
[stage
].mode
621 dstShaOtherBranch
= ren1
.dstCacheEntry
.stages
[stage
].sha1
622 dstModeOtherBranch
= ren1
.dstCacheEntry
.stages
[stage
].mode
626 if ren1
.dstName
in currentDirectorySet
:
627 newPath
= uniquePath(ren1
.dstName
, branchName1
)
628 output('CONFLICT (rename/directory): Rename',
629 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in', branchName1
,
630 'directory', ren1
.dstName
, 'added in', branchName2
)
631 output('Renaming', ren1
.srcName
, 'to', newPath
, 'instead')
633 removeFile(False, ren1
.dstName
)
634 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, newPath
)
635 elif srcShaOtherBranch
== None:
636 output('CONFLICT (rename/delete): Rename',
637 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in',
638 branchName1
, 'and deleted in', branchName2
)
640 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
)
641 elif dstShaOtherBranch
:
642 newPath
= uniquePath(ren1
.dstName
, branchName2
)
643 output('CONFLICT (rename/add): Rename',
644 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in',
645 branchName1
+ '.', ren1
.dstName
, 'added in', branchName2
)
646 output('Adding as', newPath
, 'instead')
647 updateFile(False, dstShaOtherBranch
, dstModeOtherBranch
, newPath
)
650 elif renames2
.getDst(ren1
.dstName
):
651 dst2
= renames2
.getDst(ren1
.dstName
)
652 newPath1
= uniquePath(ren1
.dstName
, branchName1
)
653 newPath2
= uniquePath(dst2
.dstName
, branchName2
)
654 output('CONFLICT (rename/rename): Rename',
655 fmtRename(ren1
.srcName
, ren1
.dstName
), 'in',
656 branchName1
+'. Rename',
657 fmtRename(dst2
.srcName
, dst2
.dstName
), 'in', branchName2
)
658 output('Renaming', ren1
.srcName
, 'to', newPath1
, 'and',
659 dst2
.srcName
, 'to', newPath2
, 'instead')
660 removeFile(False, ren1
.dstName
)
661 updateFile(False, ren1
.dstSha
, ren1
.dstMode
, newPath1
)
662 updateFile(False, dst2
.dstSha
, dst2
.dstMode
, newPath2
)
663 dst2
.processed
= True
669 [resSha
, resMode
, clean
, merge
] = \
670 mergeFile(ren1
.srcName
, ren1
.srcSha
, ren1
.srcMode
,
671 ren1
.dstName
, ren1
.dstSha
, ren1
.dstMode
,
672 ren1
.srcName
, srcShaOtherBranch
, srcModeOtherBranch
,
673 branchName1
, branchName2
)
675 if merge
or not clean
:
676 output('Renaming', fmtRename(ren1
.srcName
, ren1
.dstName
))
679 output('Auto-merging', ren1
.dstName
)
682 output('CONFLICT (rename/modify): Merge conflict in',
687 updateFileExt(ren1
.dstSha
, ren1
.dstMode
, ren1
.dstName
,
688 updateCache
=True, updateWd
=False)
689 updateFile(clean
, resSha
, resMode
, ren1
.dstName
)
693 # Per entry merge function
694 # ------------------------
696 def processEntry(entry
, branch1Name
, branch2Name
):
697 '''Merge one cache entry.'''
699 debug('processing', entry
.path
, 'clean cache:', cacheOnly
)
704 oSha
= entry
.stages
[1].sha1
705 oMode
= entry
.stages
[1].mode
706 aSha
= entry
.stages
[2].sha1
707 aMode
= entry
.stages
[2].mode
708 bSha
= entry
.stages
[3].sha1
709 bMode
= entry
.stages
[3].mode
711 assert(oSha
== None or isSha(oSha
))
712 assert(aSha
== None or isSha(aSha
))
713 assert(bSha
== None or isSha(bSha
))
715 assert(oMode
== None or type(oMode
) is int)
716 assert(aMode
== None or type(aMode
) is int)
717 assert(bMode
== None or type(bMode
) is int)
719 if (oSha
and (not aSha
or not bSha
)):
721 # Case A: Deleted in one
723 if (not aSha
and not bSha
) or \
724 (aSha
== oSha
and not bSha
) or \
725 (not aSha
and bSha
== oSha
):
726 # Deleted in both or deleted in one and unchanged in the other
728 output('Removing', path
)
729 removeFile(True, path
)
731 # Deleted in one and changed in the other
734 output('CONFLICT (delete/modify):', path
, 'deleted in',
735 branch1Name
, 'and modified in', branch2Name
+ '.',
736 'Version', branch2Name
, 'of', path
, 'left in tree.')
740 output('CONFLICT (modify/delete):', path
, 'deleted in',
741 branch2Name
, 'and modified in', branch1Name
+ '.',
742 'Version', branch1Name
, 'of', path
, 'left in tree.')
746 updateFile(False, sha
, mode
, path
)
748 elif (not oSha
and aSha
and not bSha
) or \
749 (not oSha
and not aSha
and bSha
):
751 # Case B: Added in one.
754 addBranch
= branch1Name
755 otherBranch
= branch2Name
758 conf
= 'file/directory'
760 addBranch
= branch2Name
761 otherBranch
= branch1Name
764 conf
= 'directory/file'
766 if path
in currentDirectorySet
:
768 newPath
= uniquePath(path
, addBranch
)
769 output('CONFLICT (' + conf
+ '):',
770 'There is a directory with name', path
, 'in',
771 otherBranch
+ '. Adding', path
, 'as', newPath
)
773 removeFile(False, path
)
774 updateFile(False, sha
, mode
, newPath
)
776 output('Adding', path
)
777 updateFile(True, sha
, mode
, path
)
779 elif not oSha
and aSha
and bSha
:
781 # Case C: Added in both (check for same permissions).
786 output('CONFLICT: File', path
,
787 'added identically in both branches, but permissions',
788 'conflict', '0%o' % aMode
, '->', '0%o' % bMode
)
789 output('CONFLICT: adding with permission:', '0%o' % aMode
)
791 updateFile(False, aSha
, aMode
, path
)
793 # This case is handled by git-read-tree
797 newPath1
= uniquePath(path
, branch1Name
)
798 newPath2
= uniquePath(path
, branch2Name
)
799 output('CONFLICT (add/add): File', path
,
800 'added non-identically in both branches. Adding as',
801 newPath1
, 'and', newPath2
, 'instead.')
802 removeFile(False, path
)
803 updateFile(False, aSha
, aMode
, newPath1
)
804 updateFile(False, bSha
, bMode
, newPath2
)
806 elif oSha
and aSha
and bSha
:
808 # case D: Modified in both, but differently.
810 output('Auto-merging', path
)
811 [sha
, mode
, clean
, dummy
] = \
812 mergeFile(path
, oSha
, oMode
,
815 branch1Name
, branch2Name
)
817 updateFile(True, sha
, mode
, path
)
820 output('CONFLICT (content): Merge conflict in', path
)
823 updateFile(False, sha
, mode
, path
)
825 updateFileExt(aSha
, aMode
, path
,
826 updateCache
=True, updateWd
=False)
827 updateFileExt(sha
, mode
, path
, updateCache
=False, updateWd
=True)
829 die("ERROR: Fatal merge failure, shouldn't happen.")
834 die('Usage:', sys
.argv
[0], ' <base>... -- <head> <remote>..')
836 # main entry point as merge strategy module
837 # The first parameters up to -- are merge bases, and the rest are heads.
838 # This strategy module figures out merge bases itself, so we only
841 if len(sys
.argv
) < 4:
844 for nextArg
in xrange(1, len(sys
.argv
)):
845 if sys
.argv
[nextArg
] == '--':
846 if len(sys
.argv
) != nextArg
+ 3:
847 die('Not handling anything other than two heads merge.')
849 h1
= firstBranch
= sys
.argv
[nextArg
+ 1]
850 h2
= secondBranch
= sys
.argv
[nextArg
+ 2]
855 print 'Merging', h1
, 'with', h2
858 h1
= runProgram(['git-rev-parse', '--verify', h1
+ '^0']).rstrip()
859 h2
= runProgram(['git-rev-parse', '--verify', h2
+ '^0']).rstrip()
861 graph
= buildGraph([h1
, h2
])
863 [dummy
, clean
] = merge(graph
.shaMap
[h1
], graph
.shaMap
[h2
],
864 firstBranch
, secondBranch
, graph
)
868 if isinstance(sys
.exc_info()[1], SystemExit):
871 traceback
.print_exc(None, sys
.stderr
)