git-imerge/git-imerge
2013-05-06 10:32:58 +02:00

2361 lines
78 KiB
Python
Executable File

#! /usr/bin/python2
# Copyright 2012-2013 Michael Haggerty <mhagger@alum.mit.edu>
#
# This file is part of git-imerge.
#
# git-imerge is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 2 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see
# <http://www.gnu.org/licenses/>.
r"""Git incremental merge
Perform the merge between two branches incrementally. If conflicts
are encountered, figure out exactly which pairs of commits conflict,
and present the user with one pairwise conflict at a time.
The theory and benefits of incremental merging are described in minute
detail in a series of blog posts [1], as are the benefits of retaining
history when doing a rebase [2].
Multiple incremental merges can be in progress at the same time. Each
incremental merge has a name, and its progress is recorded in the Git
repository as references under 'refs/imerge/NAME'. The current state
of an incremental merge can (crudely) be visualized using the
"diagram" command.
An incremental merge can be interrupted and resumed arbitrarily, or
even pushed to a server to allow somebody else to work on it.
When an incremental merge is finished, you can discard the
intermediate merge commits and create a simpler history to record
permanently in your project repository using either the "finish" or
"simplify" command. The incremental merge can be simplified in one of
three ways:
* merge
keep only a simple merge of the second branch into the first
branch, discarding all intermediate merges. The result is
similar to what you would get from
git checkout BRANCH1
git merge BRANCH2
* rebase
keep the versions of the commits from the second branch rebased
onto the first branch. The result is similar to what you would
get from
git checkout BRANCH2
git rebase BRANCH1
* rebase-with-history
like rebase, except that each of the rebased commits is recorded
as a merge from its original version to its rebased predecessor.
This is a style of rebasing that does not discard the old
version of the branch, and allows an already-published branch to
be rebased. See [2] for more information.
Simple operation:
For basic operation, you only need to know three git-imerge commands.
To merge BRANCH into MASTER or rebase BRANCH onto MASTER,
git checkout MASTER
git-imerge start --name=NAME --goal=GOAL --first-parent BRANCH
while not done:
<fix conflict presented to you>
git commit
git-imerge continue
git-imerge finish
where
NAME is the name for this merge (and also the default name of the
branch to which the results will be saved)
GOAL describes how you want to simplify the results:
"merge" for a simple merge
"rebase" for a simple rebase
"rebase-with-history" for a rebase that retains history. This
is equivalent to merging the commits from BRANCH into MASTER,
one commit at a time. In other words, it transforms this::
o---o---o---o MASTER
\
A---B---C---D BRANCH
into this::
o---o---o---o---A'--B'--C'--D' MASTER
\ / / / /
--------A---B---C---D BRANCH
This is like a rebase, except that it retains the history of
individual merges. See [2] for more information.
[1] http://softwareswirl.blogspot.com/2012/12/the-conflict-frontier-of-nightmare-merge.html
http://softwareswirl.blogspot.com/2012/12/mapping-merge-conflict-frontier.html
http://softwareswirl.blogspot.com/2012/12/real-world-conflict-diagrams.html
http://softwareswirl.blogspot.com/2013/05/git-incremental-merge.html
http://softwareswirl.blogspot.com/2013/05/one-merge-to-rule-them-all.html
http://softwareswirl.blogspot.com/2013/05/incremental-merge-vs-direct-merge-vs.html
[2] http://softwareswirl.blogspot.com/2009/04/truce-in-merge-vs-rebase-war.html
http://softwareswirl.blogspot.com/2009/08/upstream-rebase-just-works-if-history.html
http://softwareswirl.blogspot.com/2009/08/rebase-with-history-implementation.html
"""
import sys
import re
import subprocess
import itertools
import functools
import argparse
from cStringIO import StringIO
import json
# CalledProcessError, check_call, and check_output were not in the
# original Python 2.4 subprocess library, so implement it here if
# necessary (implementations are taken from the Python 2.7 library):
try:
from subprocess import CalledProcessError
except ImportError:
class CalledProcessError(Exception):
def __init__(self, returncode, cmd, output=None):
self.returncode = returncode
self.cmd = cmd
self.output = output
def __str__(self):
return "Command '%s' returned non-zero exit status %d" % (self.cmd, self.returncode)
try:
from subprocess import check_call
except ImportError:
def check_call(*popenargs, **kwargs):
retcode = subprocess.call(*popenargs, **kwargs)
if retcode:
cmd = kwargs.get("args")
if cmd is None:
cmd = popenargs[0]
raise CalledProcessError(retcode, cmd)
return 0
try:
from subprocess import check_output
except ImportError:
def check_output(*popenargs, **kwargs):
if 'stdout' in kwargs:
raise ValueError('stdout argument not allowed, it will be overridden.')
process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs)
output, unused_err = process.communicate()
retcode = process.poll()
if retcode:
cmd = kwargs.get("args")
if cmd is None:
cmd = popenargs[0]
raise CalledProcessError(retcode, cmd, output=output)
return output
STATE_VERSION = (1, 0, 0)
ZEROS = '0' * 40
ALLOWED_GOALS = [
#'full',
'rebase-with-history',
'rebase',
'merge',
]
DEFAULT_GOAL = 'merge'
class Failure(Exception):
"""An exception that indicates a normal failure of the script.
Failures are reported at top level via sys.exit(str(e)) rather
than via a Python stack dump."""
@classmethod
def wrap(klass, f):
"""Wrap a function inside a try...except that catches this error.
If the exception is thrown, call sys.exit(). This function
can be used as a decorator."""
@functools.wraps(f)
def wrapper(*args, **kw):
try:
return f(*args, **kw)
except klass, e:
sys.exit(str(e))
return wrapper
pass
def iter_neighbors(iterable):
"""For an iterable (x0, x1, x2, ...) generate [(x0,x1), (x1,x2), ...]."""
i = iter(iterable)
try:
last = i.next()
except StopIteration:
return
for x in i:
yield (last, x)
last = x
def find_first_false(f, lo, hi):
"""Return the smallest i in lo <= i < hi for which f(i) returns False using bisection.
If there is no such i, return hi.
"""
# Loop invariant: f(i) returns True for i < lo; f(i) returns False
# for i >= hi.
while lo < hi:
mid = (lo + hi) // 2
if f(mid):
lo = mid + 1
else:
hi = mid
return lo
def call_silently(cmd):
try:
NULL = open('/dev/null', 'w')
except IOError:
NULL = subprocess.PIPE
p = subprocess.Popen(
cmd, stdout=NULL, stderr=subprocess.PIPE,
)
(out,err) = p.communicate()
retcode = p.wait()
if retcode:
raise CalledProcessError(retcode, cmd, err)
class UncleanWorkTreeError(Failure):
pass
def require_clean_work_tree(action):
"""Verify that the current tree is clean.
The code is a Python translation of the git-sh-setup(1) function
of the same name."""
process = subprocess.Popen(
['git', 'rev-parse', '--verify', 'HEAD'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
_unused, err = process.communicate()
retcode = process.poll()
if retcode:
raise UncleanWorkTreeError(err.rstrip())
process = subprocess.Popen(
['git', 'update-index', '-q', '--ignore-submodules', '--refresh'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
out, err = process.communicate()
retcode = process.poll()
if retcode:
raise UncleanWorkTreeError(err.rstrip() or out.rstrip())
error = []
try:
check_call(['git', 'diff-files', '--quiet', '--ignore-submodules'])
except CalledProcessError:
error.append('Cannot %s: You have unstaged changes.' % (action,))
try:
check_call([
'git', 'diff-index', '--cached', '--quiet',
'--ignore-submodules', 'HEAD', '--',
])
except CalledProcessError:
if not error:
error.append('Cannot %s: Your index contains uncommitted changes.' % (action,))
else:
error.append('Additionally, your index contains uncommitted changes.')
if error:
raise UncleanWorkTreeError('\n'.join(error))
def rev_parse(arg):
return check_output(['git', 'rev-parse', '--verify', '--quiet', arg]).strip()
def rev_list(*args):
return [
l.strip()
for l in check_output(['git', 'rev-list'] + list(args),).splitlines()
]
def get_type(arg):
"""Return the type of a git object ('commit', 'tree', 'blob', or 'tag')."""
return check_output(['git', 'cat-file', '-t', arg]).strip()
BRANCH_PREFIX = 'refs/heads/'
def checkout(refname):
if refname.startswith(BRANCH_PREFIX):
target = refname[len(BRANCH_PREFIX):]
else:
target = '%s^0' % (refname,)
check_call(['git', 'checkout', target])
def get_commit_sha1(arg):
"""Convert arg into a SHA1 and verify that it refers to a commit.
If not, raise ValueError."""
try:
return rev_parse('%s^{commit}' % (arg,))
except CalledProcessError:
raise ValueError('%r does not refer to a valid git commit' % (arg,))
def get_commit_parents(commit):
"""Return a list containing the parents of commit."""
return check_output(
['git', 'log', '--no-walk', '--pretty=format:%P', commit]
).strip().split()
def get_log_message(commit):
contents = check_output([
'git', 'cat-file', 'commit', commit
]).splitlines(True)
contents = contents[contents.index('\n') + 1:]
if contents and contents[-1][-1:] != '\n':
contents.append('\n')
return ''.join(contents)
class TemporaryHead(object):
"""A context manager that records the current HEAD state then restores it.
The message is used for the reflog."""
def __enter__(self, message='imerge: restoring'):
self.message = message
try:
self.head_name = check_output(['git', 'symbolic-ref', '--quiet', 'HEAD']).strip()
except CalledProcessError:
self.head_name = None
self.orig_head = get_commit_sha1('HEAD')
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.head_name:
try:
check_call([
'git', 'symbolic-ref',
'-m', self.message, 'HEAD',
self.head_name,
])
except Exception, e:
raise Failure(
'Could not restore HEAD to %r!: %s\n'
% (self.head_name, e.message,)
)
else:
try:
check_call(['git', 'reset', '--hard', self.orig_head])
except Exception, e:
raise Failure(
'Could not restore HEAD to %r!: %s\n'
% (self.orig_head, e.message,)
)
return False
def reparent(commit, parent_sha1s):
"""Create a new commit object like commit, but with the specified parents.
commit is the SHA1 of an existing commit and parent_sha1s is a
list of SHA1s. Create a new commit exactly like that one, except
that it has the specified parent commits. Return the SHA1 of the
resulting commit object, which is already stored in the object
database but is not yet referenced by anything."""
old_commit = check_output(['git', 'cat-file', 'commit', commit])
separator = old_commit.index('\n\n')
headers = old_commit[:separator + 1].splitlines(True)
rest = old_commit[separator + 1:]
new_commit = StringIO()
for i in range(len(headers)):
line = headers[i]
if line.startswith('tree '):
new_commit.write(line)
for parent_sha1 in parent_sha1s:
new_commit.write('parent %s\n' % (parent_sha1,))
elif line.startswith('parent '):
# Discard old parents:
pass
else:
new_commit.write(line)
new_commit.write(rest)
process = subprocess.Popen(
['git', 'hash-object', '-t', 'commit', '-w', '--stdin'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
)
out, err = process.communicate(new_commit.getvalue())
retcode = process.poll()
if retcode:
raise Failure('Could not reparent commit %s' % (commit,))
return out.strip()
class AutomaticMergeFailed(Exception):
def __init__(self, commit1, commit2):
Exception.__init__(
self, 'Automatic merge of %s and %s failed' % (commit1, commit2,)
)
self.commit1, self.commit2 = commit1, commit2
def automerge(commit1, commit2):
"""Attempt an automatic merge of commit1 and commit2.
Return the SHA1 of the resulting commit, or raise
AutomaticMergeFailed on error. This must be called with a clean
worktree."""
call_silently(['git', 'checkout', '-f', commit1])
try:
call_silently(['git', '-c', 'rerere.enabled=false', 'merge', commit2])
except CalledProcessError:
call_silently(['git', 'merge', '--abort'])
raise AutomaticMergeFailed(commit1, commit2)
else:
return get_commit_sha1('HEAD')
class MergeRecord(object):
# Bits for the flags field:
# There is a saved successful auto merge:
SAVED_AUTO = 0x01
# An auto merge (which may have been unsuccessful) has been done:
NEW_AUTO = 0x02
# There is a saved successful manual merge:
SAVED_MANUAL = 0x04
# A manual merge (which may have been unsuccessful) has been done:
NEW_MANUAL = 0x08
# Some useful bit combinations:
SAVED = SAVED_AUTO | SAVED_MANUAL
NEW = NEW_AUTO | NEW_MANUAL
AUTO = SAVED_AUTO | NEW_AUTO
MANUAL = SAVED_MANUAL | NEW_MANUAL
ALLOWED_INITIAL_FLAGS = [
SAVED_AUTO,
SAVED_MANUAL,
NEW_AUTO,
NEW_MANUAL,
]
def __init__(self, sha1=None, flags=0):
# The currently believed correct merge, or None if it is
# unknown or the best attempt was unsuccessful.
self.sha1 = sha1
if self.sha1 is None:
if flags != 0:
raise ValueError('Initial flags (%s) for sha1=None should be 0' % (flags,))
elif flags not in self.ALLOWED_INITIAL_FLAGS:
raise ValueError('Initial flags (%s) is invalid' % (flags,))
# See bits above.
self.flags = flags
def record_merge(self, sha1, source):
"""Record a merge at this position.
source must be SAVED_AUTO, SAVED_MANUAL, NEW_AUTO, or NEW_MANUAL."""
if source == self.SAVED_AUTO:
# SAVED_AUTO is recorded in any case, but only used if it
# is the only info available.
if self.flags & (self.MANUAL | self.NEW) == 0:
self.sha1 = sha1
self.flags |= source
elif source == self.NEW_AUTO:
# NEW_AUTO is silently ignored if any MANUAL value is
# already recorded.
if self.flags & self.MANUAL == 0:
self.sha1 = sha1
self.flags |= source
elif source == self.SAVED_MANUAL:
# SAVED_MANUAL is recorded in any case, but only used if
# no NEW_MANUAL is available.
if self.flags & self.NEW_MANUAL == 0:
self.sha1 = sha1
self.flags |= source
elif source == self.NEW_MANUAL:
# NEW_MANUAL is always used, and also causes NEW_AUTO to
# be forgotten if present.
self.sha1 = sha1
self.flags = (self.flags | source) & ~self.NEW_AUTO
else:
raise ValueError('Undefined source: %s' % (source,))
def is_known(self):
return self.sha1 is not None
def save(self, name, i1, i2):
"""If this record has changed, save it."""
def set_ref(source):
check_call([
'git', 'update-ref',
'-m', 'imerge %r: Record %s merge' % (name, source,),
'refs/imerge/%s/%s/%d-%d' % (name, source, i1, i2),
self.sha1,
])
def clear_ref(source):
check_call([
'git', 'update-ref',
'-d', 'imerge %r: Remove obsolete %s merge' % (name, source,),
'refs/imerge/%s/%s/%d-%d' % (name, source, i1, i2),
])
if self.flags & self.MANUAL:
if self.flags & self.AUTO:
# Any MANUAL obsoletes any AUTO:
if self.flags & self.SAVED_AUTO:
clear_ref('auto')
self.flags &= ~self.AUTO
if self.flags & self.NEW_MANUAL:
# Convert NEW_MANUAL to SAVED_MANUAL.
if self.sha1:
set_ref('manual')
self.flags |= self.SAVED_MANUAL
elif self.flags & self.SAVED_MANUAL:
# Delete any existing SAVED_MANUAL:
clear_ref('manual')
self.flags &= ~self.SAVED_MANUAL
self.flags &= ~self.NEW_MANUAL
elif self.flags & self.NEW_AUTO:
# Convert NEW_AUTO to SAVED_AUTO.
if self.sha1:
set_ref('auto')
self.flags |= self.SAVED_AUTO
elif self.flags & self.SAVED_AUTO:
# Delete any existing SAVED_AUTO:
clear_ref('auto')
self.flags &= ~self.SAVED_AUTO
self.flags &= ~self.NEW_AUTO
class UnexpectedMergeFailure(Exception):
def __init__(self, msg, i1, i2):
Exception.__init__(self, msg)
self.i1, self.i2 = i1, i2
class BlockCompleteError(Exception):
pass
class FrontierBlockedError(Exception):
def __init__(self, msg, i1, i2):
Exception.__init__(self, msg)
self.i1 = i1
self.i2 = i2
class MergeFrontier(object):
"""Represents the merge frontier within a Block.
A MergeFrontier is represented by a list of SubBlocks, each of
which is thought to be completely mergeable. The list is kept in
normalized form:
* Only non-empty blocks are retained
* Blocks are sorted from bottom left to upper right
* No redundant blocks
"""
@staticmethod
def map_known_frontier(block):
"""Return the MergeFrontier describing existing successful merges in block.
The return value only includes the part that is fully outlined
and whose outline consists of rectangles reaching back to
(0,0)."""
# FIXME: This algorithm can take combinatorial time, for
# example if there is a big block of merges that is a dead
# end:
#
# +++++++
# +?+++++
# +?+++++
# +?+++++
# +?*++++
#
# The problem is that the algorithm will explore all of the
# ways of getting to commit *, and the number of paths grows
# like a binomial coefficient. The solution would be to
# remember dead-ends and reject any curves that visit a point
# to the right of a dead-end.
#
# For now we don't intend to allow a situation like this to be
# created, so we ignore the problem.
# A list (i1, i2, down) of points in the path so far. down is
# True iff the attempted step following this one was
# downwards.
path = []
def create_frontier(path):
blocks = []
for ((i1old, i2old, downold), (i1new, i2new, downnew)) in iter_neighbors(path):
if downold == True and downnew == False:
blocks.append(block[:i1new + 1, :i2new + 1])
return MergeFrontier(block, blocks)
# Loop invariants:
#
# * path is a valid path
#
# * (i1, i2) is in block but it not yet added to path
#
# * down is True if a step downwards from (i1, i2) has not yet
# been attempted
(i1, i2) = (block.len1 - 1, 0)
down = True
while True:
if down:
if i2 == block.len2 - 1:
# Hit edge of block; can't move down:
down = False
elif (i1, i2 + 1) in block:
# Can move down
path.append((i1, i2, True))
i2 += 1
else:
# Can't move down.
down = False
else:
if i1 == 0:
# Success!
path.append((i1, i2, False))
return create_frontier(path)
elif (i1 - 1, i2) in block:
# Can move left
path.append((i1, i2, False))
down = True
i1 -= 1
else:
# There's no way to go forward; backtrack until we
# find a place where we can still try going left:
while True:
try:
(i1, i2, down) = path.pop()
except IndexError:
# This shouldn't happen because, in the
# worst case, there is a valid path across
# the top edge of the merge diagram.
raise RuntimeError('Block is improperly formed!')
if down:
down = False
break
@staticmethod
def compute_by_bisection(block):
"""Return a MergeFrontier instance for block."""
if block.len1 <= 1 or block.len2 <= 1:
return MergeFrontier(block, [])
blocks = []
i1 = 1
i2 = block.len2
# Given that these diagrams typically have few blocks, check the
# end of a range first to see if the whole range can be filled in,
# and fall back to bisection otherwise.
while True:
# Find the width of the success rectangle at row (i2-1) and fill it in:
if block.is_mergeable(block.len1 - 1, i2 - 1):
newi1 = block.len1
else:
newi1 = find_first_false(
lambda i: block.is_mergeable(i, i2 - 1),
i1, block.len1 - 1,
)
blocks.append(block[:newi1,:i2])
i1 = newi1
if i1 == block.len1:
break
# Find the height of the conflict rectangle at column i1 and fill it in:
if not block.is_mergeable(i1, 1):
newi2 = 1
else:
newi2 = find_first_false(
lambda i: block.is_mergeable(i1, i),
2, i2,
)
i2 = newi2
if i2 == 1:
break
return MergeFrontier(block, blocks)
def __init__(self, block, blocks=None):
self.block = block
blocks = list(self._iter_non_empty_blocks(blocks or []))
blocks.sort(key=lambda block: block.len1)
self.blocks = list(self._iter_non_redundant_blocks(blocks))
def __iter__(self):
"""Iterate over blocks from bottom left to upper right."""
return iter(self.blocks)
def __nonzero__(self):
"""Return True iff this frontier has no completed parts."""
return bool(self.blocks)
def is_complete(self):
"""Return True iff the frontier covers the whole block."""
return (
len(self.blocks) == 1
and self.blocks[0].len1 == self.block.len1
and self.blocks[0].len2 == self.block.len2
)
ADD_VERTICAL = {
'?' : '|',
'-' : '+',
'+' : '+',
'*' : '*',
'.' : '|',
}
ADD_HORIZONTAL = {
'?' : '-',
'|' : '+',
'+' : '+',
'*' : '*',
'.' : '-',
}
def write(self, f):
"""Write this frontier to file-like object f."""
output = StringIO()
self.block._write(output, manual='*', auto='.', unknown='?', sep='', linesep='\n')
output = [
list(s)
for s in output.getvalue().splitlines()
]
for block in self:
i2 = block.len2 - 1
for i1 in range(block.len1 - 1):
output[i2][i1] = self.ADD_HORIZONTAL[output[i2][i1]]
i1 = block.len1 - 1
for i2 in range(block.len2 - 1):
output[i2][i1] = self.ADD_VERTICAL[output[i2][i1]]
output[block.len2 - 1][block.len1 - 1] = '+'
for i2 in range(self.block.len2):
for i1 in range(self.block.len1):
f.write(output[i2][i1])
f.write('\n')
@staticmethod
def _iter_non_empty_blocks(blocks):
for block in blocks:
if block.len1 > 1 and block.len2 > 1:
yield block
@staticmethod
def _iter_non_redundant_blocks(blocks):
def contains(block1, block2):
"""Return true if block1 contains block2."""
return block1.len1 >= block2.len1 and block1.len2 >= block2.len2
i = iter(blocks)
try:
last = i.next()
except StopIteration:
return
for block in i:
if contains(last, block):
pass
elif contains(block, last):
last = block
else:
yield last
last = block
yield last
def remove_failure(self, i1, i2):
"""Refine the merge frontier given that the specified merge fails."""
shrunk_block = False
for (i, block) in enumerate(self.blocks):
if i1 < block.len1 and i2 < block.len2:
self.blocks[i] = block[:i1, :i2]
shrunk_block = True
if shrunk_block:
self.blocks = list(self._iter_non_redundant_blocks(self.blocks))
def add_success(self, i1, i2):
"""Return True iff the specified merge affects this frontier.
This method does *not* update self; if it returns True you
should recompute the frontier from scratch."""
for block in self.iter_blocker_blocks():
try:
(block_i1, block_i2) = block.convert_original_indexes(i1, i2)
except IndexError:
pass
else:
if (block_i1, block_i2) == (1,1):
# That's the one we need to improve this block:
return True
else:
return False
else:
return False
def partition(self, block):
"""Return two MergeFrontier instances partitioned by block.
Return (frontier1, frontier2), where each frontier is limited
to each side of the argument.
block must be contained in this MergeFrontier and already be
outlined."""
# Remember that the new blocks have to include the outlined
# edge of the partitioning block to satisfy the invariant that
# the left and upper edge of a block has to be known.
left = []
right = []
for b in self.blocks:
if b.len1 == block.len1 and b.len2 == block.len2:
# That's the block we're partitioning on; just skip it.
pass
elif b.len1 < block.len1 and b.len2 > block.len2:
left.append(b[:, block.len2 - 1:])
elif b.len1 > block.len1 and b.len2 < block.len2:
right.append(b[block.len1 - 1:, :])
else:
raise ValueError(
'MergeFrontier partitioned with inappropriate block'
)
return (
MergeFrontier(self.block[:block.len1, block.len2 - 1:], left),
MergeFrontier(self.block[block.len1 - 1:, :block.len2], right),
)
def iter_blocker_blocks(self):
"""Iterate over the blocks on the far side of this frontier.
This must only be called for an outlined frontier."""
if not self:
yield self.block
return
blockruns = []
if self.blocks[0].len2 < self.block.len2:
blockruns.append([self.block[0,:]])
blockruns.append(self)
if self.blocks[-1].len1 < self.block.len1:
blockruns.append([self.block[:,0]])
for block1, block2 in iter_neighbors(itertools.chain(*blockruns)):
yield self.block[block1.len1 - 1:block2.len1, block2.len2 - 1: block1.len2]
def auto_expand(self):
"""Try pushing out one of the blocks on this frontier.
Raise BlockCompleteError if the whole block has already been
solved. Raise FrontierBlockedError if the frontier is blocked
everywhere. This method does *not* update self; if it returns
successfully you should recompute the frontier from
scratch."""
blocks = list(self.iter_blocker_blocks())
if not blocks:
raise BlockCompleteError('The block is already complete')
# Try blocks from biggest to smallest:
blocks.sort(key=lambda block: block.get_area(), reverse=True)
for block in blocks:
if block.auto_outline_frontier():
return
else:
# None of the blocks could be expanded. Suggest that the
# caller do a manual merge of the commit that is blocking
# the *smallest* blocker block.
i1, i2 = blocks[-1].get_original_indexes(1, 1)
raise FrontierBlockedError(
'Frontier blocked; suggest manual merge of %d-%d' % (i1, i2),
i1, i2
)
class ManualMergeUnusableError(Exception):
def __init__(self, msg, commit):
Exception.__init__(self, 'Commit %s is not usable; %s' % (commit, msg))
self.commit = commit
class CommitNotFoundError(Exception):
def __init__(self, commit):
Exception.__init__(
self,
'Commit %s was not found among the known merge commits' % (commit,),
)
self.commit = commit
class Block(object):
"""A rectangular range of commits, indexed by (i1,i2).
The commits block[0,1:] and block[1:,0] are always all known.
block[0,0] may or may not be known; it is usually unneeded (except
maybe implicitly).
Members:
len1, len2 -- the dimensions of the block.
"""
def __init__(self, len1, len2):
self.len1 = len1
self.len2 = len2
def get_area(self):
"""Return the area of this block, ignoring the known edges."""
return (self.len1 - 1) * (self.len2 - 1)
def _check_indexes(self, i1, i2):
if not (0 <= i1 < self.len1):
raise IndexError('first index (%s) is out of range 0:%d' % (i1, self.len1,))
if not (0 <= i2 < self.len2):
raise IndexError('second index (%s) is out of range 0:%d' % (i2, self.len2,))
def _normalize_indexes(self, index):
"""Return a pair of non-negative integers (i1, i2)."""
try:
(i1, i2) = index
except TypeError:
raise IndexError('Block indexing requires exactly two indexes')
if i1 < 0:
i1 += self.len1
if i2 < 0:
i2 += self.len2
self._check_indexes(i1, i2)
return (i1, i2)
def get_original_indexes(self, i1, i2):
"""Return the original indexes corresponding to (i1,i2) in this block.
This function supports negative indexes."""
return self._normalize_indexes((i1, i2))
def convert_original_indexes(self, i1, i2):
"""Return the indexes in this block corresponding to original indexes (i1,i2).
raise IndexError if they are not within this block. This
method does not support negative indices."""
return (i1, i2)
def set_value(self, i1, i2, value):
"""Set the MergeRecord for integer indexes (i1, i2).
i1 and i2 must be non-negative."""
raise NotImplementedError()
def __setitem__(self, index, value):
"""Return the MergeRecord for indexes (i1, i2).
i1 and i2 must be integers, but they may be negative."""
(i1, i2) = self._normalize_indexes(index)
if i1 < 0:
i1 += self.len1
if i2 < 0:
i2 += self.len2
self.set_value(i1, i2, value)
def get_value(self, i1, i2):
"""Return the MergeRecord for integer indexes (i1, i2).
i1 and i2 must be non-negative."""
raise NotImplementedError()
def __getitem__(self, index):
"""Return the MergeRecord at (i1, i2) (requires two indexes).
If i1 and i2 are integers but the merge is unknown, return
None. If either index is a slice, return a SubBlock."""
try:
(i1, i2) = index
except TypeError:
raise IndexError('Block indexing requires exactly two indexes')
if isinstance(i1, slice) or isinstance(i2, slice):
return SubBlock(self, i1, i2)
else:
return self.get_value(*self._normalize_indexes((i1, i2)))
def __contains__(self, index):
return self[index].is_known()
def is_mergeable(self, i1, i2):
"""Determine whether (i1,i2) can be merged automatically.
If we already have a merge record for (i1,i2), return True.
Otherwise, attempt a merge (discarding the result)."""
(i1, i2) = self._normalize_indexes((i1, i2))
if (i1, i2) in self:
return True
else:
sys.stderr.write(
'Attempting automerge of %d-%d...' % self.get_original_indexes(i1, i2)
)
try:
automerge(self[i1, 0].sha1, self[0, i2].sha1)
sys.stderr.write('success.\n')
return True
except AutomaticMergeFailed:
sys.stderr.write('failure.\n')
return False
def auto_outline(self):
"""Complete the outline of this Block.
raise UnexpectedMergeFailure if automerging fails."""
# Check that all of the merges go through before recording any
# of them permanently.
merges = []
def do_merge(i1, commit1, i2, commit2):
if (i1, i2) in self:
return self[i1,i2].sha1
try:
sys.stderr.write(
'Autofilling %d-%d...' % self.get_original_indexes(i1, i2)
)
merge = automerge(commit1, commit2)
sys.stderr.write('success.\n')
except AutomaticMergeFailed, e:
sys.stderr.write('unexpected failure.\n')
raise UnexpectedMergeFailure(str(e), i1, i2)
merges.append((i1, i2, merge))
return merge
i2 = self.len2 - 1
left = self[0, i2].sha1
for i1 in range(1, self.len1 - 1):
left = do_merge(i1, self[i1,0].sha1, i2, left)
i1 = self.len1 - 1
above = self[i1, 0].sha1
for i2 in range(1, self.len2 - 1):
above = do_merge(i1, above, i2, self[0,i2].sha1)
i1, i2 = self.len1 - 1, self.len2 - 1
do_merge(i1, above, i2, left)
# Success! Now we can record the results:
for (i1, i2, merge) in merges:
self[i1, i2].record_merge(merge, MergeRecord.NEW_AUTO)
def auto_outline_frontier(self, merge_frontier=None):
"""Try to outline the merge frontier of this block.
Return True iff some progress was made."""
if merge_frontier is None:
merge_frontier = MergeFrontier.compute_by_bisection(self)
if not merge_frontier:
# Nothing to do.
return False
best_block = max(merge_frontier, key=lambda block: block.get_area())
try:
best_block.auto_outline()
except UnexpectedMergeFailure, e:
# One of the merges that we expected to succeed in
# fact failed.
i1, i2 = e.i1, e.i1
merge_frontier.remove_failure(e.i1, e.i2)
return self.auto_outline_frontier(merge_frontier)
else:
f1, f2 = merge_frontier.partition(best_block)
if f1:
f1.block.auto_outline_frontier(f1)
if f2:
f2.block.auto_outline_frontier(f2)
return True
def _write(self, f, manual='*', auto='.', unknown='?', sep='', linesep='\n'):
for i2 in range(self.len2):
for i1 in range(self.len1):
if i1 != 0:
f.write(sep)
if (i1,i2) in self:
if self.get_value(i1, i2).flags & MergeRecord.MANUAL:
f.write(manual)
else:
f.write(auto)
else:
f.write(unknown)
f.write(linesep)
def write(self, f):
self._write(f)
def writeppm(self, f):
f.write('P3\n')
f.write('%d %d 255\n' % (self.n1, self.n2,))
self._write(
f,
manual='0 255 0',
auto='0 127 0',
unknown='127 127 0',
sep=' ',
)
class SubBlock(Block):
@staticmethod
def _convert_to_slice(i, len):
"""Return (start, len) for the specified index.
i may be an integer or a slice with step equal to 1."""
if isinstance(i, int):
if i < 0:
i += len
i = slice(i, i + 1)
elif isinstance(i, slice):
if i.step is not None and i.step != 1:
raise ValueError('Index has a non-zero step size')
else:
raise ValueError('Index cannot be converted to a slice')
(start, stop, step) = i.indices(len)
return (start, stop - start)
def __init__(self, block, slice1, slice2):
(start1, len1) = self._convert_to_slice(slice1, block.len1)
(start2, len2) = self._convert_to_slice(slice2, block.len2)
Block.__init__(self, len1, len2)
if isinstance(block, SubBlock):
# Peel away one level of indirection:
self._block = block._block
self._start1 = start1 + block._start1
self._start2 = start2 + block._start2
else:
self._block = block
self._start1 = start1
self._start2 = start2
def get_original_indexes(self, i1, i2):
i1, i2 = self._normalize_indexes((i1, i2))
return self._block.get_original_indexes(i1 + self._start1, i2 + self._start2)
def convert_original_indexes(self, i1, i2):
(i1, i2) = self._block.convert_original_indexes(i1, i2)
if not (
self._start1 <= i1 < self._start1 + self.len1
and self._start2 <= i2 < self._start2 + self.len2
):
raise IndexError('Indexes are not within block')
return (i1 - self._start1, i2 - self._start2)
def set_value(self, i1, i2, sha1, flags):
self._check_indexes(i1, i2)
self._block.set_value(i1 + self._start1, i2 + self._start2, sha1, flags)
def get_value(self, i1, i2):
self._check_indexes(i1, i2)
return self._block.get_value(i1 + self._start1, i2 + self._start2)
def __str__(self):
return '%s[%d:%d,%d:%d]' % (
self._block,
self._start1, self._start1 + self.len1,
self._start2, self._start2 + self.len2,
)
class MergeState(Block):
SOURCE_TABLE = {
'auto' : MergeRecord.SAVED_AUTO,
'manual' : MergeRecord.SAVED_MANUAL,
}
MERGE_STATE_RE = re.compile(
r"""
^
refs\/imerge\/
(?P<name>[^\/]+)
\/state
$
""",
re.VERBOSE,
)
@staticmethod
def iter_existing_names():
"""Iterate over the names of existing MergeStates in this repo."""
for line in check_output(['git', 'for-each-ref', 'refs/imerge',]).splitlines():
(sha1, type, refname) = line.split()
if type == 'blob':
m = MergeState.MERGE_STATE_RE.match(refname)
if m:
yield m.group('name')
@staticmethod
def get_scratch_refname(name):
return 'refs/heads/imerge/%s' % (name,)
@staticmethod
def _read_state(name, sha1):
state_string = check_output(['git', 'cat-file', 'blob', sha1])
state = json.loads(state_string)
version = tuple(map(int, state['version'].split('.')))
if version[0] != STATE_VERSION[0] or version > STATE_VERSION:
raise Failure(
'The format of imerge %s (%s) is not compatible with this script version.'
% (name, state['version'],)
)
state['version'] = version
return state
@staticmethod
def check_exists(name):
"""Verify that a MergeState with the given name exists.
Just check for the existence, readability, and compatible
version of the "state" reference. If the reference doesn't
exist, just return False. If it exists but is unusable for
some other reason, raise an exception."""
try:
call_silently(
['git', 'check-ref-format', 'refs/imerge/%s' % (name,)]
)
except CalledProcessError:
raise Failure('Name %r is not a valid refname component!' % (name,))
state_refname = 'refs/imerge/%s/state' % (name,)
for line in check_output(['git', 'for-each-ref', state_refname]).splitlines():
(sha1, type, refname) = line.split()
if refname == state_refname and type == 'blob':
MergeState._read_state(name, sha1)
# If that didn't throw an exception:
return True
else:
return False
@staticmethod
def set_default_name(name):
"""Set the default merge to the specified one.
name can be None to cause the default to be cleared."""
if name is None:
try:
check_call(['git', 'config', '--unset', 'imerge.default'])
except CalledProcessError, e:
if e.returncode == 5:
# Value was not set
pass
else:
raise
else:
check_call(['git', 'config', 'imerge.default', name])
@staticmethod
def get_default_name():
"""Get the name of the default merge, or None if none is currently set."""
try:
return check_output(['git', 'config', 'imerge.default']).rstrip()
except CalledProcessError:
return None
@staticmethod
def _check_no_merges(commits):
multiparent_commits = [
commit
for commit in commits
if len(get_commit_parents(commit)) > 1
]
if multiparent_commits:
raise Failure(
'The following commits on the to-be-merged branch are merge commits:\n'
' %s\n'
'--goal=\'rebase\' is not yet supported for branches that include merges.\n'
% ('\n '.join(multiparent_commits),)
)
@staticmethod
def initialize(name, goal, tip1, tip2):
"""Create and return a new MergeState object."""
if '/' in name:
raise Failure('Name %r must not include a slash character!' % (name,))
try:
call_silently(
['git', 'check-ref-format', 'refs/imerge/%s' % (name,)]
)
except CalledProcessError:
raise Failure('Name %r is not a valid refname component!' % (name,))
if check_output(['git', 'for-each-ref', 'refs/imerge/%s' % (name,)]):
raise Failure('Name %r is already in use!' % (name,))
merge_base = check_output(['git', 'merge-base', tip1, tip2]).strip()
commits1 = rev_list('--first-parent', '%s..%s' % (tip2, tip1))
commits1.reverse()
if not commits1:
raise Failure(
'There are no commits on %r that are not already in %r' % (tip1, tip2)
)
commits2 = rev_list('--first-parent', '%s..%s' % (tip1, tip2))
commits2.reverse()
if not commits2:
raise Failure(
'There are no commits on %r that are not already in %r' % (tip2, tip1)
)
if goal == 'rebase':
MergeState._check_no_merges(commits2)
return MergeState(name, goal, merge_base, commits1, commits2, MergeRecord.NEW_MANUAL)
@staticmethod
def read(name):
merge_ref_re = re.compile(
r"""
^
refs\/imerge\/
""" + re.escape(name) + r"""
\/(?P<source>auto|manual)\/
(?P<i1>0|[1-9][0-9]*)
\-
(?P<i2>0|[1-9][0-9]*)
$
""",
re.VERBOSE,
)
state_ref_re = re.compile(
r"""
^
refs\/imerge\/
""" + re.escape(name) + r"""
\/state
$
""",
re.VERBOSE,
)
state = None
# A map {(i1, i2) : (sha1, source)}:
merges = {}
# refnames that were found but not understood:
unexpected = []
for line in check_output([
'git', 'for-each-ref', 'refs/imerge/%s' % (name,)
]).splitlines():
(sha1, type, refname) = line.split()
m = merge_ref_re.match(refname)
if m:
if type != 'commit':
raise Failure('Reference %r is not a commit!' % (refname,))
i1, i2 = int(m.group('i1')), int(m.group('i2'))
source = MergeState.SOURCE_TABLE[m.group('source')]
merges[i1, i2] = (sha1, source)
continue
m = state_ref_re.match(refname)
if m:
if type != 'blob':
raise Failure('Reference %r is not a blob!' % (refname,))
state = MergeState._read_state(name, sha1)
continue
unexpected.append(refname)
if state is None:
raise Failure(
'No state found; it should have been a blob reference at '
'"refs/imerge/%s/state' % (name,)
)
goal = state['goal']
if goal not in ALLOWED_GOALS:
raise Failure('Goal %r, read from state, is not recognized.' % (goal,))
if unexpected:
raise Failure(
'Unexpected reference(s) found in "refs/imerge/%s" namespace:\n %s\n'
% (name, '\n '.join(unexpected),)
)
# Find merge_base, commits1, and commits2:
(merge_base, source) = merges.pop((0, 0))
if source != MergeRecord.SAVED_MANUAL:
raise Failure('Merge base should be manual!')
commits1 = []
for i1 in itertools.count(1):
try:
(sha1, source) = merges.pop((i1, 0))
if source != MergeRecord.SAVED_MANUAL:
raise Failure('Merge %d-0 should be manual!' % (i1,))
commits1.append(sha1)
except KeyError:
break
commits2 = []
for i2 in itertools.count(1):
try:
(sha1, source) = merges.pop((0, i2))
if source != MergeRecord.SAVED_MANUAL:
raise Failure('Merge (0,%d) should be manual!' % (i2,))
commits2.append(sha1)
except KeyError:
break
state = MergeState(name, goal, merge_base, commits1, commits2, MergeRecord.SAVED_MANUAL)
# Now write the rest of the merges to state:
for ((i1, i2), (sha1, source)) in merges.iteritems():
if i1 == 0 and i2 >= state.len2:
raise Failure('Merge 0-%d is missing!' % (state.len2,))
if i1 >= state.len1 and i2 == 0:
raise Failure('Merge %d-0 is missing!' % (state.len1,))
if i1 >= state.len1 or i2 >= state.len2:
raise Failure(
'Merge %d-%d is out of range [0:%d,0:%d]'
% (i1, i2, state.len1, state.len2)
)
state[i1, i2].record_merge(sha1, source)
return state
@staticmethod
def remove(name):
# If HEAD is the scratch refname, abort any in-progress
# commits and detach HEAD:
scratch_refname = MergeState.get_scratch_refname(name)
try:
head_refname = check_output(['git', 'symbolic-ref', '--quiet', 'HEAD']).strip()
except CalledProcessError:
head_refname = None
if head_refname == scratch_refname:
try:
check_call(['git', 'merge', '--abort'])
except CalledProcessError:
pass
# Detach head so that we can delete scratch_refname:
check_call([
'git', 'update-ref', '--no-deref',
'-m', 'Detach HEAD from %s' % (scratch_refname,),
'HEAD', get_commit_sha1('HEAD'),
])
# Delete the scratch refname:
check_call([
'git', 'update-ref',
'-m', 'imerge %s: remove scratch reference' % (name,),
'-d', scratch_refname,
])
# Remove any references referring to intermediate merges:
for line in check_output([
'git', 'for-each-ref', 'refs/imerge/%s' % (name,)
]).splitlines():
(sha1, type, refname) = line.split()
try:
check_call([
'git', 'update-ref',
'-m', 'imerge: remove merge %r' % (name,),
'-d', refname,
])
except CalledProcessError, e:
sys.stderr.write(
'Warning: error removing reference %r: %s' % (refname, e)
)
# If this merge was the default, unset the default:
if MergeState.get_default_name() == name:
MergeState.set_default_name(None)
def __init__(self, name, goal, merge_base, commits1, commits2, source):
Block.__init__(self, len(commits1) + 1, len(commits2) + 1)
self.name = name
self.goal = goal
# A simulated 2D array. Values are None or MergeRecord instances.
self._data = [[None] * self.len2 for i1 in range(self.len1)]
self.get_value(0, 0).record_merge(merge_base, source)
for (i1, commit) in enumerate(commits1, 1):
self.get_value(i1, 0).record_merge(commit, source)
for (i2, commit) in enumerate(commits2, 1):
self.get_value(0, i2).record_merge(commit, source)
def set_goal(self, goal):
if goal not in ALLOWED_GOALS:
raise ValueError('%r is not an allowed goal' % (goal,))
if goal == 'rebase':
self._check_no_merges(
[self[0,i2].sha1 for i2 in range(1,self.len2)]
)
self.goal = goal
def set_value(self, i1, i2, value):
self._data[i1][i2] = value
def get_value(self, i1, i2):
value = self._data[i1][i2]
# Missing values spring to life on first access:
if value is None:
value = MergeRecord()
self._data[i1][i2] = value
return value
def __contains__(self, index):
# Avoid creating new MergeRecord objects here.
(i1, i2) = self._normalize_indexes(index)
value = self._data[i1][i2]
return (value is not None) and value.is_known()
def auto_complete_frontier(self):
"""Complete the frontier using automerges.
If progress is blocked before the frontier is complete, raise
a FrontierBlockedError. Save the state as progress is
made."""
progress_made = False
try:
while True:
frontier = MergeFrontier.map_known_frontier(self)
frontier.auto_expand()
self.save()
progress_made = True
except BlockCompleteError:
return
except FrontierBlockedError, e:
if not progress_made:
# Adjust the error message:
raise FrontierBlockedError(
'No progress was possible; suggest manual merge of %d-%d'
% (e.i1, e.i2),
e.i1, e.i2,
)
else:
raise
def find_index(self, commit):
"""Return (i1,i2) for the specified commit.
Raise CommitNotFoundError if it is not known."""
for i2 in range(0, self.len2):
for i1 in range(0, self.len1):
if (i1, i2) in self:
record = self[i1,i2]
if record.sha1 == commit:
return (i1, i2)
raise CommitNotFoundError(commit)
def incorporate_manual_merge(self, commit):
"""Record commit as a manual merge of its parents.
Return the indexes (i1,i2) where it was recorded. If the
commit is not usable for some reason, raise
ManualMergeUnusableError."""
parents = get_commit_parents(commit)
if len(parents) < 2:
raise ManualMergeUnusableError('it is not a merge', commit)
if len(parents) > 2:
raise ManualMergeUnusableError('it is an octopus merge', commit)
# Find the parents among our contents...
try:
(i1first, i2first) = self.find_index(parents[0])
(i1second, i2second) = self.find_index(parents[1])
except CommitNotFoundError:
raise ManualMergeUnusableError(
'its parents are not known merge commits', commit,
)
swapped = False
if i1first < i1second:
# Swap parents to make the parent from above the first parent:
(i1first, i2first, i1second, i2second) = (i1second, i2second, i2first, i1first)
swapped = True
if i1first != i1second + 1 or i2first != i2second - 1:
raise ManualMergeUnusableError(
'it is not a pairwise merge of adjacent parents', commit,
)
if swapped:
# Create a new merge with the parents in the conventional order:
commit = reparent(commit, [parents[1], parents[0]])
i1, i2 = i1first, i2second
self[i1, i2].record_merge(commit, MergeRecord.NEW_MANUAL)
return (i1, i2)
@staticmethod
def _is_ancestor(commit1, commit2):
"""Return True iff commit1 is an ancestor (or equal to) commit2."""
if commit1 == commit2:
return True
else:
return int(
check_output([
'git', 'rev-list', '--count', '--ancestry-path',
'%s..%s' % (commit1, commit2,),
]).strip()
) != 0
@staticmethod
def _set_refname(refname, commit, force=False):
try:
ref_oldval = get_commit_sha1(refname)
except ValueError:
# refname doesn't already exist; simply point it at commit:
check_call(['git', 'update-ref', refname, commit])
checkout(refname)
else:
# refname already exists. This has two ramifications:
# 1. HEAD might point at it
# 2. We may only fast-forward it (unless force is set)
try:
head_refname = check_output(['git', 'symbolic-ref', '--quiet', 'HEAD']).strip()
except CalledProcessError:
head_refname = None
if not force:
if not MergeState._is_ancestor(ref_oldval, commit):
raise Failure(
'%s cannot be fast-forwarded to %s!' % (refname, commit)
)
if head_refname == refname:
check_call(['git', 'reset', '--hard', commit])
else:
check_call([
'git', 'update-ref',
'-m', 'imerge: recording final merge',
refname, commit,
])
checkout(refname)
def simplify_to_rebase_with_history(self, refname, force=False):
i1 = self.len1 - 1
for i2 in range(1, self.len2):
if not (i1, i2) in self:
raise Failure(
'Cannot simplify to rebase-with-history because '
'merge %d-%d is not yet done'
% (i1, i2)
)
commit = self[i1, 0].sha1
for i2 in range(1, self.len2):
orig = self[0, i2].sha1
tree = rev_parse('%s^{tree}' % (self[i1, i2].sha1,))
# Create a commit, copying the old log message:
commit = check_output([
'git', 'commit-tree',
'-m', get_log_message(orig),
'-p', commit, '-p', orig, tree,
]).strip()
self._set_refname(refname, commit, force=force)
def simplify_to_rebase(self, refname, force=False):
i1 = self.len1 - 1
for i2 in range(1, self.len2):
if not (i1, i2) in self:
raise Failure(
'Cannot simplify to rebase because merge %d-%d is not yet done'
% (i1, i2)
)
commit = self[i1, 0].sha1
for i2 in range(1, self.len2):
orig = self[0, i2].sha1
tree = rev_parse('%s^{tree}' % (self[i1, i2].sha1,))
# Create a commit, copying the old log message:
commit = check_output([
'git', 'commit-tree',
'-m', get_log_message(orig),
'-p', commit, tree,
]).strip()
self._set_refname(refname, commit, force=force)
def simplify_to_merge(self, refname, force=False):
if not (-1, -1) in self:
raise Failure(
'Cannot simplify to merge because merge %d-%d is not yet done'
% (self.len1 - 1, self.len2 - 1)
)
tree = rev_parse('%s^{tree}' % (self[-1, -1].sha1,))
parents = [self[-1,0].sha1, self[0,-1].sha1]
# Create a preliminary commit with a generic commit message:
sha1 = check_output([
'git', 'commit-tree',
'-m', 'Merge commit %s into commit %s' % (parents[1], parents[0]),
'-p', parents[0], '-p', parents[1], tree,
]).strip()
self._set_refname(refname, sha1, force=force)
# Now let the user edit the commit log message:
check_call(['git', 'commit', '--amend'])
def simplify(self, refname, force=False):
"""Simplify this MergeState and save the result to refname.
The merge must be complete before calling this method."""
if self.goal == 'rebase-with-history':
self.simplify_to_rebase_with_history(refname, force=force)
elif self.goal == 'rebase':
self.simplify_to_rebase(refname, force=force)
elif self.goal == 'merge':
self.simplify_to_merge(refname, force=force)
else:
raise ValueError('Invalid value for goal (%r)' % (self.goal,))
def save(self):
"""Write the current MergeState to the repository."""
for i2 in range(0, self.len2):
for i1 in range(0, self.len1):
if (i1, i2) in self:
self[i1,i2].save(self.name, i1, i2)
state = dict(
version='.'.join(map(str, STATE_VERSION)),
goal=self.goal,
)
state_string = json.dumps(state, sort_keys=True, indent=4)
cmd = ['git', 'hash-object', '-t', 'blob', '-w', '--stdin']
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
(out, err) = p.communicate(state_string)
retcode = p.poll()
if retcode:
raise CalledProcessError(retcode, cmd, output=out)
sha1 = out.strip()
check_call([
'git', 'update-ref',
'-m', 'imerge %r: Record state' % (self.name,),
'refs/imerge/%s/state' % (self.name,),
sha1,
])
def __str__(self):
return 'MergeState(%r, goal=%r)' % (self.name, self.goal,)
def request_user_merge(merge_state, i1, i2):
"""Prepare the working tree for the user to do a manual merge.
It is assumed that the merge above and to the left of (i1, i2) are
already done."""
above = merge_state[i1, i2 - 1]
left = merge_state[i1 - 1, i2]
if not above.is_known() or not left.is_known():
raise RuntimeError('The parents of merge %d-%d are not ready' % (i1, i2))
refname = MergeState.get_scratch_refname(merge_state.name)
check_call([
'git', 'update-ref',
'-m', 'imerge %r: Prepare merge %d-%d' % (merge_state.name, i1, i2,),
refname, above.sha1,
])
checkout(refname)
try:
check_call([
'git', 'merge', '--no-commit',
'-m', 'Merge %d-%d of incremental merge \'%s\'' % (i1, i2, merge_state.name,),
left.sha1,
])
except CalledProcessError:
# We expect an error (otherwise we would have automerged!)
pass
sys.stdout.write('Original first commit:\n')
check_call(['git', 'log', '--no-walk', merge_state[i1,0].sha1])
sys.stdout.write('Original second commit:\n')
check_call(['git', 'log', '--no-walk', merge_state[0,i2].sha1])
sys.stdout.write(
'Please prepare a merge of the above commits, commit the result,\n'
'then type\n'
' git-imerge continue\n'
)
def incorporate_user_merge(merge_state):
"""If the user has done a merge for us, incorporate the results.
If reference refs/heads/imerge/NAME exists, try to incorporate it
into merge_state, auto-fill if possible, and delete the reference.
If the reference exists but cannot be used, raise a
ManualMergeUnusableError. This function must be called with a
clean work tree."""
refname = MergeState.get_scratch_refname(merge_state.name)
try:
commit = get_commit_sha1(refname)
except ValueError:
return False
merge_frontier = MergeFrontier.map_known_frontier(merge_state)
# This might throw ManualMergeUnusableError:
(i1, i2) = merge_state.incorporate_manual_merge(commit)
merge_state.save()
try:
headref = check_output(['git', 'symbolic-ref', '-q', 'HEAD']).strip()
except CalledProcessError:
pass
else:
if headref == refname:
# Detach head so that we can delete refname.
check_call([
'git', 'update-ref', '--no-deref',
'-m', 'Detach HEAD from %s' % (refname,),
'HEAD', commit,
])
check_call([
'git', 'update-ref',
'-m', 'imerge %s: remove scratch reference' % (merge_state.name,),
'-d', refname,
])
# Now expand the merge frontier based on the new information (if
# possible):
return merge_frontier.add_success(i1, i2)
def choose_merge_name(name, default_to_unique=True):
# If a name was specified, try to use it and fail if not possible:
if name is not None:
if not MergeState.check_exists(name):
raise Failure('There is no incremental merge called \'%s\'!' % (name,))
MergeState.set_default_name(name)
return name
# A name was not specified. Try to use the default name:
default_name = MergeState.get_default_name()
if default_name:
if MergeState.check_exists(default_name):
return default_name
else:
# There's no reason to keep the invalid default around:
MergeState.set_default_name(None)
raise Failure(
'Warning: The default incremental merge \'%s\' has disappeared.\n'
'(The setting imerge.default has been cleared.)\n'
'Please select an incremental merge using --name'
% (default_name,)
)
if default_to_unique:
# If there is exactly one imerge, set it to be the default and use it.
names = list(MergeState.iter_existing_names())
if len(names) == 1 and MergeState.check_exists(names[0]):
MergeState.set_default_name(names[0])
return names[0]
raise Failure('Please select an incremental merge using --name')
def read_merge_state(name=None):
return MergeState.read(choose_merge_name(name))
@Failure.wrap
def main(args):
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest='subcommand', help='sub-command')
parser_start = subparsers.add_parser(
'start',
help=(
'start a new incremental merge '
'(equivalent to "init" followed by "continue")'
),
)
parser_start.add_argument(
'--name', action='store', default=None,
help='name to use for this incremental merge',
)
parser_start.add_argument(
'--goal',
action='store', default=DEFAULT_GOAL,
choices=ALLOWED_GOALS,
help='the goal of the incremental merge',
)
#parser_start.add_argument(
# '--conflicts', ...
# action='store', default='pairwise',
# choices=['pairwise', 'fewest'],
# help='what sort of conflicts will be presented to the user',
# )
parser_start.add_argument(
'--first-parent', action='store_true', default=None,
help=(
'handle only the first parent commits '
'(this option is currently required)'
),
)
parser_start.add_argument(
'branch', action='store',
help='the tip of the branch to be merged into HEAD',
)
parser_continue = subparsers.add_parser(
'continue',
help=(
'record the merge at branch imerge/NAME '
'and start the next step of the merge '
'(equivalent to "record" followed by "autofill" '
'and then sets up the working copy with the next '
'conflict that has to be resolved manually)'
),
)
parser_continue.add_argument(
'--name', action='store', default=None,
help='name of merge to continue',
)
parser_finish = subparsers.add_parser(
'finish',
help=(
'simplify then remove a completed incremental merge '
'(equivalent to "simplify" followed by "remove")'
),
)
parser_finish.add_argument(
'--name', action='store', default=None,
help='name of merge to finish',
)
parser_finish.add_argument(
'--goal',
action='store', default=None,
choices=ALLOWED_GOALS,
help=(
'the type of simplification to be made '
'(default is the value provided to "init" or "start")'
),
)
parser_finish.add_argument(
'--branch',
action='store', default=None,
help=(
'the name of the branch to which to store the result '
'(default is the name of the merge). If '
'BRANCH already exists then it must be able to be '
'fast-forwarded to the result unless the --force option is '
'specified.'
),
)
parser_finish.add_argument(
'--force',
action='store_true', default=False,
help='allow the target branch to be updated in a non-fast-forward manner',
)
parser_diagram = subparsers.add_parser(
'diagram',
help='display a diagram of the current state of a merge',
)
parser_diagram.add_argument(
'--name', action='store', default=None,
help='name of merge to diagram',
)
parser_diagram.add_argument(
'--commits', action='store_true', default=False,
help='show the merges that have been made so far',
)
parser_diagram.add_argument(
'--frontier', action='store_true', default=False,
help='show the current merge frontier',
)
parser_list = subparsers.add_parser(
'list',
help=(
'list the names of incremental merges that are currently in progress. '
'The active merge is shown with an asterisk next to it.'
),
)
parser_init = subparsers.add_parser(
'init',
help='initialize a new incremental merge',
)
parser_init.add_argument(
'--name', action='store', default=None,
help='name to use for this incremental merge',
)
parser_init.add_argument(
'--goal',
action='store', default=DEFAULT_GOAL,
choices=ALLOWED_GOALS,
help='the goal of the incremental merge',
)
#parser_init.add_argument(
# '--conflicts', ...
# action='store', default='pairwise',
# choices=['pairwise', 'fewest'],
# help='what sort of conflicts will be presented to the user',
# )
parser_init.add_argument(
'--first-parent', action='store_true', default=None,
help=(
'handle only the first parent commits '
'(this option is currently required)'
),
)
parser_init.add_argument(
'branch', action='store',
help='the tip of the branch to be merged into HEAD',
)
parser_record = subparsers.add_parser(
'record',
help='record the merge at branch imerge/NAME',
)
parser_record.add_argument(
'--name', action='store', default=None,
help='name of merge to which the merge should be added',
)
parser_autofill = subparsers.add_parser(
'autofill',
help='autofill non-conflicting merges',
)
parser_autofill.add_argument(
'--name', action='store', default=None,
help='name of merge to autofill',
)
parser_simplify = subparsers.add_parser(
'simplify',
help=(
'simplify a completed incremental merge by discarding unneeded '
'intermediate merges and cleaning up the ancestry of the commits '
'that are retained'
),
)
parser_simplify.add_argument(
'--name', action='store', default=None,
help='name of merge to simplify',
)
parser_simplify.add_argument(
'--goal',
action='store', default=None,
choices=ALLOWED_GOALS,
help=(
'the type of simplification to be made '
'(default is the value provided to "init" or "start")'
),
)
parser_simplify.add_argument(
'--branch',
action='store', default=None,
help=(
'the name of the branch to which to store the result '
'(default is the name of the merge). If '
'BRANCH already exists then it must be able to be '
'fast-forwarded to the result unless the --force option is '
'specified.'
),
)
parser_simplify.add_argument(
'--force',
action='store_true', default=False,
help='allow the target branch to be updated in a non-fast-forward manner',
)
parser_remove = subparsers.add_parser(
'remove',
help='irrevocably remove an incremental merge',
)
parser_remove.add_argument(
'--name', action='store', default=None,
help='name of incremental merge to remove',
)
parser_reparent = subparsers.add_parser(
'reparent',
help='change the parents of the HEAD commit',
)
parser_reparent.add_argument(
'parents', nargs='*', help='[PARENT...]',
)
options = parser.parse_args(args)
if options.subcommand == 'list':
default_merge = MergeState.get_default_name()
for name in MergeState.iter_existing_names():
if name == default_merge:
sys.stdout.write('* %s\n' % (name,))
else:
sys.stdout.write(' %s\n' % (name,))
elif options.subcommand == 'init':
require_clean_work_tree('proceed')
if not options.first_parent:
parser.error(
'The --first-parent option is currently required for the "init" command'
)
if not options.name:
parser.error(
'Please specify the --name to be used for this incremental merge'
)
merge_state = MergeState.initialize(
options.name, options.goal, 'HEAD', options.branch,
)
merge_state.save()
MergeState.set_default_name(options.name)
elif options.subcommand == 'start':
require_clean_work_tree('proceed')
if not options.first_parent:
parser.error(
'The --first-parent option is currently required for the "start" command'
)
if not options.name:
parser.error(
'Please specify the --name to be used for this incremental merge'
)
merge_state = MergeState.initialize(
options.name, options.goal, 'HEAD', options.branch,
)
merge_state.save()
MergeState.set_default_name(options.name)
try:
merge_state.auto_complete_frontier()
except FrontierBlockedError, e:
request_user_merge(merge_state, e.i1, e.i2)
else:
sys.stderr.write('Merge is complete!\n')
elif options.subcommand == 'remove':
MergeState.remove(choose_merge_name(options.name, default_to_unique=False))
elif options.subcommand == 'continue':
require_clean_work_tree('proceed')
merge_state = read_merge_state(options.name)
try:
incorporate_user_merge(merge_state)
except ManualMergeUnusableError, e:
raise Failure(str(e))
try:
merge_state.auto_complete_frontier()
except FrontierBlockedError, e:
request_user_merge(merge_state, e.i1, e.i2)
else:
sys.stderr.write('Merge is complete!\n')
elif options.subcommand == 'record':
require_clean_work_tree('proceed')
merge_state = read_merge_state(options.name)
try:
if incorporate_user_merge(merge_state):
sys.stderr.write('Merge has been recorded.\n')
else:
raise Failure(
'There was no merge at %s!'
% (MergeState.get_scratch_refname(merge_state.name),)
)
except ManualMergeUnusableError, e:
raise Failure(str(e))
try:
merge_state.auto_complete_frontier()
except FrontierBlockedError, e:
pass
else:
sys.stderr.write('Merge is complete!\n')
elif options.subcommand == 'autofill':
require_clean_work_tree('proceed')
merge_state = read_merge_state(options.name)
with TemporaryHead():
try:
merge_state.auto_complete_frontier()
except FrontierBlockedError, e:
raise Failure(str(e))
elif options.subcommand == 'simplify':
require_clean_work_tree('proceed')
merge_state = read_merge_state(options.name)
merge_frontier = MergeFrontier.map_known_frontier(merge_state)
if not merge_frontier.is_complete():
raise Failure('Merge %s is not yet complete!' % (merge_state.name,))
refname = 'refs/heads/%s' % ((options.branch or merge_state.name),)
if options.goal is not None:
merge_state.set_goal(options.goal)
merge_state.save()
merge_state.simplify(refname, force=options.force)
elif options.subcommand == 'finish':
require_clean_work_tree('proceed')
options.name = choose_merge_name(options.name, default_to_unique=False)
merge_state = read_merge_state(options.name)
merge_frontier = MergeFrontier.map_known_frontier(merge_state)
if not merge_frontier.is_complete():
raise Failure('Merge %s is not yet complete!' % (merge_state.name,))
refname = 'refs/heads/%s' % ((options.branch or merge_state.name),)
if options.goal is not None:
merge_state.set_goal(options.goal)
merge_state.save()
merge_state.simplify(refname, force=options.force)
MergeState.remove(options.name)
elif options.subcommand == 'diagram':
if not (options.commits or options.frontier):
options.frontier = True
merge_state = read_merge_state(options.name)
if options.commits:
merge_state.write(sys.stdout)
sys.stdout.write('\n')
if options.frontier:
merge_frontier = MergeFrontier.map_known_frontier(merge_state)
merge_frontier.write(sys.stdout)
sys.stdout.write('\n')
sys.stdout.write(
'Key:\n'
)
if options.frontier:
sys.stdout.write(
' |,-,+ = rectangles forming current merge frontier\n'
)
sys.stdout.write(
' * = merge done manually\n'
' . = merge done automatically\n'
' ? = no merge recorded\n'
'\n'
)
elif options.subcommand == 'reparent':
try:
commit_sha1 = get_commit_sha1('HEAD')
except ValueError:
sys.exit('HEAD is not a valid commit')
try:
parent_sha1s = map(get_commit_sha1, options.parents)
except ValueError, e:
sys.exit(e.message)
sys.stdout.write('%s\n' % (reparent(commit_sha1, parent_sha1s),))
else:
parser.error('Unrecognized subcommand')
main(sys.argv[1:])