mirror of
https://github.com/mhagger/git-imerge.git
synced 2024-09-21 12:39:56 +03:00
2361 lines
78 KiB
Python
Executable File
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:])
|
|
|