summaryrefslogtreecommitdiffstats
path: root/bin
diff options
context:
space:
mode:
authorJesse Luehrs <doy@tozt.net>2018-11-10 14:27:19 -0500
committerJesse Luehrs <doy@tozt.net>2018-11-10 14:27:19 -0500
commit30a133e0b2f5a75a0f2350f25773768a71db11b2 (patch)
tree32aa3a59e78f268f45fafb3082f4a0c998da5f21 /bin
parent6cea5cc590d4ec623e1aa66d8b5113cbc58a2132 (diff)
downloadconf-30a133e0b2f5a75a0f2350f25773768a71db11b2.tar.gz
conf-30a133e0b2f5a75a0f2350f25773768a71db11b2.zip
update git-imerge
Diffstat (limited to 'bin')
-rw-r--r--[-rwxr-xr-x]bin/git/git-imerge3705
1 files changed, 2260 insertions, 1445 deletions
diff --git a/bin/git/git-imerge b/bin/git/git-imerge
index 457fdab..b903539 100755..100644
--- a/bin/git/git-imerge
+++ b/bin/git/git-imerge
@@ -1,4 +1,5 @@
#! /usr/bin/env python
+# -*- coding: utf-8 -*-
# Copyright 2012-2013 Michael Haggerty <mhagger@alum.mit.edu>
#
@@ -38,9 +39,21 @@ Instructions:
To start an incremental merge or rebase, use one of the following
commands:
- git-imerge merge BRANCH # analogous to "git merge BRANCH"
- git-imerge rebase BRANCH # analogous to "git rebase BRANCH"
- git-imerge start --name=NAME --goal=GOAL --first-parent BRANCH
+ git-imerge merge BRANCH
+ Analogous to "git merge BRANCH"
+
+ git-imerge rebase BRANCH
+ Analogous to "git rebase BRANCH"
+
+ git-imerge drop [commit | commit1..commit2]
+ Drop the specified commit(s) from the current branch
+
+ git-imerge revert [commit | commit1..commit2]
+ Revert the specified commits by adding new commits that
+ reverse their effects
+
+ git-imerge start --name=NAME --goal=GOAL BRANCH
+ Start a general imerge
Then the tool will present conflicts to you one at a time, similar to
"git rebase --incremental". Resolve each conflict, and then
@@ -75,7 +88,6 @@ import subprocess
from subprocess import CalledProcessError
from subprocess import check_call
import itertools
-import functools
import argparse
from io import StringIO
import json
@@ -110,9 +122,14 @@ ZEROS = '0' * 40
ALLOWED_GOALS = [
'full',
- 'rebase-with-history',
'rebase',
+ 'rebase-with-history',
+ 'border',
+ 'border-with-history',
+ 'border-with-history2',
'merge',
+ 'drop',
+ 'revert',
]
DEFAULT_GOAL = 'merge'
@@ -123,22 +140,6 @@ class Failure(Exception):
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 as e:
- sys.exit(str(e))
-
- return wrapper
-
pass
@@ -243,337 +244,915 @@ def communicate(process, input=None):
return (output, error)
+if sys.hexversion < 0x03000000:
+ # In Python 2.x, os.environ keys and values must be byte
+ # strings:
+ def env_encode(s):
+ """Encode unicode keys or values for use in os.environ."""
+
+ return s.encode(PREFERRED_ENCODING)
+
+else:
+ # In Python 3.x, os.environ keys and values must be unicode
+ # strings:
+ def env_encode(s):
+ """Use unicode keys or values unchanged in os.environ."""
+
+ return s
+
+
class UncleanWorkTreeError(Failure):
pass
-def require_clean_work_tree(action):
- """Verify that the current tree is clean.
+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
- 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,
- )
- err = communicate(process)[1]
- retcode = process.poll()
- if retcode:
- raise UncleanWorkTreeError(err.rstrip())
+class InvalidBranchNameError(Failure):
+ pass
+
+
+class NotFirstParentAncestorError(Failure):
+ def __init__(self, commit1, commit2):
+ Failure.__init__(
+ self,
+ 'Commit "%s" is not a first-parent ancestor of "%s"'
+ % (commit1, commit2),
+ )
+
+
+class NonlinearAncestryError(Failure):
+ def __init__(self, commit1, commit2):
+ Failure.__init__(
+ self,
+ 'The history "%s..%s" is not linear'
+ % (commit1, commit2),
+ )
+
+
+class NothingToDoError(Failure):
+ def __init__(self, src_tip, dst_tip):
+ Failure.__init__(
+ self,
+ 'There are no commits on "%s" that are not already in "%s"'
+ % (src_tip, dst_tip),
+ )
+
+
+class GitTemporaryHead(object):
+ """A context manager that records the current HEAD state then restores it.
+
+ This should only be used when the working copy is clean. message
+ is used for the reflog.
+
+ """
+
+ def __init__(self, git, message):
+ self.git = git
+ self.message = message
+
+ def __enter__(self):
+ self.head_name = self.git.get_head_refname()
+ return self
- process = subprocess.Popen(
- ['git', 'update-index', '-q', '--ignore-submodules', '--refresh'],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if self.head_name:
+ try:
+ self.git.restore_head(self.head_name, self.message)
+ except CalledProcessError as e:
+ raise Failure(
+ 'Could not restore HEAD to %r!: %s\n'
+ % (self.head_name, e.message,)
+ )
+
+ return False
+
+
+class GitRepository(object):
+ BRANCH_PREFIX = 'refs/heads/'
+
+ MERGE_STATE_REFNAME_RE = re.compile(
+ r"""
+ ^
+ refs\/imerge\/
+ (?P<name>.+)
+ \/state
+ $
+ """,
+ re.VERBOSE,
)
- out, err = communicate(process)
- 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,))
+ def __init__(self):
+ self.git_dir_cache = None
- try:
+ def git_dir(self):
+ if self.git_dir_cache is None:
+ self.git_dir_cache = check_output(
+ ['git', 'rev-parse', '--git-dir']
+ ).rstrip('\n')
+
+ return self.git_dir_cache
+
+ def check_imerge_name_format(self, name):
+ """Check that name is a valid imerge 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,))
+
+ def check_branch_name_format(self, name):
+ """Check that name is a valid branch name."""
+
+ try:
+ call_silently(
+ ['git', 'check-ref-format', 'refs/heads/%s' % (name,)]
+ )
+ except CalledProcessError:
+ raise InvalidBranchNameError('Name %r is not a valid branch name!' % (name,))
+
+ def iter_existing_imerge_names(self):
+ """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 = GitRepository.MERGE_STATE_REFNAME_RE.match(refname)
+ if m:
+ yield m.group('name')
+
+ def set_default_imerge_name(self, 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 as e:
+ if e.returncode == 5:
+ # Value was not set
+ pass
+ else:
+ raise
+ else:
+ check_call(['git', 'config', 'imerge.default', name])
+
+ def get_default_imerge_name(self):
+ """Get the name of the default merge, or None if it is currently unset."""
+
+ try:
+ return check_output(['git', 'config', 'imerge.default']).rstrip()
+ except CalledProcessError:
+ return None
+
+ def get_default_edit(self):
+ """Should '--edit' be used when committing intermediate user merges?
+
+ When 'git imerge continue' or 'git imerge record' finds a user
+ merge that can be committed, should it (by default) ask the user
+ to edit the commit message? This behavior can be configured via
+ 'imerge.editmergemessages'. If it is not configured, return False.
+
+ Please note that this function is only used to choose the default
+ value. It can be overridden on the command line using '--edit' or
+ '--no-edit'.
+
+ """
+
+ try:
+ return {'true' : True, 'false' : False}[
+ check_output(
+ ['git', 'config', '--bool', 'imerge.editmergemessages']
+ ).rstrip()
+ ]
+ except CalledProcessError:
+ return False
+
+ def unstaged_changes(self):
+ """Return True iff there are unstaged changes in the working copy"""
+
+ try:
+ check_call(['git', 'diff-files', '--quiet', '--ignore-submodules'])
+ return False
+ except CalledProcessError:
+ return True
+
+ def uncommitted_changes(self):
+ """Return True iff the index contains uncommitted changes."""
+
+ try:
+ check_call([
+ 'git', 'diff-index', '--cached', '--quiet',
+ '--ignore-submodules', 'HEAD', '--',
+ ])
+ return False
+ except CalledProcessError:
+ return True
+
+ def get_commit_sha1(self, arg):
+ """Convert arg into a SHA1 and verify that it refers to a commit.
+
+ If not, raise ValueError."""
+
+ try:
+ return self.rev_parse('%s^{commit}' % (arg,))
+ except CalledProcessError:
+ raise ValueError('%r does not refer to a valid git commit' % (arg,))
+
+ def refresh_index(self):
+ process = subprocess.Popen(
+ ['git', 'update-index', '-q', '--ignore-submodules', '--refresh'],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ )
+ out, err = communicate(process)
+ retcode = process.poll()
+ if retcode:
+ raise UncleanWorkTreeError(err.rstrip() or out.rstrip())
+
+ def verify_imerge_name_available(self, name):
+ self.check_imerge_name_format(name)
+ if check_output(['git', 'for-each-ref', 'refs/imerge/%s' % (name,)]):
+ raise Failure('Name %r is already in use!' % (name,))
+
+ def check_imerge_exists(self, 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."""
+
+ self.check_imerge_name_format(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':
+ self.read_imerge_state_dict(name)
+ # If that didn't throw an exception:
+ return True
+ else:
+ return False
+
+ def read_imerge_state_dict(self, name):
+ state_string = check_output(
+ ['git', 'cat-file', 'blob', 'refs/imerge/%s/state' % (name,)],
+ )
+ state = json.loads(state_string)
+
+ # Convert state['version'] to a tuple of integers, and verify
+ # that it is compatible with this version of the script:
+ version = tuple(int(i) for i in state['version'].split('.'))
+ if version[0] != STATE_VERSION[0] or version[1] > STATE_VERSION[1]:
+ raise Failure(
+ 'The format of imerge %s (%s) is not compatible with this script version.'
+ % (name, state['version'],)
+ )
+ state['version'] = version
+
+ return state
+
+ def read_imerge_state(self, name):
+ """Read the state associated with the specified imerge.
+
+ Return the tuple
+
+ (state_dict, {(i1, i2) : (sha1, source), ...})
+
+ , where source is 'auto' or 'manual'. Validity is checked only
+ lightly.
+
+ """
+
+ 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 = 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 = self.read_imerge_state_dict(name)
+ 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,)
+ )
+
+ if unexpected:
+ raise Failure(
+ 'Unexpected reference(s) found in "refs/imerge/%s" namespace:\n %s\n'
+ % (name, '\n '.join(unexpected),)
+ )
+
+ return (state, merges)
+
+ def write_imerge_state_dict(self, name, state):
+ state_string = json.dumps(state, sort_keys=True) + '\n'
+
+ cmd = ['git', 'hash-object', '-t', 'blob', '-w', '--stdin']
+ p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
+ out = communicate(p, input=state_string)[0]
+ retcode = p.poll()
+ if retcode:
+ raise CalledProcessError(retcode, cmd)
+ sha1 = out.strip()
check_call([
- 'git', 'diff-index', '--cached', '--quiet',
- '--ignore-submodules', 'HEAD', '--',
+ 'git', 'update-ref',
+ '-m', 'imerge %r: Record state' % (name,),
+ 'refs/imerge/%s/state' % (name,),
+ sha1,
])
- except CalledProcessError:
- if not error:
- error.append('Cannot %s: Your index contains uncommitted changes.' % (action,))
+
+ def is_ancestor(self, commit1, commit2):
+ """Return True iff commit1 is an ancestor (or equal to) commit2."""
+
+ if commit1 == commit2:
+ return True
else:
- error.append('Additionally, your index contains uncommitted changes.')
+ return int(
+ check_output([
+ 'git', 'rev-list', '--count', '--ancestry-path',
+ '%s..%s' % (commit1, commit2,),
+ ]).strip()
+ ) != 0
- if error:
- raise UncleanWorkTreeError('\n'.join(error))
+ def is_ff(self, refname, commit):
+ """Would updating refname to commit be a fast-forward update?
+ Return True iff refname is not currently set or it points to an
+ ancestor of commit.
-class InvalidBranchNameError(Failure):
- pass
+ """
+
+ try:
+ ref_oldval = self.get_commit_sha1(refname)
+ except ValueError:
+ # refname doesn't already exist; no problem.
+ return True
+ else:
+ return self.is_ancestor(ref_oldval, commit)
+ def automerge(self, commit1, commit2, msg=None):
+ """Attempt an automatic merge of commit1 and commit2.
-def check_branch_name_format(name):
- """Check that name is a valid branch name."""
+ Return the SHA1 of the resulting commit, or raise
+ AutomaticMergeFailed on error. This must be called with a clean
+ worktree."""
- try:
- call_silently(
- ['git', 'check-ref-format', 'refs/heads/%s' % (name,)]
+ call_silently(['git', 'checkout', '-f', commit1])
+ cmd = ['git', '-c', 'rerere.enabled=false', 'merge']
+ if msg is not None:
+ cmd += ['-m', msg]
+ cmd += [commit2]
+ try:
+ call_silently(cmd)
+ except CalledProcessError:
+ self.abort_merge()
+ raise AutomaticMergeFailed(commit1, commit2)
+ else:
+ return self.get_commit_sha1('HEAD')
+
+ def manualmerge(self, commit, msg):
+ """Initiate a merge of commit into the current HEAD."""
+
+ check_call(['git', 'merge', '--no-commit', '-m', msg, commit,])
+
+ def require_clean_work_tree(self, 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,
)
- except CalledProcessError:
- raise InvalidBranchNameError('Name %r is not a valid branch name!' % (name,))
+ err = communicate(process)[1]
+ retcode = process.poll()
+ if retcode:
+ raise UncleanWorkTreeError(err.rstrip())
+ self.refresh_index()
-def rev_parse(arg):
- return check_output(['git', 'rev-parse', '--verify', '--quiet', arg]).strip()
+ error = []
+ if self.unstaged_changes():
+ error.append('Cannot %s: You have unstaged changes.' % (action,))
+ if self.uncommitted_changes():
+ if not error:
+ error.append('Cannot %s: Your index contains uncommitted changes.' % (action,))
+ else:
+ error.append('Additionally, your index contains uncommitted changes.')
-def rev_list(*args):
- return [
- l.strip()
- for l in check_output(['git', 'rev-list'] + list(args),).splitlines()
- ]
+ if error:
+ raise UncleanWorkTreeError('\n'.join(error))
+ def simple_merge_in_progress(self):
+ """Return True iff a merge (of a single branch) is in progress."""
-def get_type(arg):
- """Return the type of a git object ('commit', 'tree', 'blob', or 'tag')."""
+ try:
+ with open(os.path.join(self.git_dir(), 'MERGE_HEAD')) as f:
+ heads = [line.rstrip() for line in f]
+ except IOError:
+ return False
- return check_output(['git', 'cat-file', '-t', arg]).strip()
+ return len(heads) == 1
+ def commit_user_merge(self, edit_log_msg=None):
+ """If a merge is in progress and ready to be committed, commit it.
-def get_tree(arg):
- return rev_parse('%s^{tree}' % (arg,))
+ If a simple merge is in progress and any changes in the working
+ tree are staged, commit the merge commit and return True.
+ Otherwise, return False.
+ """
-BRANCH_PREFIX = 'refs/heads/'
+ if not self.simple_merge_in_progress():
+ return False
-def checkout(refname):
- if refname.startswith(BRANCH_PREFIX):
- target = refname[len(BRANCH_PREFIX):]
- else:
- target = '%s^0' % (refname,)
- check_call(['git', 'checkout', target])
+ # Check if all conflicts are resolved and everything in the
+ # working tree is staged:
+ self.refresh_index()
+ if self.unstaged_changes():
+ raise UncleanWorkTreeError(
+ 'Cannot proceed: You have unstaged changes.'
+ )
+ # A merge is in progress, and either all changes have been staged
+ # or no changes are necessary. Create a merge commit.
+ cmd = ['git', 'commit', '--no-verify']
-def get_commit_sha1(arg):
- """Convert arg into a SHA1 and verify that it refers to a commit.
+ if edit_log_msg is None:
+ edit_log_msg = self.get_default_edit()
- If not, raise ValueError."""
+ if edit_log_msg:
+ cmd += ['--edit']
+ else:
+ cmd += ['--no-edit']
- try:
- return rev_parse('%s^{commit}' % (arg,))
- except CalledProcessError:
- raise ValueError('%r does not refer to a valid git commit' % (arg,))
+ try:
+ check_call(cmd)
+ except CalledProcessError:
+ raise Failure('Could not commit staged changes.')
+ return True
-def get_commit_parents(commit):
- """Return a list containing the parents of commit."""
+ def create_commit_chain(self, base, path):
+ """Point refname at the chain of commits indicated by path.
- return check_output(
- ['git', '--no-pager', 'log', '--no-walk', '--pretty=format:%P', commit]
- ).strip().split()
+ path is a list [(commit, metadata), ...]. Create a series of
+ commits corresponding to the entries in path. Each commit's tree
+ is taken from the corresponding old commit, and each commit's
+ metadata is taken from the corresponding metadata commit. Use base
+ as the parent of the first commit, or make the first commit a root
+ commit if base is None. Reuse existing commits from the list
+ whenever possible.
+ Return a commit object corresponding to the last commit in the
+ chain.
-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)
-
-
-def get_author_info(commit):
- a = check_output([
- 'git', '--no-pager', 'log', '-n1',
- '--format=%an%x00%ae%x00%ai', commit
- ]).strip().split('\x00')
-
- return {
- str('GIT_AUTHOR_NAME'): str(a[0]),
- str('GIT_AUTHOR_EMAIL'): str(a[1]),
- str('GIT_AUTHOR_DATE'): str(a[2]),
- }
+ """
+ reusing = True
+ if base is None:
+ if not path:
+ raise ValueError('neither base nor path specified')
+ parents = []
+ else:
+ parents = [base]
+
+ for (commit, metadata) in path:
+ if reusing:
+ if commit == metadata and self.get_commit_parents(commit) == parents:
+ # We can reuse this commit, too.
+ parents = [commit]
+ continue
+ else:
+ reusing = False
+
+ # Create a commit, copying the old log message and author info
+ # from the metadata commit:
+ tree = self.get_tree(commit)
+ new_commit = self.commit_tree(
+ tree, parents,
+ msg=self.get_log_message(metadata),
+ metadata=self.get_author_info(metadata),
+ )
+ parents = [new_commit]
-def commit_tree(tree, parents, msg, metadata=None):
- """Create a commit containing the specified tree.
+ [commit] = parents
+ return commit
- metadata can be author or committer information to be added to the
- environment; e.g., {'GIT_AUTHOR_NAME' : 'me'}.
+ def rev_parse(self, arg):
+ return check_output(['git', 'rev-parse', '--verify', '--quiet', arg]).strip()
- Return the SHA-1 of the new commit object."""
+ def rev_list(self, *args):
+ cmd = ['git', 'rev-list'] + list(args)
+ return [
+ l.strip()
+ for l in check_output(cmd).splitlines()
+ ]
- cmd = ['git', 'commit-tree', tree]
- for parent in parents:
- cmd += ['-p', parent]
+ def rev_list_with_parents(self, *args):
+ """Iterate over (commit, [parent,...])."""
- if metadata is not None:
- env = os.environ.copy()
- env.update(metadata)
- else:
- env = os.environ
+ cmd = ['git', 'log', '--format=%H %P'] + list(args)
+ for line in check_output(cmd).splitlines():
+ commits = line.strip().split()
+ yield (commits[0], commits[1:])
- process = subprocess.Popen(
- cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
- )
- out = communicate(process, input=msg)[0]
- retcode = process.poll()
+ def summarize_commit(self, commit):
+ """Summarize `commit` to stdout."""
- if retcode:
- # We don't store the output in the CalledProcessError because
- # the "output" keyword parameter was not supported in Python
- # 2.6:
- raise CalledProcessError(retcode, cmd)
+ check_call(['git', '--no-pager', 'log', '--no-walk', commit])
+
+ def get_author_info(self, commit):
+ """Return environment settings to set author metadata.
+
+ Return a map {str : str}."""
- return out.strip()
+ # We use newlines as separators here because msysgit has problems
+ # with NUL characters; see
+ #
+ # https://github.com/mhagger/git-imerge/pull/71
+ a = check_output([
+ 'git', '--no-pager', 'log', '-n1',
+ '--format=%an%n%ae%n%ai', commit
+ ]).strip().splitlines()
+
+ return {
+ 'GIT_AUTHOR_NAME': env_encode(a[0]),
+ 'GIT_AUTHOR_EMAIL': env_encode(a[1]),
+ 'GIT_AUTHOR_DATE': env_encode(a[2]),
+ }
+ def get_log_message(self, 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)
-def get_boundaries(tip1, tip2):
- """Get the boundaries of an incremental merge.
+ def get_commit_parents(self, commit):
+ """Return a list containing the parents of commit."""
- Given the tips of two branches that should be merged, return
- (merge_base, commits1, commits2) describing the edges of the
- imerge. Raise Failure if there are any problems."""
+ return check_output(
+ ['git', '--no-pager', 'log', '--no-walk', '--pretty=format:%P', commit]
+ ).strip().split()
- try:
- merge_base = check_output(['git', 'merge-base', '--all', tip1, tip2]).splitlines()
- except CalledProcessError:
- raise Failure('Cannot compute merge base for %r and %r' % (tip1, tip2))
- if not merge_base:
- raise Failure('%r and %r do not have a common merge base' % (tip1, tip2))
- if len(merge_base) > 1:
- raise Failure('%r and %r do not have a unique merge base' % (tip1, tip2))
-
- [merge_base] = merge_base
-
- ancestry_path1 = set(rev_list('--ancestry-path', '%s..%s' % (merge_base, tip1)))
- commits1 = [
- sha1
- for sha1 in rev_list('--first-parent', '%s..%s' % (merge_base, tip1))
- if sha1 in ancestry_path1
- ]
- commits1.reverse()
- if not commits1:
- raise Failure(
- 'There are no commits on %r that are not already in %r' % (tip1, tip2)
+ def get_tree(self, arg):
+ return self.rev_parse('%s^{tree}' % (arg,))
+
+ def update_ref(self, refname, value, msg, deref=True):
+ if deref:
+ opt = []
+ else:
+ opt = ['--no-deref']
+
+ check_call(['git', 'update-ref'] + opt + ['-m', msg, refname, value])
+
+ def delete_ref(self, refname, msg, deref=True):
+ if deref:
+ opt = []
+ else:
+ opt = ['--no-deref']
+
+ check_call(['git', 'update-ref'] + opt + ['-m', msg, '-d', refname])
+
+ def delete_imerge_refs(self, name):
+ stdin = ''.join(
+ 'delete %s\n' % (refname,)
+ for refname in check_output([
+ 'git', 'for-each-ref',
+ '--format=%(refname)',
+ 'refs/imerge/%s' % (name,)
+ ]).splitlines()
)
- ancestry_path2 = set(rev_list('--ancestry-path', '%s..%s' % (merge_base, tip2)))
- commits2 = [
- sha1
- for sha1 in rev_list('--first-parent', '%s..%s' % (merge_base, tip2))
- if sha1 in ancestry_path2
- ]
- commits2.reverse()
- if not commits2:
- raise Failure(
- 'There are no commits on %r that are not already in %r' % (tip2, tip1)
+ process = subprocess.Popen(
+ [
+ 'git', 'update-ref',
+ '-m', 'imerge: remove merge %r' % (name,),
+ '--stdin',
+ ],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
+ out = communicate(process, input=stdin)[0]
+ retcode = process.poll()
+ if retcode:
+ sys.stderr.write(
+ 'Warning: error removing references:\n%s' % (out,)
+ )
- return (merge_base, commits1, commits2)
+ def detach(self, msg):
+ """Detach HEAD. msg is used for the reflog."""
+ self.update_ref('HEAD', 'HEAD^0', msg, deref=False)
-class TemporaryHead(object):
- """A context manager that records the current HEAD state then restores it.
+ def reset_hard(self, commit):
+ check_call(['git', 'reset', '--hard', commit])
- The message is used for the reflog."""
+ def amend(self):
+ check_call(['git', 'commit', '--amend'])
- def __enter__(self, message='imerge: restoring'):
- self.message = message
+ def abort_merge(self):
+ # We don't use "git merge --abort" here because it was
+ # only added in git version 1.7.4.
+ check_call(['git', 'reset', '--merge'])
+
+ def compute_best_merge_base(self, tip1, tip2):
try:
- self.head_name = check_output(['git', 'symbolic-ref', '--quiet', 'HEAD']).strip()
+ merge_bases = check_output(['git', 'merge-base', '--all', tip1, tip2]).splitlines()
except CalledProcessError:
- self.head_name = None
- self.orig_head = get_commit_sha1('HEAD')
- return self
+ raise Failure('Cannot compute merge base for %r and %r' % (tip1, tip2))
+ if not merge_bases:
+ raise Failure('%r and %r do not have a common merge base' % (tip1, tip2))
+ if len(merge_bases) == 1:
+ return merge_bases[0]
- 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 as e:
- raise Failure(
- 'Could not restore HEAD to %r!: %s\n'
- % (self.head_name, e.message,)
- )
+ # There are multiple merge bases. The "best" one is the one that
+ # is the "closest" to the tips, which we define to be the one with
+ # the fewest non-merge commits in "merge_base..tip". (It can be
+ # shown that the result is independent of which tip is used in the
+ # computation.)
+ best_base = best_count = None
+ for merge_base in merge_bases:
+ cmd = ['git', 'rev-list', '--no-merges', '--count', '%s..%s' % (merge_base, tip1)]
+ count = int(check_output(cmd).strip())
+ if best_base is None or count < best_count:
+ best_base = merge_base
+ best_count = count
+
+ return best_base
+
+ def linear_ancestry(self, commit1, commit2, first_parent):
+ """Compute a linear ancestry between commit1 and commit2.
+
+ Our goal is to find a linear series of commits connecting
+ `commit1` and `commit2`. We do so as follows:
+
+ * If all of the commits in
+
+ git rev-list --ancestry-path commit1..commit2
+
+ are on a linear chain, return that.
+
+ * If there are multiple paths between `commit1` and `commit2` in
+ that list of commits, then
+
+ * If `first_parent` is not set, then raise an
+ `NonlinearAncestryError` exception.
+
+ * If `first_parent` is set, then, at each merge commit, follow
+ the first parent that is in that list of commits.
+
+ Return a list of SHA-1s in 'chronological' order.
+
+ Raise NotFirstParentAncestorError if commit1 is not an ancestor of
+ commit2.
+
+ """
+
+ oid1 = self.rev_parse(commit1)
+ oid2 = self.rev_parse(commit2)
+
+ parentage = {oid1 : []}
+ for (commit, parents) in self.rev_list_with_parents(
+ '--ancestry-path', '--topo-order', '%s..%s' % (oid1, oid2)
+ ):
+ parentage[commit] = parents
+
+ commits = []
+
+ commit = oid2
+ while commit != oid1:
+ parents = parentage.get(commit, [])
+
+ # Only consider parents that are in the ancestry path:
+ included_parents = [
+ parent
+ for parent in parents
+ if parent in parentage
+ ]
+
+ if not included_parents:
+ raise NotFirstParentAncestorError(commit1, commit2)
+ elif len(included_parents) == 1 or first_parent:
+ parent = included_parents[0]
else:
- try:
- check_call(['git', 'reset', '--hard', self.orig_head])
- except Exception as e:
- raise Failure(
- 'Could not restore HEAD to %r!: %s\n'
- % (self.orig_head, e.message,)
- )
- return False
+ raise NonlinearAncestryError(commit1, commit2)
+ commits.append(commit)
+ commit = parent
-def reparent(commit, parent_sha1s, msg=None):
- """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.
-
- If msg is set, then use it as the commit message for the new
- commit."""
-
- 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 + 2:]
-
- 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
+ commits.reverse()
+
+ return commits
+
+ def get_boundaries(self, tip1, tip2, first_parent):
+ """Get the boundaries of an incremental merge.
+
+ Given the tips of two branches that should be merged, return
+ (merge_base, commits1, commits2) describing the edges of the
+ imerge. Raise Failure if there are any problems."""
+
+ merge_base = self.compute_best_merge_base(tip1, tip2)
+
+ commits1 = self.linear_ancestry(merge_base, tip1, first_parent)
+ if not commits1:
+ raise NothingToDoError(tip1, tip2)
+
+ commits2 = self.linear_ancestry(merge_base, tip2, first_parent)
+ if not commits2:
+ raise NothingToDoError(tip2, tip1)
+
+ return (merge_base, commits1, commits2)
+
+ def get_head_refname(self, short=False):
+ """Return the name of the reference that is currently checked out.
+
+ If `short` is set, return it as a branch name. If HEAD is
+ currently detached, return None."""
+
+ cmd = ['git', 'symbolic-ref', '--quiet']
+ if short:
+ cmd += ['--short']
+ cmd += ['HEAD']
+ try:
+ return check_output(cmd).strip()
+ except CalledProcessError:
+ return None
+
+ def restore_head(self, refname, message):
+ check_call(['git', 'symbolic-ref', '-m', message, 'HEAD', refname])
+ check_call(['git', 'reset', '--hard'])
+
+ def checkout(self, refname, quiet=False):
+ cmd = ['git', 'checkout']
+ if quiet:
+ cmd += ['--quiet']
+ if refname.startswith(GitRepository.BRANCH_PREFIX):
+ target = refname[len(GitRepository.BRANCH_PREFIX):]
else:
- new_commit.write(line)
+ target = '%s^0' % (refname,)
+ cmd += [target]
+ check_call(cmd)
- new_commit.write('\n')
- if msg is None:
- new_commit.write(rest)
- else:
- new_commit.write(msg)
- if not msg.endswith('\n'):
- new_commit.write('\n')
+ def commit_tree(self, tree, parents, msg, metadata=None):
+ """Create a commit containing the specified tree.
- process = subprocess.Popen(
- ['git', 'hash-object', '-t', 'commit', '-w', '--stdin'],
- stdin=subprocess.PIPE, stdout=subprocess.PIPE,
- )
- out = communicate(process, input=new_commit.getvalue())[0]
- retcode = process.poll()
- if retcode:
- raise Failure('Could not reparent commit %s' % (commit,))
- return out.strip()
+ metadata can be author or committer information to be added to the
+ environment, as str objects; e.g., {'GIT_AUTHOR_NAME' : 'me'}.
+ Return the SHA-1 of the new commit object."""
-class AutomaticMergeFailed(Exception):
- def __init__(self, commit1, commit2):
- Exception.__init__(
- self, 'Automatic merge of %s and %s failed' % (commit1, commit2,)
+ cmd = ['git', 'commit-tree', tree]
+ for parent in parents:
+ cmd += ['-p', parent]
+
+ if metadata is not None:
+ env = os.environ.copy()
+ env.update(metadata)
+ else:
+ env = os.environ
+
+ process = subprocess.Popen(
+ cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
)
- self.commit1, self.commit2 = commit1, commit2
+ out = communicate(process, input=msg)[0]
+ retcode = process.poll()
+ if retcode:
+ # We don't store the output in the CalledProcessError because
+ # the "output" keyword parameter was not supported in Python
+ # 2.6:
+ raise CalledProcessError(retcode, cmd)
-def automerge(commit1, commit2, msg=None):
- """Attempt an automatic merge of commit1 and commit2.
+ return out.strip()
+
+ def revert(self, commit):
+ """Apply the inverse of commit^..commit to HEAD and commit."""
+
+ cmd = ['git', 'revert', '--no-edit']
+ if len(self.get_commit_parents(commit)) > 1:
+ cmd += ['-m', '1']
+ cmd += [commit]
+ check_call(cmd)
+
+ def reparent(self, commit, parent_sha1s, msg=None):
+ """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.
+
+ If msg is set, then use it as the commit message for the new
+ commit."""
+
+ 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 + 2:]
+
+ 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)
- Return the SHA1 of the resulting commit, or raise
- AutomaticMergeFailed on error. This must be called with a clean
- worktree."""
+ new_commit.write('\n')
+ if msg is None:
+ new_commit.write(rest)
+ else:
+ new_commit.write(msg)
+ if not msg.endswith('\n'):
+ new_commit.write('\n')
- call_silently(['git', 'checkout', '-f', commit1])
- cmd = ['git', '-c', 'rerere.enabled=false', 'merge']
- if msg is not None:
- cmd += ['-m', msg]
- cmd += [commit2]
- try:
- call_silently(cmd)
- except CalledProcessError:
- # We don't use "git merge --abort" here because it was only
- # added in git version 1.7.4.
- call_silently(['git', 'reset', '--merge'])
- raise AutomaticMergeFailed(commit1, commit2)
- else:
- return get_commit_sha1('HEAD')
+ process = subprocess.Popen(
+ ['git', 'hash-object', '-t', 'commit', '-w', '--stdin'],
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ )
+ out = communicate(process, input=new_commit.getvalue())[0]
+ retcode = process.poll()
+ if retcode:
+ raise Failure('Could not reparent commit %s' % (commit,))
+ return out.strip()
+
+ def temporary_head(self, message):
+ """Return a context manager to manage a temporary HEAD.
+
+ On entry, record the current HEAD state. On exit, restore it.
+ message is used for the reflog.
+
+ """
+
+ return GitTemporaryHead(self, message)
class MergeRecord(object):
@@ -668,23 +1247,21 @@ class MergeRecord(object):
def is_manual(self):
return self.flags & self.MANUAL != 0
- def save(self, name, i1, i2):
+ def save(self, git, 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,
- ])
+ git.update_ref(
+ 'refs/imerge/%s/%s/%d-%d' % (name, source, i1, i2),
+ self.sha1,
+ 'imerge %r: Record %s merge' % (name, source,),
+ )
def clear_ref(source):
- check_call([
- 'git', 'update-ref',
- '-m', 'imerge %r: Remove obsolete %s merge' % (name, source,),
- '-d', 'refs/imerge/%s/%s/%d-%d' % (name, source, i1, i2),
- ])
+ git.delete_ref(
+ 'refs/imerge/%s/%s/%d-%d' % (name, source, i1, i2),
+ 'imerge %r: Remove obsolete %s merge' % (name, source,),
+ )
if self.flags & self.MANUAL:
if self.flags & self.AUTO:
@@ -738,6 +1315,148 @@ class NotABlockingCommitError(Exception):
pass
+def find_frontier_blocks(block):
+ """Iterate over the frontier blocks for the specified block.
+
+ Use bisection to find the blocks. Iterate over the blocks starting
+ in the bottom left and ending at the top right. Record in block
+ any blockers that we find.
+
+ We make the following assumptions (using Python subscript
+ notation):
+
+ 0. All of the merges in block[1:,0] and block[0,1:] are
+ already known. (This is an invariant of the Block class.)
+
+ 1. If a direct merge can be done between block[i1-1,0] and
+ block[0,i2-1], then all of the pairwise merges in
+ block[1:i1, 1:i2] can also be done.
+
+ 2. If a direct merge fails between block[i1-1,0] and
+ block[0,i2-1], then all of the pairwise merges in
+ block[i1-1:,i2-1:] would also fail.
+
+ Under these assumptions, the merge frontier is a stepstair
+ pattern going from the bottom-left to the top-right, and
+ bisection can be used to find the transition between mergeable
+ and conflicting in any row or column.
+
+ Of course these assumptions are not rigorously true, so the
+ MergeFrontier returned by this function is only an
+ approximation of the real merge diagram. We check for and
+ correct such inconsistencies later.
+
+ """
+
+ # Given that these diagrams typically have few blocks, check
+ # the end of a range first to see if the whole range can be
+ # determined, and fall back to bisection otherwise. We
+ # determine the frontier block by block, starting in the lower
+ # left.
+
+ if block.len1 <= 1 or block.len2 <= 1 or block.is_blocked(1, 1):
+ return
+
+ if block.is_mergeable(block.len1 - 1, block.len2 - 1):
+ # The whole block is mergable!
+ yield block
+ return
+
+ if not block.is_mergeable(1, 1):
+ # There are no mergeable blocks in block; therefore,
+ # block[1,1] must itself be unmergeable. Record that
+ # fact:
+ block[1, 1].record_blocked(True)
+ return
+
+ # At this point, we know that there is at least one mergeable
+ # commit in the first column. Find the height of the success
+ # block in column 1:
+ i1 = 1
+ i2 = find_first_false(
+ lambda i: block.is_mergeable(i1, i),
+ 2, block.len2,
+ )
+
+ # Now we know that (1,i2-1) is mergeable but (1,i2) is not;
+ # e.g., (i1, i2) is like 'A' (or maybe 'B') in the following
+ # diagram (where '*' means mergeable, 'x' means not mergeable,
+ # and '?' means indeterminate) and that the merge under 'A' is
+ # not mergeable:
+ #
+ # i1
+ #
+ # 0123456
+ # 0 *******
+ # 1 **?????
+ # i2 2 **?????
+ # 3 **?????
+ # 4 *Axxxxx
+ # 5 *xxxxxx
+ # B
+
+ while True:
+ if i2 == 1:
+ break
+
+ # At this point in the loop, we know that any blocks to
+ # the left of 'A' have already been recorded, (i1, i2-1)
+ # is mergeable but (i1, i2) is not; e.g., we are at a
+ # place like 'A' in the following diagram:
+ #
+ # i1
+ #
+ # 0123456
+ # 0 **|****
+ # 1 **|*???
+ # i2 2 **|*???
+ # 3 **|Axxx
+ # 4 --+xxxx
+ # 5 *xxxxxx
+ #
+ # This implies that (i1, i2) is the first unmergeable
+ # commit in a blocker block (though blocker blocks are not
+ # recorded explicitly). It also implies that a mergeable
+ # block has its last mergeable commit somewhere in row
+ # i2-1; find its width.
+ if (
+ i1 == block.len1 - 1
+ or block.is_mergeable(block.len1 - 1, i2 - 1)
+ ):
+ yield block[:block.len1, :i2]
+ break
+ else:
+ i1 = find_first_false(
+ lambda i: block.is_mergeable(i, i2 - 1),
+ i1 + 1, block.len1 - 1,
+ )
+ yield block[:i1, :i2]
+
+ # At this point in the loop, (i1-1, i2-1) is mergeable but
+ # (i1, i2-1) is not; e.g., 'A' in the following diagram:
+ #
+ # i1
+ #
+ # 0123456
+ # 0 **|*|**
+ # 1 **|*|??
+ # i2 2 --+-+xx
+ # 3 **|xxAx
+ # 4 --+xxxx
+ # 5 *xxxxxx
+ #
+ # The block ending at (i1-1,i2-1) has just been recorded.
+ # Now find the height of the conflict rectangle at column
+ # i1 and fill it in:
+ if i2 - 1 == 1 or not block.is_mergeable(i1, 1):
+ break
+ else:
+ i2 = find_first_false(
+ lambda i: block.is_mergeable(i1, i),
+ 2, i2 - 1,
+ )
+
+
class MergeFrontier(object):
"""Represents the merge frontier within a Block.
@@ -792,7 +1511,7 @@ class MergeFrontier(object):
def create_frontier(path):
blocks = []
for ((i1old, i2old, downold), (i1new, i2new, downnew)) in iter_neighbors(path):
- if downold == True and downnew == False:
+ if downold is True and downnew is False:
blocks.append(block[:i1new + 1, :i2new + 1])
return MergeFrontier(block, blocks)
@@ -847,142 +1566,16 @@ class MergeFrontier(object):
def compute_by_bisection(block):
"""Return a MergeFrontier instance for block.
- We make the following assumptions (using Python subscript
- notation):
-
- 0. All of the merges in block[1:,0] and block[0,1:] are
- already known. (This is an invariant of the Block class.)
-
- 1. If a direct merge can be done between block[i1-1,0] and
- block[0,i2-1], then all of the pairwise merges in
- block[1:i1, 1:i2] can also be done.
-
- 2. If a direct merge fails between block[i1-1,0] and
- block[0,i2-1], then all of the pairwise merges in
- block[i1-1:,i2-1:] would also fail.
-
- Under these assumptions, the merge frontier is a stepstair
- pattern going from the bottom-left to the top-right, and
- bisection can be used to find the transition between mergeable
- and conflicting in any row or column.
-
- Of course these assumptions are not rigorously true, so the
- MergeFrontier returned by this function is only an
- approximation of the real merge diagram. We check for and
- correct such inconsistencies later."""
-
- # Given that these diagrams typically have few blocks, check
- # the end of a range first to see if the whole range can be
- # determined, and fall back to bisection otherwise. We
- # determine the frontier block by block, starting in the lower
- # left.
-
- if block.len1 <= 1 or block.len2 <= 1 or block.is_blocked(1, 1):
- return MergeFrontier(block, [])
-
- if not block.is_mergeable(1, 1):
- # There are no mergeable blocks in block; therefore,
- # block[1,1] must itself be unmergeable. Record that
- # fact:
- block[1,1].record_blocked(True)
- return MergeFrontier(block, [])
-
- blocks = []
-
- # At this point, we know that there is at least one mergeable
- # commit in the first column. Find the height of the success
- # block in column 1:
- i1 = 1
- i2 = find_first_false(
- lambda i: block.is_mergeable(i1, i),
- 2, block.len2,
- )
-
- # Now we know that (1,i2-1) is mergeable but (1,i2) is not;
- # e.g., (i1, i2) is like 'A' (or maybe 'B') in the following
- # diagram (where '*' means mergeable, 'x' means not mergeable,
- # and '?' means indeterminate) and that the merge under 'A' is
- # not mergeable:
- #
- # i1
- #
- # 0123456
- # 0 *******
- # 1 **?????
- # i2 2 **?????
- # 3 **?????
- # 4 *Axxxxx
- # 5 *xxxxxx
- # B
+ Compute the blocks making up the boundary using bisection. See
+ find_frontier_blocks() for more information.
- while True:
- if i2 == 1:
- break
-
- # At this point in the loop, we know that any blocks to
- # the left of 'A' have already been recorded, (i1, i2-1)
- # is mergeable but (i1, i2) is not; e.g., we are at a
- # place like 'A' in the following diagram:
- #
- # i1
- #
- # 0123456
- # 0 **|****
- # 1 **|*???
- # i2 2 **|*???
- # 3 **|Axxx
- # 4 --+xxxx
- # 5 *xxxxxx
- #
- # This implies that (i1, i2) is the first unmergeable
- # commit in a blocker block (though blocker blocks are not
- # recorded explicitly). It also implies that a mergeable
- # block has its last mergeable commit somewhere in row
- # i2-1; find its width.
- if (
- i1 == block.len1 - 1
- or block.is_mergeable(block.len1 - 1, i2 - 1)
- ):
- blocks.append(block[:block.len1,:i2])
- break
- else:
- i1 = find_first_false(
- lambda i: block.is_mergeable(i, i2 - 1),
- i1 + 1, block.len1 - 1,
- )
- blocks.append(block[:i1,:i2])
-
- # At this point in the loop, (i1-1, i2-1) is mergeable but
- # (i1, i2-1) is not; e.g., 'A' in the following diagram:
- #
- # i1
- #
- # 0123456
- # 0 **|*|**
- # 1 **|*|??
- # i2 2 --+-+xx
- # 3 **|xxAx
- # 4 --+xxxx
- # 5 *xxxxxx
- #
- # The block ending at (i1-1,i2-1) has just been recorded.
- # Now find the height of the conflict rectangle at column
- # i1 and fill it in:
- if i2 - 1 == 1 or not block.is_mergeable(i1, 1):
- break
- else:
- i2 = find_first_false(
- lambda i: block.is_mergeable(i1, i),
- 2, i2 - 1,
- )
+ """
- return MergeFrontier(block, blocks)
+ return MergeFrontier(block, list(find_frontier_blocks(block)))
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))
+ self.blocks = self._normalized_blocks(blocks or [])
def __iter__(self):
"""Iterate over blocks from bottom left to upper right."""
@@ -1073,12 +1666,12 @@ class MergeFrontier(object):
for i2 in range(block.len2):
v = self.FRONTIER_WITHIN
if i1 == block.len1 - 1 and (
- next_block is None or i2 >= next_block.len2
- ):
+ next_block is None or i2 >= next_block.len2
+ ):
v |= self.FRONTIER_RIGHT_EDGE
if i2 == block.len2 - 1 and (
- prev_block is None or i1 >= prev_block.len1
- ):
+ prev_block is None or i1 >= prev_block.len1
+ ):
v |= self.FRONTIER_BOTTOM_EDGE
diagram[i1][i2] |= v
prev_block = block
@@ -1180,34 +1773,43 @@ class MergeFrontier(object):
f.write('</table>\n</body>\n</html>\n')
@staticmethod
- def _iter_non_empty_blocks(blocks):
- for block in blocks:
- if block.len1 > 1 and block.len2 > 1:
- yield block
+ def _normalized_blocks(blocks):
+ """Return a normalized list of blocks from the argument.
+
+ * Remove empty blocks.
+
+ * Remove redundant blocks.
+
+ * Sort the blocks according to their len1 members.
+
+ """
- @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 = next(i)
- except StopIteration:
- return
+ blocks = sorted(blocks, key=lambda block: block.len1)
+ ret = []
- for block in i:
- if contains(last, block):
- pass
- elif contains(block, last):
- last = block
- else:
- yield last
- last = block
+ for block in blocks:
+ if block.len1 == 0 or block.len2 == 0:
+ continue
+ while True:
+ if not ret:
+ ret.append(block)
+ break
+
+ last = ret[-1]
+ if contains(last, block):
+ break
+ elif contains(block, last):
+ ret.pop()
+ else:
+ ret.append(block)
+ break
- yield last
+ return ret
def remove_failure(self, i1, i2):
"""Refine the merge frontier given that the specified merge fails."""
@@ -1226,7 +1828,7 @@ class MergeFrontier(object):
newblocks.append(block)
if shrunk_block:
- self.blocks = list(self._iter_non_redundant_blocks(newblocks))
+ self.blocks = self._normalized_blocks(newblocks)
def partition(self, block):
"""Return two MergeFrontier instances partitioned by block.
@@ -1271,10 +1873,10 @@ class MergeFrontier(object):
blockruns = []
if self.blocks[0].len2 < self.block.len2:
- blockruns.append([self.block[0,:]])
+ blockruns.append([self.block[0, :]])
blockruns.append(self)
if self.blocks[-1].len1 < self.block.len1:
- blockruns.append([self.block[:,0]])
+ 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]
@@ -1290,7 +1892,7 @@ class MergeFrontier(object):
except IndexError:
pass
else:
- if (block_i1, block_i2) == (1,1):
+ if (block_i1, block_i2) == (1, 1):
# That's the one we need to improve this block:
return block
else:
@@ -1370,7 +1972,8 @@ class Block(object):
"""
- def __init__(self, name, len1, len2):
+ def __init__(self, git, name, len1, len2):
+ self.git = git
self.name = name
self.len1 = len1
self.len2 = len2
@@ -1474,7 +2077,7 @@ class Block(object):
'Attempting automerge of %d-%d...' % self.get_original_indexes(i1, i2)
)
try:
- automerge(self[i1, 0].sha1, self[0, i2].sha1)
+ self.git.automerge(self[i1, 0].sha1, self[0, i2].sha1)
sys.stderr.write('success.\n')
return True
except AutomaticMergeFailed:
@@ -1492,12 +2095,12 @@ class Block(object):
def do_merge(i1, commit1, i2, commit2, msg='Autofilling %d-%d...', record=True):
if (i1, i2) in self:
- return self[i1,i2].sha1
+ return self[i1, i2].sha1
(i1orig, i2orig) = self.get_original_indexes(i1, i2)
sys.stderr.write(msg % (i1orig, i2orig))
logmsg = 'imerge \'%s\': automatic merge %d-%d' % (self.name, i1orig, i2orig)
try:
- merge = automerge(commit1, commit2, msg=logmsg)
+ merge = self.git.automerge(commit1, commit2, msg=logmsg)
sys.stderr.write('success.\n')
except AutomaticMergeFailed as e:
sys.stderr.write('unexpected conflict. Backtracking...\n')
@@ -1509,12 +2112,12 @@ class Block(object):
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)
+ 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)
+ above = do_merge(i1, above, i2, self[0, i2].sha1)
i1, i2 = self.len1 - 1, self.len2 - 1
if i1 > 1 and i2 > 1:
@@ -1523,23 +2126,25 @@ class Block(object):
# of the right edge. We only accept it if both approaches
# succeed and give identical trees.
vertex_v1 = do_merge(
- i1, self[i1,0].sha1, i2, left,
+ i1, self[i1, 0].sha1, i2, left,
msg='Autofilling %d-%d (first way)...',
record=False,
)
vertex_v2 = do_merge(
- i1, above, i2, self[0,i2].sha1,
+ i1, above, i2, self[0, i2].sha1,
msg='Autofilling %d-%d (second way)...',
record=False,
)
- if get_tree(vertex_v1) == get_tree(vertex_v2):
+ if self.git.get_tree(vertex_v1) == self.git.get_tree(vertex_v2):
sys.stderr.write(
'The two ways of autofilling %d-%d agree.\n'
% self.get_original_indexes(i1, i2)
)
# Everything is OK. Now reparent the actual vertex merge to
# have above and left as its parents:
- merges.append((i1, i2, reparent(vertex_v1, [above, left])))
+ merges.append(
+ (i1, i2, self.git.reparent(vertex_v1, [above, left]))
+ )
else:
sys.stderr.write(
'The two ways of autofilling %d-%d do not agree. Backtracking...\n'
@@ -1571,7 +2176,7 @@ class Block(object):
sys.stderr.write('Attempting to merge %d-%d...' % (i1orig, i2orig))
logmsg = 'imerge \'%s\': automatic merge %d-%d' % (self.name, i1orig, i2orig)
try:
- merge = automerge(
+ merge = self.git.automerge(
self[i1, i2 - 1].sha1,
self[i1 - 1, i2].sha1,
msg=logmsg,
@@ -1579,7 +2184,7 @@ class Block(object):
sys.stderr.write('success.\n')
except AutomaticMergeFailed:
sys.stderr.write('conflict.\n')
- self[i1,i2].record_blocked(True)
+ self[i1, i2].record_blocked(True)
return False
else:
self[i1, i2].record_merge(merge, MergeRecord.NEW_AUTO)
@@ -1633,12 +2238,12 @@ class Block(object):
# A map {(is_known(), manual, is_blocked()) : integer constant}
MergeState = {
- (False, False, False) : MERGE_UNKNOWN,
- (False, False, True) : MERGE_BLOCKED,
- (True, False, True) : MERGE_UNBLOCKED,
- (True, True, True) : MERGE_UNBLOCKED,
- (True, False, False) : MERGE_AUTOMATIC,
- (True, True, False) : MERGE_MANUAL,
+ (False, False, False): MERGE_UNKNOWN,
+ (False, False, True): MERGE_BLOCKED,
+ (True, False, True): MERGE_UNBLOCKED,
+ (True, True, True): MERGE_UNBLOCKED,
+ (True, False, False): MERGE_AUTOMATIC,
+ (True, True, False): MERGE_MANUAL,
}
def create_diagram(self):
@@ -1710,7 +2315,7 @@ class SubBlock(Block):
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, block.name, len1, len2)
+ Block.__init__(self, block.git, block.name, len1, len2)
if isinstance(block, SubBlock):
# Peel away one level of indirection:
self._merge_state = block._merge_state
@@ -1735,9 +2340,9 @@ class SubBlock(Block):
def convert_original_indexes(self, i1, i2):
(i1, i2) = self._merge_state.convert_original_indexes(i1, i2)
if not (
- self._start1 <= i1 < self._start1 + self.len1
- and self._start2 <= i2 < self._start2 + self.len2
- ):
+ 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)
@@ -1761,111 +2366,29 @@ class SubBlock(Block):
)
+class MissingMergeFailure(Failure):
+ def __init__(self, i1, i2):
+ Failure.__init__(self, 'Merge %d-%d is not yet done' % (i1, i2))
+ self.i1 = i1
+ self.i2 = i2
+
+
class MergeState(Block):
SOURCE_TABLE = {
- 'auto' : MergeRecord.SAVED_AUTO,
- 'manual' : MergeRecord.SAVED_MANUAL,
+ '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(int(i) for i in state['version'].split('.'))
- if version[0] != STATE_VERSION[0] or version[1] > STATE_VERSION[1]:
- 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 as 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):
+ def _check_no_merges(git, commits):
multiparent_commits = [
commit
for commit in commits
- if len(get_commit_parents(commit)) > 1
+ if len(git.get_commit_parents(commit)) > 1
]
if multiparent_commits:
raise Failure(
@@ -1876,118 +2399,47 @@ class MergeState(Block):
)
@staticmethod
- def check_name_format(name):
- """Check that name is a valid imerge 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,))
-
- @staticmethod
def initialize(
- name, merge_base,
- tip1, commits1,
- tip2, commits2,
- goal=DEFAULT_GOAL, manual=False, branch=None,
- ):
+ git, name, merge_base,
+ tip1, commits1,
+ tip2, commits2,
+ goal=DEFAULT_GOAL, goalopts=None,
+ manual=False, branch=None,
+ ):
"""Create and return a new MergeState object."""
- MergeState.check_name_format(name)
+ git.verify_imerge_name_available(name)
if branch:
- check_branch_name_format(branch)
+ git.check_branch_name_format(branch)
else:
branch = name
- if check_output(['git', 'for-each-ref', 'refs/imerge/%s' % (name,)]):
- raise Failure('Name %r is already in use!' % (name,))
-
if goal == 'rebase':
- MergeState._check_no_merges(commits2)
+ MergeState._check_no_merges(git, commits2)
return MergeState(
- name, merge_base,
+ git, name, merge_base,
tip1, commits1,
tip2, commits2,
MergeRecord.NEW_MANUAL,
- goal=goal,
+ goal=goal, goalopts=goalopts,
manual=manual,
branch=branch,
)
@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,
- )
+ def read(git, name):
+ (state, merges) = git.read_imerge_state(name)
- 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,)
- )
+ # Translate sources from strings into MergeRecord constants
+ # SAVED_AUTO or SAVED_MANUAL:
+ merges = dict((
+ ((i1, i2), (sha1, MergeState.SOURCE_TABLE[source]))
+ for ((i1, i2), (sha1, source)) in merges.items()
+ ))
blockers = state.get('blockers', [])
- 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:
@@ -2019,15 +2471,17 @@ class MergeState(Block):
if goal not in ALLOWED_GOALS:
raise Failure('Goal %r, read from state, is not recognized.' % (goal,))
+ goalopts = state['goalopts']
+
manual = state['manual']
branch = state.get('branch', name)
state = MergeState(
- name, merge_base,
+ git, name, merge_base,
tip1, commits1,
tip2, commits2,
MergeRecord.SAVED_MANUAL,
- goal=goal,
+ goal=goal, goalopts=goalopts,
manual=manual,
branch=branch,
)
@@ -2046,74 +2500,50 @@ class MergeState(Block):
state[i1, i2].record_merge(sha1, source)
# Record any blockers:
- for (i1,i2) in blockers:
+ for (i1, i2) in blockers:
state[i1, i2].record_blocked(True)
return state
@staticmethod
- def remove(name):
+ def remove(git, 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:
+ if git.get_head_refname() == scratch_refname:
try:
- # We don't use "git merge --abort" here because it was
- # only added in git version 1.7.4.
- check_call(['git', 'reset', '--merge'])
+ git.abort_merge()
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'),
- ])
+ git.detach('Detach HEAD from %s' % (scratch_refname,))
# Delete the scratch refname:
- check_call([
- 'git', 'update-ref',
- '-m', 'imerge %s: remove scratch reference' % (name,),
- '-d', scratch_refname,
- ])
+ git.delete_ref(
+ scratch_refname, 'imerge %s: remove scratch reference' % (name,),
+ )
# 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 as e:
- sys.stderr.write(
- 'Warning: error removing reference %r: %s' % (refname, e)
- )
+ git.delete_imerge_refs(name)
# If this merge was the default, unset the default:
- if MergeState.get_default_name() == name:
- MergeState.set_default_name(None)
+ if git.get_default_imerge_name() == name:
+ git.set_default_imerge_name(None)
def __init__(
- self, name, merge_base,
- tip1, commits1,
- tip2, commits2,
- source,
- goal=DEFAULT_GOAL,
- manual=False,
- branch=None,
- ):
- Block.__init__(self, name, len(commits1) + 1, len(commits2) + 1)
+ self, git, name, merge_base,
+ tip1, commits1,
+ tip2, commits2,
+ source,
+ goal=DEFAULT_GOAL, goalopts=None,
+ manual=False,
+ branch=None,
+ ):
+ Block.__init__(self, git, name, len(commits1) + 1, len(commits2) + 1)
self.tip1 = tip1
self.tip2 = tip2
self.goal = goal
+ self.goalopts = goalopts
self.manual = bool(manual)
self.branch = branch or name
@@ -2135,7 +2565,8 @@ class MergeState(Block):
if goal == 'rebase':
self._check_no_merges(
- [self[0,i2].sha1 for i2 in range(1,self.len2)]
+ self.git,
+ [self[0, i2].sha1 for i2 in range(1, self.len2)],
)
self.goal = goal
@@ -2193,11 +2624,52 @@ class MergeState(Block):
for i2 in range(0, self.len2):
for i1 in range(0, self.len1):
if (i1, i2) in self:
- record = self[i1,i2]
+ record = self[i1, i2]
if record.sha1 == commit:
return (i1, i2)
raise CommitNotFoundError(commit)
+ def request_user_merge(self, i1, i2):
+ """Prepare the working tree for the user to do a manual merge.
+
+ It is assumed that the merges above and to the left of (i1, i2)
+ are already done."""
+
+ above = self[i1, i2 - 1]
+ left = self[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(self.name)
+ self.git.update_ref(
+ refname, above.sha1,
+ 'imerge %r: Prepare merge %d-%d' % (self.name, i1, i2,),
+ )
+ self.git.checkout(refname)
+ logmsg = 'imerge \'%s\': manual merge %d-%d' % (self.name, i1, i2)
+ try:
+ self.git.manualmerge(left.sha1, logmsg)
+ except CalledProcessError:
+ # We expect an error (otherwise we would have automerged!)
+ pass
+ sys.stderr.write(
+ '\n'
+ 'Original first commit:\n'
+ )
+ self.git.summarize_commit(self[i1, 0].sha1)
+ sys.stderr.write(
+ '\n'
+ 'Original second commit:\n'
+ )
+ self.git.summarize_commit(self[0, i2].sha1)
+ sys.stderr.write(
+ '\n'
+ 'There was a conflict merging commit %d-%d, shown above.\n'
+ 'Please resolve the conflict, commit the result, then type\n'
+ '\n'
+ ' git-imerge continue\n'
+ % (i1, i2)
+ )
+
def incorporate_manual_merge(self, commit):
"""Record commit as a manual merge of its parents.
@@ -2205,7 +2677,7 @@ class MergeState(Block):
commit is not usable for some reason, raise
ManualMergeUnusableError."""
- parents = get_commit_parents(commit)
+ parents = self.git.get_commit_parents(commit)
if len(parents) < 2:
raise ManualMergeUnusableError('it is not a merge', commit)
if len(parents) > 2:
@@ -2229,58 +2701,135 @@ class MergeState(Block):
)
if swapped:
# Create a new merge with the parents in the conventional order:
- commit = reparent(commit, [parents[1], parents[0]])
+ commit = self.git.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."""
+ def incorporate_user_merge(self, edit_log_msg=None):
+ """If the user has done a merge for us, incorporate the results.
- if commit1 == commit2:
- return True
- else:
- return int(
- check_output([
- 'git', 'rev-list', '--count', '--ancestry-path',
- '%s..%s' % (commit1, commit2,),
- ]).strip()
- ) != 0
+ If the scratch reference refs/heads/imerge/NAME exists and is
+ checked out, first check if there are staged changes that can
+ be committed. Then try to incorporate the current commit into
+ this MergeState, delete the reference, and return (i1,i2)
+ corresponding to the merge. If the scratch reference does not
+ exist, raise NoManualMergeError(). If the scratch reference
+ exists but cannot be used, raise a ManualMergeUnusableError.
+ If there are unstaged changes in the working tree, emit an
+ error message and raise UncleanWorkTreeError.
+
+ """
+
+ refname = MergeState.get_scratch_refname(self.name)
- @staticmethod
- def _set_refname(refname, commit, force=False):
try:
- ref_oldval = get_commit_sha1(refname)
+ commit = self.git.get_commit_sha1(refname)
+ except ValueError:
+ raise NoManualMergeError('Reference %s does not exist.' % (refname,))
+
+ head_name = self.git.get_head_refname()
+ if head_name is None:
+ raise NoManualMergeError('HEAD is currently detached.')
+ elif head_name != refname:
+ # This should not usually happen. The scratch reference
+ # exists, but it is not current. Perhaps the user gave up on
+ # an attempted merge then switched to another branch. We want
+ # to delete refname, but only if it doesn't contain any
+ # content that we don't already know.
+ try:
+ self.find_index(commit)
+ except CommitNotFoundError:
+ # It points to a commit that we don't have in our records.
+ raise Failure(
+ 'The scratch reference, %(refname)s, already exists but is not\n'
+ 'checked out. If it points to a merge commit that you would like\n'
+ 'to use, please check it out using\n'
+ '\n'
+ ' git checkout %(refname)s\n'
+ '\n'
+ 'and then try to continue again. If it points to a commit that is\n'
+ 'unneeded, then please delete the reference using\n'
+ '\n'
+ ' git update-ref -d %(refname)s\n'
+ '\n'
+ 'and then continue.'
+ % dict(refname=refname)
+ )
+ else:
+ # It points to a commit that is already recorded. We can
+ # delete it without losing any information.
+ self.git.delete_ref(
+ refname,
+ 'imerge %r: Remove obsolete scratch reference' % (self.name,),
+ )
+ sys.stderr.write(
+ '%s did not point to a new merge; it has been deleted.\n'
+ % (refname,)
+ )
+ raise NoManualMergeError(
+ 'Reference %s was not checked out.' % (refname,)
+ )
+
+ # If we reach this point, then the scratch reference exists and is
+ # checked out. Now check whether there is staged content that
+ # can be committed:
+ if self.git.commit_user_merge(edit_log_msg=edit_log_msg):
+ commit = self.git.get_commit_sha1('HEAD')
+
+ self.git.require_clean_work_tree('proceed')
+
+ merge_frontier = MergeFrontier.map_known_frontier(self)
+
+ # This might throw ManualMergeUnusableError:
+ (i1, i2) = self.incorporate_manual_merge(commit)
+
+ # Now detach head so that we can delete refname.
+ self.git.detach('Detach HEAD from %s' % (refname,))
+
+ self.git.delete_ref(
+ refname, 'imerge %s: remove scratch reference' % (self.name,),
+ )
+
+ try:
+ # This might throw NotABlockingCommitError:
+ unblocked_block = merge_frontier.get_affected_blocker_block(i1, i2)
+ unblocked_block[1, 1].record_blocked(False)
+ sys.stderr.write(
+ 'Merge has been recorded for merge %d-%d.\n'
+ % unblocked_block.get_original_indexes(1, 1)
+ )
+ except NotABlockingCommitError:
+ raise
+ finally:
+ self.save()
+
+ def _set_refname(self, refname, commit, force=False):
+ try:
+ ref_oldval = self.git.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)
+ self.git.update_ref(refname, commit, 'imerge: recording final merge')
+ self.git.checkout(refname, quiet=True)
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
+ head_refname = self.git.get_head_refname()
- if not force:
- if not MergeState._is_ancestor(ref_oldval, commit):
- raise Failure(
- '%s cannot be fast-forwarded to %s!' % (refname, commit)
- )
+ if not force and not self.git.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])
+ self.git.reset_hard(commit)
else:
- check_call([
- 'git', 'update-ref',
- '-m', 'imerge: recording final merge',
- refname, commit,
- ])
- checkout(refname)
+ self.git.update_ref(
+ refname, commit, 'imerge: recording final merge',
+ )
+ self.git.checkout(refname, quiet=True)
def simplify_to_full(self, refname, force=False):
for i1 in range(1, self.len1):
@@ -2307,57 +2856,181 @@ class MergeState(Block):
commit = self[i1, 0].sha1
for i2 in range(1, self.len2):
orig = self[0, i2].sha1
- tree = get_tree(self[i1, i2].sha1)
+ tree = self.git.get_tree(self[i1, i2].sha1)
# Create a commit, copying the old log message:
msg = (
- get_log_message(orig).rstrip('\n')
+ self.git.get_log_message(orig).rstrip('\n')
+ '\n\n(rebased-with-history from commit %s)\n' % orig
)
- commit = commit_tree(tree, [commit, orig], msg=msg)
+ commit = self.git.commit_tree(tree, [commit, orig], msg=msg)
self._set_refname(refname, commit, force=force)
- def simplify_to_rebase(self, refname, force=False):
+ def simplify_to_border(
+ self, refname,
+ with_history1=False, with_history2=False, 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'
+ 'Cannot simplify to border because '
+ 'merge %d-%d is not yet done'
% (i1, i2)
)
- if not force:
- # A rebase simplification is allowed to discard history,
- # as long as the *pre-simplification* apex commit is a
- # descendant of the branch to be moved.
- try:
- ref_oldval = get_commit_sha1(refname)
- except ValueError:
- # refname doesn't already exist; no problem.
- pass
- else:
- commit = self[-1, -1].sha1
- if not MergeState._is_ancestor(ref_oldval, commit):
- raise Failure(
- '%s is not an ancestor of %s; use --force if you are sure'
- % (commit, refname,)
- )
+ i2 = self.len2 - 1
+ for i1 in range(1, self.len1):
+ if not (i1, i2) in self:
+ raise Failure(
+ 'Cannot simplify to border because '
+ 'merge %d-%d is not yet done'
+ % (i1, i2)
+ )
+ i1 = self.len1 - 1
commit = self[i1, 0].sha1
- for i2 in range(1, self.len2):
+ for i2 in range(1, self.len2 - 1):
orig = self[0, i2].sha1
- tree = get_tree(self[i1, i2].sha1)
- authordata = get_author_info(orig)
+ tree = self.git.get_tree(self[i1, i2].sha1)
+
+ # Create a commit, copying the old log message:
+ if with_history2:
+ parents = [commit, orig]
+ msg = (
+ self.git.get_log_message(orig).rstrip('\n')
+ + '\n\n(rebased-with-history from commit %s)\n' % (orig,)
+ )
+ else:
+ parents = [commit]
+ msg = (
+ self.git.get_log_message(orig).rstrip('\n')
+ + '\n\n(rebased from commit %s)\n' % (orig,)
+ )
+
+ commit = self.git.commit_tree(tree, parents, msg=msg)
+ commit1 = commit
+
+ i2 = self.len2 - 1
+ commit = self[0, i2].sha1
+ for i1 in range(1, self.len1 - 1):
+ orig = self[i1, 0].sha1
+ tree = self.git.get_tree(self[i1, i2].sha1)
- # Create a commit, copying the old log message and author info:
- commit = commit_tree(
- tree, [commit], msg=get_log_message(orig), metadata=authordata,
+ # Create a commit, copying the old log message:
+ if with_history1:
+ parents = [orig, commit]
+ msg = (
+ self.git.get_log_message(orig).rstrip('\n')
+ + '\n\n(rebased-with-history from commit %s)\n' % (orig,)
+ )
+ else:
+ parents = [commit]
+ msg = (
+ self.git.get_log_message(orig).rstrip('\n')
+ + '\n\n(rebased from commit %s)\n' % (orig,)
+ )
+
+ commit = self.git.commit_tree(tree, parents, msg=msg)
+ commit2 = commit
+
+ # Construct the apex commit:
+ tree = self.git.get_tree(self[-1, -1].sha1)
+ msg = (
+ 'Merge %s into %s (using imerge border)'
+ % (self.tip2, self.tip1)
+ )
+
+ commit = self.git.commit_tree(tree, [commit1, commit2], msg=msg)
+
+ # Update the reference:
+ self._set_refname(refname, commit, force=force)
+
+ def _simplify_to_path(self, refname, base, path, force=False):
+ """Simplify based on path and set refname to the result.
+
+ The base and path arguments are defined similarly to
+ create_commit_chain(), except that instead of SHA-1s they may
+ optionally represent commits via (i1, i2) tuples.
+
+ """
+
+ def to_sha1(arg):
+ if type(arg) is tuple:
+ commit_record = self[arg]
+ if not commit_record.is_known():
+ raise MissingMergeFailure(*arg)
+ return commit_record.sha1
+ else:
+ return arg
+
+ base_sha1 = to_sha1(base)
+ path_sha1 = []
+ for (commit, metadata) in path:
+ commit_sha1 = to_sha1(commit)
+ metadata_sha1 = to_sha1(metadata)
+ path_sha1.append((commit_sha1, metadata_sha1))
+
+ # A path simplification is allowed to discard history, as long
+ # as the *pre-simplification* apex commit is a descendant of
+ # the branch to be moved.
+ if path:
+ apex = path_sha1[-1][0]
+ else:
+ apex = base_sha1
+
+ if not force and not self.git.is_ff(refname, apex):
+ raise Failure(
+ '%s cannot be updated to %s without discarding history.\n'
+ 'Use --force if you are sure, or choose a different reference'
+ % (refname, apex,)
+ )
+
+ # The update is OK, so here we can set force=True:
+ self._set_refname(
+ refname,
+ self.git.create_commit_chain(base_sha1, path_sha1),
+ force=True,
+ )
+
+ def simplify_to_rebase(self, refname, force=False):
+ i1 = self.len1 - 1
+ path = [
+ ((i1, i2), (0, i2))
+ for i2 in range(1, self.len2)
+ ]
+
+ try:
+ self._simplify_to_path(refname, (i1, 0), path, force=force)
+ except MissingMergeFailure as e:
+ raise Failure(
+ 'Cannot simplify to %s because merge %d-%d is not yet done'
+ % (self.goal, e.i1, e.i2)
+ )
+
+ def simplify_to_drop(self, refname, force=False):
+ try:
+ base = self.goalopts['base']
+ except KeyError:
+ raise Failure('Goal "drop" was not initialized correctly')
+
+ i2 = self.len2 - 1
+ path = [
+ ((i1, i2), (i1, 0))
+ for i1 in range(1, self.len1)
+ ]
+
+ try:
+ self._simplify_to_path(refname, base, path, force=force)
+ except MissingMergeFailure as e:
+ raise Failure(
+ 'Cannot simplify to rebase because merge %d-%d is not yet done'
+ % (e.i1, e.i2)
)
- # We checked above that the update is OK, so here we can set
- # force=True:
- self._set_refname(refname, commit, force=True)
+ def simplify_to_revert(self, refname, force=False):
+ self.simplify_to_rebase(refname, force=force)
def simplify_to_merge(self, refname, force=False):
if not (-1, -1) in self:
@@ -2365,11 +3038,11 @@ class MergeState(Block):
'Cannot simplify to merge because merge %d-%d is not yet done'
% (self.len1 - 1, self.len2 - 1)
)
- tree = get_tree(self[-1, -1].sha1)
- parents = [self[-1,0].sha1, self[0,-1].sha1]
+ tree = self.git.get_tree(self[-1, -1].sha1)
+ parents = [self[-1, 0].sha1, self[0, -1].sha1]
# Create a preliminary commit with a generic commit message:
- sha1 = commit_tree(
+ sha1 = self.git.commit_tree(
tree, parents,
msg='Merge %s into %s (using imerge)' % (self.tip2, self.tip1),
)
@@ -2377,7 +3050,7 @@ class MergeState(Block):
self._set_refname(refname, sha1, force=force)
# Now let the user edit the commit log message:
- check_call(['git', 'commit', '--amend'])
+ self.git.amend()
def simplify(self, refname, force=False):
"""Simplify this MergeState and save the result to refname.
@@ -2386,10 +3059,22 @@ class MergeState(Block):
if self.goal == 'full':
self.simplify_to_full(refname, force=force)
- elif 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 == 'rebase-with-history':
+ self.simplify_to_rebase_with_history(refname, force=force)
+ elif self.goal == 'border':
+ self.simplify_to_border(refname, force=force)
+ elif self.goal == 'border-with-history':
+ self.simplify_to_border(refname, with_history2=True, force=force)
+ elif self.goal == 'border-with-history2':
+ self.simplify_to_border(
+ refname, with_history1=True, with_history2=True, force=force,
+ )
+ elif self.goal == 'drop':
+ self.simplify_to_drop(refname, force=force)
+ elif self.goal == 'revert':
+ self.simplify_to_revert(refname, force=force)
elif self.goal == 'merge':
self.simplify_to_merge(refname, force=force)
else:
@@ -2401,9 +3086,9 @@ class MergeState(Block):
blockers = []
for i2 in range(0, self.len2):
for i1 in range(0, self.len1):
- record = self[i1,i2]
+ record = self[i1, i2]
if record.is_known():
- record.save(self.name, i1, i2)
+ record.save(self.git, self.name, i1, i2)
if record.is_blocked():
blockers.append((i1, i2))
@@ -2412,24 +3097,11 @@ class MergeState(Block):
blockers=blockers,
tip1=self.tip1, tip2=self.tip2,
goal=self.goal,
+ goalopts=self.goalopts,
manual=self.manual,
branch=self.branch,
)
- state_string = json.dumps(state, sort_keys=True) + '\n'
-
- cmd = ['git', 'hash-object', '-t', 'blob', '-w', '--stdin']
- p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
- out = communicate(p, input=state_string)[0]
- retcode = p.poll()
- if retcode:
- raise CalledProcessError(retcode, cmd)
- sha1 = out.strip()
- check_call([
- 'git', 'update-ref',
- '-m', 'imerge %r: Record state' % (self.name,),
- 'refs/imerge/%s/state' % (self.name,),
- sha1,
- ])
+ self.git.write_imerge_state_dict(self.name, state)
def __str__(self):
return 'MergeState(\'%s\', tip1=\'%s\', tip2=\'%s\', goal=\'%s\')' % (
@@ -2437,216 +3109,749 @@ class MergeState(Block):
)
-def request_user_merge(merge_state, i1, i2):
- """Prepare the working tree for the user to do a manual merge.
+def choose_merge_name(git, name):
+ names = list(git.iter_existing_imerge_names())
- It is assumed that the merges above and to the left of (i1, i2)
- are already done."""
+ # If a name was specified, try to use it and fail if not possible:
+ if name is not None:
+ if name not in names:
+ raise Failure('There is no incremental merge called \'%s\'!' % (name,))
+ if len(names) > 1:
+ # Record this as the new default:
+ git.set_default_imerge_name(name)
+ return name
- 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)
- logmsg = 'imerge \'%s\': manual merge %d-%d' % (merge_state.name, i1, i2)
+ # A name was not specified. Try to use the default name:
+ default_name = git.get_default_imerge_name()
+ if default_name:
+ if git.check_imerge_exists(default_name):
+ return default_name
+ else:
+ # There's no reason to keep the invalid default around:
+ git.set_default_imerge_name(None)
+ raise Failure(
+ 'Warning: The default incremental merge \'%s\' has disappeared.\n'
+ '(The setting imerge.default has been cleared.)\n'
+ 'Please try again.'
+ % (default_name,)
+ )
+
+ # If there is exactly one imerge, set it to be the default and use it.
+ if len(names) == 1 and git.check_imerge_exists(names[0]):
+ return names[0]
+
+ raise Failure('Please select an incremental merge using --name')
+
+
+def read_merge_state(git, name=None):
+ return MergeState.read(git, choose_merge_name(git, name))
+
+
+def cmd_list(parser, options):
+ git = GitRepository()
+ names = list(git.iter_existing_imerge_names())
+ default_merge = git.get_default_imerge_name()
+ if not default_merge and len(names) == 1:
+ default_merge = names[0]
+ for name in names:
+ if name == default_merge:
+ sys.stdout.write('* %s\n' % (name,))
+ else:
+ sys.stdout.write(' %s\n' % (name,))
+
+
+def cmd_init(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
+
+ if not options.name:
+ parser.error(
+ 'Please specify the --name to be used for this incremental merge'
+ )
+ tip1 = git.get_head_refname(short=True) or 'HEAD'
+ tip2 = options.tip2
try:
- check_call([
- 'git', 'merge', '--no-commit',
- '-m', logmsg,
- left.sha1,
- ])
- except CalledProcessError:
- # We expect an error (otherwise we would have automerged!)
- pass
- sys.stderr.write(
- '\n'
- 'Original first commit:\n'
- )
- check_call(['git', '--no-pager', 'log', '--no-walk', merge_state[i1,0].sha1])
- sys.stderr.write(
- '\n'
- 'Original second commit:\n'
+ (merge_base, commits1, commits2) = git.get_boundaries(
+ tip1, tip2, options.first_parent,
+ )
+ except NonlinearAncestryError as e:
+ if options.first_parent:
+ parser.error(str(e))
+ else:
+ parser.error('%s\nPerhaps use "--first-parent"?' % (e,))
+
+ merge_state = MergeState.initialize(
+ git, options.name, merge_base,
+ tip1, commits1,
+ tip2, commits2,
+ goal=options.goal, manual=options.manual,
+ branch=(options.branch or options.name),
)
- check_call(['git', '--no-pager', 'log', '--no-walk', merge_state[0,i2].sha1])
- sys.stderr.write(
- '\n'
- 'There was a conflict merging commit %d-%d, shown above.\n'
- 'Please resolve the conflict, commit the result, then type\n'
- '\n'
- ' git-imerge continue\n'
- % (i1, i2)
+ merge_state.save()
+ if len(list(git.iter_existing_imerge_names())) > 1:
+ git.set_default_imerge_name(options.name)
+
+
+def cmd_start(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
+
+ if not options.name:
+ parser.error(
+ 'Please specify the --name to be used for this incremental merge'
+ )
+ tip1 = git.get_head_refname(short=True) or 'HEAD'
+ tip2 = options.tip2
+
+ try:
+ (merge_base, commits1, commits2) = git.get_boundaries(
+ tip1, tip2, options.first_parent,
+ )
+ except NonlinearAncestryError as e:
+ if options.first_parent:
+ parser.error(str(e))
+ else:
+ parser.error('%s\nPerhaps use "--first-parent"?' % (e,))
+
+ merge_state = MergeState.initialize(
+ git, options.name, merge_base,
+ tip1, commits1,
+ tip2, commits2,
+ goal=options.goal, manual=options.manual,
+ branch=(options.branch or options.name),
)
+ merge_state.save()
+ if len(list(git.iter_existing_imerge_names())) > 1:
+ git.set_default_imerge_name(options.name)
+
+ try:
+ merge_state.auto_complete_frontier()
+ except FrontierBlockedError as e:
+ merge_state.request_user_merge(e.i1, e.i2)
+ else:
+ sys.stderr.write('Merge is complete!\n')
-def incorporate_user_merge(merge_state, edit_log_msg=None):
- """If the user has done a merge for us, incorporate the results.
+def cmd_merge(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
- If the scratch reference refs/heads/imerge/NAME exists and is
- checked out, first check if there are staged changes that can be
- committed. Then try to incorporate the current commit into
- merge_state, delete the reference, and return (i1,i2)
- corresponding to the merge. If the scratch reference does not
- exist, raise NoManualMergeError(). If the scratch reference
- exists but cannot be used, raise a ManualMergeUnusableError. If
- there are unstaged changes in the working tree, emit an error
- message and raise UncleanWorkTreeError."""
+ tip2 = options.tip2
- refname = MergeState.get_scratch_refname(merge_state.name)
+ if options.name:
+ name = options.name
+ else:
+ # By default, name the imerge after the branch being merged:
+ name = tip2
+ git.check_imerge_name_format(name)
+
+ tip1 = git.get_head_refname(short=True)
+ if tip1:
+ if not options.branch:
+ # See if we can store the result to the checked-out branch:
+ try:
+ git.check_branch_name_format(tip1)
+ except InvalidBranchNameError:
+ pass
+ else:
+ options.branch = tip1
+ else:
+ tip1 = 'HEAD'
+
+ if not options.branch:
+ if options.name:
+ options.branch = options.name
+ else:
+ parser.error(
+ 'HEAD is not a simple branch. '
+ 'Please specify --branch for storing results.'
+ )
try:
- commit = get_commit_sha1(refname)
- except ValueError:
- raise NoManualMergeError('Reference %s does not exist.' % (refname,))
+ (merge_base, commits1, commits2) = git.get_boundaries(
+ tip1, tip2, options.first_parent,
+ )
+ except NonlinearAncestryError as e:
+ if options.first_parent:
+ parser.error(str(e))
+ else:
+ parser.error('%s\nPerhaps use "--first-parent"?' % (e,))
+ except NothingToDoError as e:
+ sys.stdout.write('Already up-to-date.\n')
+ sys.exit(0)
+
+ merge_state = MergeState.initialize(
+ git, name, merge_base,
+ tip1, commits1,
+ tip2, commits2,
+ goal=options.goal, manual=options.manual,
+ branch=options.branch,
+ )
+ merge_state.save()
+ if len(list(git.iter_existing_imerge_names())) > 1:
+ git.set_default_imerge_name(name)
try:
- head_name = check_output(['git', 'symbolic-ref', '--quiet', 'HEAD']).strip()
- except CalledProcessError:
- raise NoManualMergeError('HEAD is currently detached.')
-
- if head_name != refname:
- # This should not usually happen. The scratch reference
- # exists, but it is not current. Perhaps the user gave up on
- # an attempted merge then switched to another branch. We want
- # to delete refname, but only if it doesn't contain any
- # content that we don't already know.
- try:
- merge_state.find_index(commit)
- except CommitNotFoundError:
- # It points to a commit that we don't have in our records.
- raise Failure(
- 'The scratch reference, %(refname)s, already exists but is not\n'
- 'checked out. If it points to a merge commit that you would like\n'
- 'to use, please check it out using\n'
- '\n'
- ' git checkout %(refname)s\n'
- '\n'
- 'and then try to continue again. If it points to a commit that is\n'
- 'unneeded, then please delete the reference using\n'
- '\n'
- ' git update-ref -d %(refname)s\n'
- '\n'
- 'and then continue.'
- % dict(refname=refname)
+ merge_state.auto_complete_frontier()
+ except FrontierBlockedError as e:
+ merge_state.request_user_merge(e.i1, e.i2)
+ else:
+ sys.stderr.write('Merge is complete!\n')
+
+
+def cmd_rebase(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
+
+ tip1 = options.tip1
+
+ tip2 = git.get_head_refname(short=True)
+ if tip2:
+ if not options.branch:
+ # See if we can store the result to the current branch:
+ try:
+ git.check_branch_name_format(tip2)
+ except InvalidBranchNameError:
+ pass
+ else:
+ options.branch = tip2
+ if not options.name:
+ # By default, name the imerge after the branch being rebased:
+ options.name = tip2
+ else:
+ tip2 = git.rev_parse('HEAD')
+
+ if not options.name:
+ parser.error(
+ 'The checked-out branch could not be used as the imerge name.\n'
+ 'Please use the --name option.'
+ )
+
+ if not options.branch:
+ if options.name:
+ options.branch = options.name
+ else:
+ parser.error(
+ 'HEAD is not a simple branch. '
+ 'Please specify --branch for storing results.'
)
+
+ try:
+ (merge_base, commits1, commits2) = git.get_boundaries(
+ tip1, tip2, options.first_parent,
+ )
+ except NonlinearAncestryError as e:
+ if options.first_parent:
+ parser.error(str(e))
else:
- # It points to a commit that is already recorded. We can
- # delete it without losing any information.
- check_call([
- 'git', 'update-ref',
- '-m',
- 'imerge %r: Remove obsolete scratch reference'
- % (merge_state.name,),
- '-d', refname,
- ])
- sys.stderr.write(
- '%s did not point to a new merge; it has been deleted.\n'
- % (refname,)
+ parser.error('%s\nPerhaps use "--first-parent"?' % (e,))
+ except NothingToDoError as e:
+ sys.stdout.write('Already up-to-date.\n')
+ sys.exit(0)
+
+ merge_state = MergeState.initialize(
+ git, options.name, merge_base,
+ tip1, commits1,
+ tip2, commits2,
+ goal=options.goal, manual=options.manual,
+ branch=options.branch,
+ )
+ merge_state.save()
+ if len(list(git.iter_existing_imerge_names())) > 1:
+ git.set_default_imerge_name(options.name)
+
+ try:
+ merge_state.auto_complete_frontier()
+ except FrontierBlockedError as e:
+ merge_state.request_user_merge(e.i1, e.i2)
+ else:
+ sys.stderr.write('Merge is complete!\n')
+
+
+def cmd_drop(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
+
+ m = re.match(r'^(?P<start>.*[^\.])(?P<sep>\.{2,})(?P<end>[^\.].*)$', options.range)
+ if m:
+ if m.group('sep') != '..':
+ parser.error(
+ 'Range must either be a single commit '
+ 'or in the form "commit..commit"'
)
- raise NoManualMergeError(
- 'Reference %s was not checked out.' % (refname,)
+ start = git.rev_parse(m.group('start'))
+ end = git.rev_parse(m.group('end'))
+ else:
+ end = git.rev_parse(options.range)
+ start = git.rev_parse('%s^' % (end,))
+
+ try:
+ to_drop = git.linear_ancestry(start, end, options.first_parent)
+ except NonlinearAncestryError as e:
+ if options.first_parent:
+ parser.error(str(e))
+ else:
+ parser.error('%s\nPerhaps use "--first-parent"?' % (e,))
+
+ # Suppose we want to drop commits 2 and 3 in the branch below.
+ # Then we set up an imerge as follows:
+ #
+ # o - 0 - 1 - 2 - 3 - 4 - 5 - 6 ← tip1
+ # |
+ # 3⁻¹
+ # |
+ # 2⁻¹
+ #
+ # ↑
+ # tip2
+ #
+ # We first use imerge to rebase tip1 onto tip2, then we simplify
+ # by discarding the sequence (2, 3, 3⁻¹, 2⁻¹) (which together are
+ # a NOOP). In this case, goalopts would have the following
+ # contents:
+ #
+ # goalopts['base'] = rev_parse(commit1)
+
+ tip1 = git.get_head_refname(short=True)
+ if tip1:
+ if not options.branch:
+ # See if we can store the result to the current branch:
+ try:
+ git.check_branch_name_format(tip1)
+ except InvalidBranchNameError:
+ pass
+ else:
+ options.branch = tip1
+ if not options.name:
+ # By default, name the imerge after the branch being rebased:
+ options.name = tip1
+ else:
+ tip1 = git.rev_parse('HEAD')
+
+ if not options.name:
+ parser.error(
+ 'The checked-out branch could not be used as the imerge name.\n'
+ 'Please use the --name option.'
+ )
+
+ if not options.branch:
+ if options.name:
+ options.branch = options.name
+ else:
+ parser.error(
+ 'HEAD is not a simple branch. '
+ 'Please specify --branch for storing results.'
)
- # If we reach this point, then the scratch reference exists and is
- # checked out. Now check whether there is staged content that
- # can be committed:
+ # Create a branch based on end that contains the inverse of the
+ # commits that we want to drop. This will be tip2:
- merge_frontier = MergeFrontier.map_known_frontier(merge_state)
+ git.checkout(end)
+ for commit in reversed(to_drop):
+ git.revert(commit)
+
+ tip2 = git.rev_parse('HEAD')
try:
- check_call(['git', 'diff-index', '--cached', '--quiet', 'HEAD', '--'])
- except CalledProcessError:
- # There are staged changes; commit them if possible.
- cmd = ['git', 'commit', '--no-verify']
+ (merge_base, commits1, commits2) = git.get_boundaries(
+ tip1, tip2, options.first_parent,
+ )
+ except NonlinearAncestryError as e:
+ if options.first_parent:
+ parser.error(str(e))
+ else:
+ parser.error('%s\nPerhaps use "--first-parent"?' % (e,))
+ except NothingToDoError as e:
+ sys.stdout.write('Already up-to-date.\n')
+ sys.exit(0)
+
+ merge_state = MergeState.initialize(
+ git, options.name, merge_base,
+ tip1, commits1,
+ tip2, commits2,
+ goal='drop', goalopts={'base' : start},
+ manual=options.manual,
+ branch=options.branch,
+ )
+ merge_state.save()
+ if len(list(git.iter_existing_imerge_names())) > 1:
+ git.set_default_imerge_name(options.name)
+
+ try:
+ merge_state.auto_complete_frontier()
+ except FrontierBlockedError as e:
+ merge_state.request_user_merge(e.i1, e.i2)
+ else:
+ sys.stderr.write('Merge is complete!\n')
+
+
+def cmd_revert(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
+
+ m = re.match(r'^(?P<start>.*[^\.])(?P<sep>\.{2,})(?P<end>[^\.].*)$', options.range)
+ if m:
+ if m.group('sep') != '..':
+ parser.error(
+ 'Range must either be a single commit '
+ 'or in the form "commit..commit"'
+ )
+ start = git.rev_parse(m.group('start'))
+ end = git.rev_parse(m.group('end'))
+ else:
+ end = git.rev_parse(options.range)
+ start = git.rev_parse('%s^' % (end,))
- if edit_log_msg is not None:
- if edit_log_msg:
- cmd += ['--edit']
+ try:
+ to_revert = git.linear_ancestry(start, end, options.first_parent)
+ except NonlinearAncestryError as e:
+ if options.first_parent:
+ parser.error(str(e))
+ else:
+ parser.error('%s\nPerhaps use "--first-parent"?' % (e,))
+
+ # Suppose we want to revert commits 2 and 3 in the branch below.
+ # Then we set up an imerge as follows:
+ #
+ # o - 0 - 1 - 2 - 3 - 4 - 5 - 6 ← tip1
+ # |
+ # 3⁻¹
+ # |
+ # 2⁻¹
+ #
+ # ↑
+ # tip2
+ #
+ # Then we use imerge to rebase tip2 onto tip1.
+
+ tip1 = git.get_head_refname(short=True)
+ if tip1:
+ if not options.branch:
+ # See if we can store the result to the current branch:
+ try:
+ git.check_branch_name_format(tip1)
+ except InvalidBranchNameError:
+ pass
else:
- cmd += ['--no-edit']
+ options.branch = tip1
+ if not options.name:
+ # By default, name the imerge after the branch being rebased:
+ options.name = tip1
+ else:
+ tip1 = git.rev_parse('HEAD')
- try:
- check_call(cmd)
- except CalledProcessError:
- raise Failure('Could not commit staged changes.')
- commit = get_commit_sha1('HEAD')
+ if not options.name:
+ parser.error(
+ 'The checked-out branch could not be used as the imerge name.\n'
+ 'Please use the --name option.'
+ )
- require_clean_work_tree('proceed')
+ if not options.branch:
+ if options.name:
+ options.branch = options.name
+ else:
+ parser.error(
+ 'HEAD is not a simple branch. '
+ 'Please specify --branch for storing results.'
+ )
- # This might throw ManualMergeUnusableError:
- (i1, i2) = merge_state.incorporate_manual_merge(commit)
+ # Create a branch based on end that contains the inverse of the
+ # commits that we want to drop. This will be tip2:
- # Now detach head so that we can delete refname.
- check_call([
- 'git', 'update-ref', '--no-deref',
- '-m', 'Detach HEAD from %s' % (refname,),
- 'HEAD', commit,
- ])
+ git.checkout(end)
+ for commit in reversed(to_revert):
+ git.revert(commit)
- check_call([
- 'git', 'update-ref',
- '-m', 'imerge %s: remove scratch reference' % (merge_state.name,),
- '-d', refname,
- ])
+ tip2 = git.rev_parse('HEAD')
try:
- # This might throw NotABlockingCommitError:
- unblocked_block = merge_frontier.get_affected_blocker_block(i1, i2)
- unblocked_block[1,1].record_blocked(False)
- sys.stderr.write(
- 'Merge has been recorded for merge %d-%d.\n'
- % unblocked_block.get_original_indexes(1, 1)
+ (merge_base, commits1, commits2) = git.get_boundaries(
+ tip1, tip2, options.first_parent,
)
+ except NonlinearAncestryError as e:
+ if options.first_parent:
+ parser.error(str(e))
+ else:
+ parser.error('%s\nPerhaps use "--first-parent"?' % (e,))
+ except NothingToDoError as e:
+ sys.stdout.write('Already up-to-date.\n')
+ sys.exit(0)
+
+ merge_state = MergeState.initialize(
+ git, options.name, merge_base,
+ tip1, commits1,
+ tip2, commits2,
+ goal='revert',
+ manual=options.manual,
+ branch=options.branch,
+ )
+ merge_state.save()
+ if len(list(git.iter_existing_imerge_names())) > 1:
+ git.set_default_imerge_name(options.name)
+
+ try:
+ merge_state.auto_complete_frontier()
+ except FrontierBlockedError as e:
+ merge_state.request_user_merge(e.i1, e.i2)
+ else:
+ sys.stderr.write('Merge is complete!\n')
+
+
+def cmd_remove(parser, options):
+ git = GitRepository()
+ MergeState.remove(git, choose_merge_name(git, options.name))
+
+
+def cmd_continue(parser, options):
+ git = GitRepository()
+ merge_state = read_merge_state(git, options.name)
+ try:
+ merge_state.incorporate_user_merge(edit_log_msg=options.edit)
+ except NoManualMergeError:
+ pass
+ except NotABlockingCommitError as e:
+ raise Failure(str(e))
+ except ManualMergeUnusableError as e:
+ raise Failure(str(e))
+
+ try:
+ merge_state.auto_complete_frontier()
+ except FrontierBlockedError as e:
+ merge_state.request_user_merge(e.i1, e.i2)
+ else:
+ sys.stderr.write('Merge is complete!\n')
+
+
+def cmd_record(parser, options):
+ git = GitRepository()
+ merge_state = read_merge_state(git, options.name)
+ try:
+ merge_state.incorporate_user_merge(edit_log_msg=options.edit)
+ except NoManualMergeError as e:
+ raise Failure(str(e))
except NotABlockingCommitError:
- raise
- finally:
+ raise Failure(str(e))
+ except ManualMergeUnusableError as e:
+ raise Failure(str(e))
+
+ try:
+ merge_state.auto_complete_frontier()
+ except FrontierBlockedError as e:
+ pass
+ else:
+ sys.stderr.write('Merge is complete!\n')
+
+
+def cmd_autofill(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
+ merge_state = read_merge_state(git, options.name)
+ with git.temporary_head(message='imerge: restoring'):
+ try:
+ merge_state.auto_complete_frontier()
+ except FrontierBlockedError as e:
+ raise Failure(str(e))
+
+
+def cmd_simplify(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
+ merge_state = read_merge_state(git, 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.branch),)
+ if options.goal is not None:
+ merge_state.set_goal(options.goal)
merge_state.save()
+ merge_state.simplify(refname, force=options.force)
-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
+def cmd_finish(parser, options):
+ git = GitRepository()
+ git.require_clean_work_tree('proceed')
+ merge_state = read_merge_state(git, 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.branch),)
+ if options.goal is not None:
+ merge_state.set_goal(options.goal)
+ merge_state.save()
+ merge_state.simplify(refname, force=options.force)
+ MergeState.remove(git, merge_state.name)
+
+
+def cmd_diagram(parser, options):
+ git = GitRepository()
+ if not (options.commits or options.frontier):
+ options.frontier = True
+ if not (options.color or (options.color is None and sys.stdout.isatty())):
+ AnsiColor.disable()
+
+ merge_state = read_merge_state(git, 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')
+ if options.html:
+ merge_frontier = MergeFrontier.map_known_frontier(merge_state)
+ html = open(options.html, 'w')
+ merge_frontier.write_html(html, merge_state.name)
+ html.close()
+ 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'
+ ' # = conflict that is currently blocking progress\n'
+ ' @ = merge was blocked but has been resolved\n'
+ ' ? = no merge recorded\n'
+ '\n'
+ )
- # 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]
+def reparent_recursively(git, start_commit, parents, end_commit):
+ """Change the parents of start_commit and its descendants.
- raise Failure('Please select an incremental merge using --name')
+ Change start_commit to have the specified parents, and reparent
+ all commits on the ancestry path between start_commit and
+ end_commit accordingly. Return the replacement end_commit.
+ start_commit, parents, and end_commit must all be resolved OIDs.
+
+ """
+
+ # A map {old_oid : new_oid} keeping track of which replacements
+ # have to be made:
+ replacements = {}
+
+ # Reparent start_commit:
+ replacements[start_commit] = git.reparent(start_commit, parents)
+
+ for (commit, parents) in git.rev_list_with_parents(
+ '--ancestry-path', '--topo-order', '--reverse',
+ '%s..%s' % (start_commit, end_commit)
+ ):
+ parents = [replacements.get(p, p) for p in parents]
+ replacements[commit] = git.reparent(commit, parents)
+
+ try:
+ return replacements[end_commit]
+ except KeyError:
+ raise ValueError(
+ "%s is not an ancestor of %s" % (start_commit, end_commit),
+ )
+
+
+def cmd_reparent(parser, options):
+ git = GitRepository()
+ try:
+ commit = git.get_commit_sha1(options.commit)
+ except ValueError:
+ sys.exit('%s is not a valid commit', options.commit)
+ try:
+ head = git.get_commit_sha1('HEAD')
+ except ValueError:
+ sys.exit('HEAD is not a valid commit')
-def read_merge_state(name=None, default_to_unique=True):
- return MergeState.read(choose_merge_name(name, default_to_unique=default_to_unique))
+ try:
+ parents = [git.get_commit_sha1(p) for p in options.parents]
+ except ValueError as e:
+ sys.exit(e.message)
+
+ sys.stderr.write('Reparenting %s..HEAD\n' % (options.commit,))
+
+ try:
+ new_head = reparent_recursively(git, commit, parents, head)
+ except ValueError as e:
+ sys.exit(e.message)
+
+ sys.stdout.write('%s\n' % (new_head,))
-@Failure.wrap
def main(args):
+ NAME_INIT_HELP = 'name to use for this incremental merge'
+
+ def add_name_argument(subparser, help=None):
+ if help is None:
+ subcommand = subparser.prog.split()[1]
+ help = 'name of incremental merge to {0}'.format(subcommand)
+
+ subparser.add_argument(
+ '--name', action='store', default=None, help=help,
+ )
+
+ def add_goal_argument(subparser, default=DEFAULT_GOAL):
+ help = 'the goal of the incremental merge'
+ if default is None:
+ help = (
+ 'the type of simplification to be made '
+ '(default is the value provided to "init" or "start")'
+ )
+ subparser.add_argument(
+ '--goal',
+ action='store', default=default,
+ choices=ALLOWED_GOALS,
+ help=help,
+ )
+
+ def add_branch_argument(subparser):
+ subcommand = subparser.prog.split()[1]
+ help = 'the name of the branch to which the result will be stored'
+ if subcommand in ['simplify', 'finish']:
+ help = (
+ 'the name of the branch to which to store the result '
+ '(default is the value provided to "init" or "start" if any; '
+ 'otherwise 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.'
+ )
+ subparser.add_argument(
+ '--branch',
+ action='store', default=None,
+ help=help,
+ )
+
+ def add_manual_argument(subparser):
+ subparser.add_argument(
+ '--manual',
+ action='store_true', default=False,
+ help=(
+ 'ask the user to complete all merges manually, even when they '
+ 'appear conflict-free. This option disables the usual bisection '
+ 'algorithm and causes the full incremental merge diagram to be '
+ 'completed.'
+ ),
+ )
+
+ def add_first_parent_argument(subparser, default=None):
+ subcommand = subparser.prog.split()[1]
+ help = (
+ 'handle only the first parent commits '
+ '(this option is currently required if the history is nonlinear)'
+ )
+ if subcommand in ['merge', 'rebase']:
+ help = argparse.SUPPRESS
+ subparser.add_argument(
+ '--first-parent', action='store_true', default=default, help=help,
+ )
+
+ def add_tip2_argument(subparser):
+ subparser.add_argument(
+ 'tip2', action='store', metavar='branch',
+ help='the tip of the branch to be merged into HEAD',
+ )
+
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -2660,119 +3865,68 @@ def main(args):
'(equivalent to "init" followed by "continue")'
),
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name to use for this incremental merge',
- )
- subparser.add_argument(
- '--goal',
- action='store', default=DEFAULT_GOAL,
- choices=ALLOWED_GOALS,
- help='the goal of the incremental merge',
- )
- subparser.add_argument(
- '--branch',
- action='store', default=None,
- help='the name of the branch to which the result will be stored',
- )
- subparser.add_argument(
- '--manual',
- action='store_true', default=False,
- help=(
- 'ask the user to complete all merges manually, even when they '
- 'appear conflict-free. This option disables the usual bisection '
- 'algorithm and causes the full incremental merge diagram to be '
- 'completed.'
- ),
- )
- subparser.add_argument(
- '--first-parent', action='store_true', default=None,
- help=(
- 'handle only the first parent commits '
- '(this option is currently required)'
- ),
- )
- subparser.add_argument(
- 'tip2', action='store', metavar='branch',
- help='the tip of the branch to be merged into HEAD',
- )
+ add_name_argument(subparser, help=NAME_INIT_HELP)
+ add_goal_argument(subparser)
+ add_branch_argument(subparser)
+ add_manual_argument(subparser)
+ add_first_parent_argument(subparser)
+ add_tip2_argument(subparser)
subparser = subparsers.add_parser(
'merge',
help='start a simple merge via incremental merge',
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name to use for this incremental merge',
- )
- subparser.add_argument(
- '--goal',
- action='store', default='merge',
- choices=ALLOWED_GOALS,
- help='the goal of the incremental merge',
- )
- subparser.add_argument(
- '--branch',
- action='store', default=None,
- help='the name of the branch to which the result will be stored',
- )
- subparser.add_argument(
- '--manual',
- action='store_true', default=False,
- help=(
- 'ask the user to complete all merges manually, even when they '
- 'appear conflict-free. This option disables the usual bisection '
- 'algorithm and causes the full incremental merge diagram to be '
- 'completed.'
- ),
- )
- subparser.add_argument(
- '--first-parent', action='store_true', default=True,
- help=argparse.SUPPRESS,
- )
- subparser.add_argument(
- 'tip2', action='store', metavar='branch',
- help='the tip of the branch to be merged into HEAD',
- )
+ add_name_argument(subparser, help=NAME_INIT_HELP)
+ add_goal_argument(subparser, default='merge')
+ add_branch_argument(subparser)
+ add_manual_argument(subparser)
+ add_first_parent_argument(subparser, default=True)
+ add_tip2_argument(subparser)
subparser = subparsers.add_parser(
'rebase',
help='start a simple rebase via incremental merge',
)
+ add_name_argument(subparser, help=NAME_INIT_HELP)
+ add_goal_argument(subparser, default='rebase')
+ add_branch_argument(subparser)
+ add_manual_argument(subparser)
+ add_first_parent_argument(subparser, default=True)
subparser.add_argument(
- '--name', action='store', default=None,
- help='name to use for this incremental merge',
- )
- subparser.add_argument(
- '--goal',
- action='store', default='rebase',
- choices=ALLOWED_GOALS,
- help='the goal of the incremental merge',
+ 'tip1', action='store', metavar='branch',
+ help=(
+ 'the tip of the branch onto which the current branch should '
+ 'be rebased'
+ ),
)
- subparser.add_argument(
- '--branch',
- action='store', default=None,
- help='the name of the branch to which the result will be stored',
+
+ subparser = subparsers.add_parser(
+ 'drop',
+ help='drop one or more commits via incremental merge',
)
+ add_name_argument(subparser, help=NAME_INIT_HELP)
+ add_branch_argument(subparser)
+ add_manual_argument(subparser)
+ add_first_parent_argument(subparser, default=True)
subparser.add_argument(
- '--manual',
- action='store_true', default=False,
+ 'range', action='store', metavar='[commit | commit..commit]',
help=(
- 'ask the user to complete all merges manually, even when they '
- 'appear conflict-free. This option disables the usual bisection '
- 'algorithm and causes the full incremental merge diagram to be '
- 'completed.'
+ 'the commit or range of commits that should be dropped'
),
)
- subparser.add_argument(
- '--first-parent', action='store_true', default=True,
- help=argparse.SUPPRESS,
+
+ subparser = subparsers.add_parser(
+ 'revert',
+ help='revert one or more commits via incremental merge',
)
+ add_name_argument(subparser, help=NAME_INIT_HELP)
+ add_branch_argument(subparser)
+ add_manual_argument(subparser)
+ add_first_parent_argument(subparser, default=True)
subparser.add_argument(
- 'tip1', action='store', metavar='branch',
+ 'range', action='store', metavar='[commit | commit..commit]',
help=(
- 'the tip of the branch onto which the current branch should '
- 'be rebased'
+ 'the commit or range of commits that should be reverted'
),
)
@@ -2786,10 +3940,7 @@ def main(args):
'conflict that has to be resolved manually)'
),
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name of merge to continue',
- )
+ add_name_argument(subparser)
subparser.set_defaults(edit=None)
subparser.add_argument(
'--edit', '-e', dest='edit', action='store_true',
@@ -2807,31 +3958,9 @@ def main(args):
'(equivalent to "simplify" followed by "remove")'
),
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name of merge to finish',
- )
- subparser.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")'
- ),
- )
- subparser.add_argument(
- '--branch',
- action='store', default=None,
- help=(
- 'the name of the branch to which to store the result '
- '(default is the value provided to "init" or "start" if any; '
- 'otherwise 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.'
- ),
- )
+ add_name_argument(subparser)
+ add_goal_argument(subparser, default=None)
+ add_branch_argument(subparser)
subparser.add_argument(
'--force',
action='store_true', default=False,
@@ -2842,10 +3971,7 @@ def main(args):
'diagram',
help='display a diagram of the current state of a merge',
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name of merge to diagram',
- )
+ add_name_argument(subparser)
subparser.add_argument(
'--commits', action='store_true', default=False,
help='show the merges that have been made so far',
@@ -2879,49 +4005,20 @@ def main(args):
'init',
help='initialize a new incremental merge',
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name to use for this incremental merge',
- )
- subparser.add_argument(
- '--goal',
- action='store', default=DEFAULT_GOAL,
- choices=ALLOWED_GOALS,
- help='the goal of the incremental merge',
- )
- subparser.add_argument(
- '--branch',
- action='store', default=None,
- help='the name of the branch to which the result will be stored',
- )
- subparser.add_argument(
- '--manual',
- action='store_true', default=False,
- help=(
- 'ask the user to complete all merges manually, even when they '
- 'appear conflict-free. This option disables the usual bisection '
- 'algorithm and causes the full incremental merge diagram to be '
- 'completed.'
- ),
- )
- subparser.add_argument(
- '--first-parent', action='store_true', default=None,
- help=(
- 'handle only the first parent commits '
- '(this option is currently required)'
- ),
- )
- subparser.add_argument(
- 'tip2', action='store', metavar='branch',
- help='the tip of the branch to be merged into HEAD',
- )
+ add_name_argument(subparser, help=NAME_INIT_HELP)
+ add_goal_argument(subparser)
+ add_branch_argument(subparser)
+ add_manual_argument(subparser)
+ add_first_parent_argument(subparser)
+ add_tip2_argument(subparser)
subparser = subparsers.add_parser(
'record',
help='record the merge at branch imerge/NAME',
)
- subparser.add_argument(
- '--name', action='store', default=None,
+ # record:
+ add_name_argument(
+ subparser,
help='name of merge to which the merge should be added',
)
subparser.set_defaults(edit=None)
@@ -2938,10 +4035,7 @@ def main(args):
'autofill',
help='autofill non-conflicting merges',
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name of merge to autofill',
- )
+ add_name_argument(subparser)
subparser = subparsers.add_parser(
'simplify',
@@ -2951,31 +4045,9 @@ def main(args):
'that are retained'
),
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name of merge to simplify',
- )
- subparser.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")'
- ),
- )
- subparser.add_argument(
- '--branch',
- action='store', default=None,
- help=(
- 'the name of the branch to which to store the result '
- '(default is the value provided to "init" or "start" if any; '
- 'otherwise 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.'
- ),
- )
+ add_name_argument(subparser)
+ add_goal_argument(subparser, default=None)
+ add_branch_argument(subparser)
subparser.add_argument(
'--force',
action='store_true', default=False,
@@ -2986,17 +4058,28 @@ def main(args):
'remove',
help='irrevocably remove an incremental merge',
)
- subparser.add_argument(
- '--name', action='store', default=None,
- help='name of incremental merge to remove',
- )
+ add_name_argument(subparser)
subparser = subparsers.add_parser(
'reparent',
- help='change the parents of the HEAD commit',
+ help=(
+ 'change the parents of the specified commit and propagate the '
+ 'change to HEAD'
+ ),
)
subparser.add_argument(
- 'parents', nargs='*', help='[PARENT...]',
+ '--commit', metavar='COMMIT', default='HEAD',
+ help=(
+ 'target commit to reparent. Create a new commit identical to '
+ 'this one, but having the specified parents. Then create '
+ 'new versions of all descendants of this commit all the way to '
+ 'HEAD, incorporating the modified commit. Output the SHA-1 of '
+ 'the replacement HEAD commit.'
+ ),
+ )
+ subparser.add_argument(
+ 'parents', nargs='*', metavar='PARENT',
+ help='a list of commits',
)
options = parser.parse_args(args)
@@ -3009,312 +4092,44 @@ def main(args):
os.environ[str('GIT_IMERGE')] = str('1')
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,))
+ cmd_list(parser, options)
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'
- )
- try:
- tip1 = check_output(
- ['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'],
- ).strip()
- except CalledProcessError:
- tip1 = 'HEAD'
- tip2 = options.tip2
- (merge_base, commits1, commits2) = get_boundaries(tip1, tip2)
- merge_state = MergeState.initialize(
- options.name, merge_base,
- tip1, commits1,
- tip2, commits2,
- goal=options.goal, manual=options.manual,
- branch=(options.branch or options.name),
- )
- merge_state.save()
- MergeState.set_default_name(options.name)
+ cmd_init(parser, options)
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'
- )
- try:
- tip1 = check_output(
- ['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'],
- ).strip()
- except CalledProcessError:
- tip1 = 'HEAD'
- tip2 = options.tip2
- (merge_base, commits1, commits2) = get_boundaries(tip1, tip2)
- merge_state = MergeState.initialize(
- options.name, merge_base,
- tip1, commits1,
- tip2, commits2,
- goal=options.goal, manual=options.manual,
- branch=(options.branch or options.name),
- )
- merge_state.save()
- MergeState.set_default_name(options.name)
-
- try:
- merge_state.auto_complete_frontier()
- except FrontierBlockedError as e:
- request_user_merge(merge_state, e.i1, e.i2)
- else:
- sys.stderr.write('Merge is complete!\n')
+ cmd_start(parser, options)
elif options.subcommand == 'merge':
- require_clean_work_tree('proceed')
-
- if not options.first_parent:
- parser.error(
- 'The --first-parent option is currently required for the "merge" command'
- )
-
- tip2 = options.tip2
-
- if options.name:
- name = options.name
- else:
- # By default, name the imerge after the branch being merged:
- name = tip2
- MergeState.check_name_format(name)
-
- try:
- tip1 = check_output(
- ['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'],
- ).strip()
- if not options.branch:
- # See if we can store the result to the checked-out branch:
- try:
- check_branch_name_format(tip1)
- except InvalidBranchNameError:
- pass
- else:
- options.branch = tip1
- except CalledProcessError:
- tip1 = 'HEAD'
-
- if not options.branch:
- if options.name:
- options.branch = options.name
- else:
- parser.error(
- 'HEAD is not a simple branch. '
- 'Please specify --branch for storing results.'
- )
-
- (merge_base, commits1, commits2) = get_boundaries(tip1, tip2)
- merge_state = MergeState.initialize(
- name, merge_base,
- tip1, commits1,
- tip2, commits2,
- goal=options.goal, manual=options.manual,
- branch=options.branch,
- )
- merge_state.save()
- MergeState.set_default_name(name)
-
- try:
- merge_state.auto_complete_frontier()
- except FrontierBlockedError as e:
- request_user_merge(merge_state, e.i1, e.i2)
- else:
- sys.stderr.write('Merge is complete!\n')
+ cmd_merge(parser, options)
elif options.subcommand == 'rebase':
- require_clean_work_tree('proceed')
-
- if not options.first_parent:
- parser.error(
- 'The --first-parent option is currently required for the "rebase" command'
- )
-
- tip1 = options.tip1
-
- try:
- tip2 = check_output(
- ['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'],
- ).strip()
- if not options.branch:
- # See if we can store the result to the current branch:
- try:
- check_branch_name_format(tip2)
- except InvalidBranchNameError:
- pass
- else:
- options.branch = tip2
- if not options.name:
- # By default, name the imerge after the branch being rebased:
- options.name = tip2
-
- except CalledProcessError:
- tip2 = rev_parse('HEAD')
-
- if not options.name:
- parser.error(
- 'The checked-out branch could not be used as the imerge name.\n'
- 'Please use the --name option.'
- )
-
- if not options.branch:
- if options.name:
- options.branch = options.name
- else:
- parser.error(
- 'HEAD is not a simple branch. '
- 'Please specify --branch for storing results.'
- )
-
- (merge_base, commits1, commits2) = get_boundaries(tip1, tip2)
-
- merge_state = MergeState.initialize(
- options.name, merge_base,
- tip1, commits1,
- tip2, commits2,
- goal=options.goal, manual=options.manual,
- branch=options.branch,
- )
- merge_state.save()
- MergeState.set_default_name(options.name)
-
- try:
- merge_state.auto_complete_frontier()
- except FrontierBlockedError as e:
- request_user_merge(merge_state, e.i1, e.i2)
- else:
- sys.stderr.write('Merge is complete!\n')
+ cmd_rebase(parser, options)
+ elif options.subcommand == 'drop':
+ cmd_drop(parser, options)
+ elif options.subcommand == 'revert':
+ cmd_revert(parser, options)
elif options.subcommand == 'remove':
- MergeState.remove(choose_merge_name(options.name, default_to_unique=False))
+ cmd_remove(parser, options)
elif options.subcommand == 'continue':
- merge_state = read_merge_state(options.name)
- try:
- incorporate_user_merge(merge_state, edit_log_msg=options.edit)
- except NoManualMergeError:
- pass
- except NotABlockingCommitError as e:
- raise Failure(str(e))
- except ManualMergeUnusableError as e:
- raise Failure(str(e))
-
- try:
- merge_state.auto_complete_frontier()
- except FrontierBlockedError as e:
- request_user_merge(merge_state, e.i1, e.i2)
- else:
- sys.stderr.write('Merge is complete!\n')
+ cmd_continue(parser, options)
elif options.subcommand == 'record':
- merge_state = read_merge_state(options.name)
- try:
- incorporate_user_merge(merge_state, edit_log_msg=options.edit)
- except NoManualMergeError as e:
- raise Failure(str(e))
- except NotABlockingCommitError:
- raise Failure(str(e))
- except ManualMergeUnusableError as e:
- raise Failure(str(e))
-
- try:
- merge_state.auto_complete_frontier()
- except FrontierBlockedError as e:
- pass
- else:
- sys.stderr.write('Merge is complete!\n')
+ cmd_record(parser, options)
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 as e:
- raise Failure(str(e))
+ cmd_autofill(parser, options)
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.branch),)
- if options.goal is not None:
- merge_state.set_goal(options.goal)
- merge_state.save()
- merge_state.simplify(refname, force=options.force)
+ cmd_simplify(parser, options)
elif options.subcommand == 'finish':
- require_clean_work_tree('proceed')
- merge_state = read_merge_state(options.name, default_to_unique=False)
- 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.branch),)
- if options.goal is not None:
- merge_state.set_goal(options.goal)
- merge_state.save()
- merge_state.simplify(refname, force=options.force)
- MergeState.remove(merge_state.name)
+ cmd_finish(parser, options)
elif options.subcommand == 'diagram':
- if not (options.commits or options.frontier):
- options.frontier = True
- if not (options.color or (options.color is None and sys.stdout.isatty())):
- AnsiColor.disable()
-
- 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')
- if options.html:
- merge_frontier = MergeFrontier.map_known_frontier(merge_state)
- html = open(options.html, 'w')
- merge_frontier.write_html(html, merge_state.name)
- html.close()
- 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'
- ' # = conflict that is currently blocking progress\n'
- ' @ = merge was blocked but has been resolved\n'
- ' ? = no merge recorded\n'
- '\n'
- )
+ cmd_diagram(parser, options)
elif options.subcommand == 'reparent':
- try:
- commit_sha1 = get_commit_sha1('HEAD')
- except ValueError:
- sys.exit('HEAD is not a valid commit')
-
- try:
- parent_sha1s = [get_commit_sha1(p) for p in options.parents]
- except ValueError as e:
- sys.exit(e.message)
-
- sys.stdout.write('%s\n' % (reparent(commit_sha1, parent_sha1s),))
+ cmd_reparent(parser, options)
else:
parser.error('Unrecognized subcommand')
-main(sys.argv[1:])
+if __name__ == '__main__':
+ try:
+ main(sys.argv[1:])
+ except Failure as e:
+ sys.exit(str(e))
+
# vim: set expandtab ft=python: