From 30a133e0b2f5a75a0f2350f25773768a71db11b2 Mon Sep 17 00:00:00 2001 From: Jesse Luehrs Date: Sat, 10 Nov 2018 14:27:19 -0500 Subject: update git-imerge --- bin/git/git-imerge | 3875 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 2345 insertions(+), 1530 deletions(-) mode change 100755 => 100644 bin/git/git-imerge (limited to 'bin') diff --git a/bin/git/git-imerge b/bin/git/git-imerge old mode 100755 new mode 100644 index 457fdab..b903539 --- 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 # @@ -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,378 +244,956 @@ def communicate(process, input=None): return (output, error) -class UncleanWorkTreeError(Failure): - pass +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) -def require_clean_work_tree(action): - """Verify that the current tree is clean. +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.""" - 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()) + return s - 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()) - error = [] - try: - check_call(['git', 'diff-files', '--quiet', '--ignore-submodules']) - except CalledProcessError: - error.append('Cannot %s: You have unstaged changes.' % (action,)) +class UncleanWorkTreeError(Failure): + pass - try: - check_call([ - 'git', 'diff-index', '--cached', '--quiet', - '--ignore-submodules', 'HEAD', '--', - ]) - except CalledProcessError: - if not error: - error.append('Cannot %s: Your index contains uncommitted changes.' % (action,)) - else: - error.append('Additionally, your index contains uncommitted changes.') - if error: - raise UncleanWorkTreeError('\n'.join(error)) +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 class InvalidBranchNameError(Failure): pass -def check_branch_name_format(name): - """Check that name is a valid branch name.""" - - try: - call_silently( - ['git', 'check-ref-format', 'refs/heads/%s' % (name,)] +class NotFirstParentAncestorError(Failure): + def __init__(self, commit1, commit2): + Failure.__init__( + self, + 'Commit "%s" is not a first-parent ancestor of "%s"' + % (commit1, commit2), ) - except CalledProcessError: - raise InvalidBranchNameError('Name %r is not a valid branch name!' % (name,)) -def rev_parse(arg): - return check_output(['git', 'rev-parse', '--verify', '--quiet', arg]).strip() +class NonlinearAncestryError(Failure): + def __init__(self, commit1, commit2): + Failure.__init__( + self, + 'The history "%s..%s" is not linear' + % (commit1, commit2), + ) -def rev_list(*args): - return [ - l.strip() - for l in check_output(['git', 'rev-list'] + list(args),).splitlines() - ] +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), + ) -def get_type(arg): - """Return the type of a git object ('commit', 'tree', 'blob', or 'tag').""" +class GitTemporaryHead(object): + """A context manager that records the current HEAD state then restores it. - return check_output(['git', 'cat-file', '-t', arg]).strip() + This should only be used when the working copy is clean. message + is used for the reflog. + """ -def get_tree(arg): - return rev_parse('%s^{tree}' % (arg,)) + def __init__(self, git, message): + self.git = git + self.message = message + def __enter__(self): + self.head_name = self.git.get_head_refname() + return self -BRANCH_PREFIX = 'refs/heads/' + 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,) + ) -def checkout(refname): - if refname.startswith(BRANCH_PREFIX): - target = refname[len(BRANCH_PREFIX):] - else: - target = '%s^0' % (refname,) - check_call(['git', 'checkout', target]) + return False -def get_commit_sha1(arg): - """Convert arg into a SHA1 and verify that it refers to a commit. +class GitRepository(object): + BRANCH_PREFIX = 'refs/heads/' - If not, raise ValueError.""" + MERGE_STATE_REFNAME_RE = re.compile( + r""" + ^ + refs\/imerge\/ + (?P.+) + \/state + $ + """, + re.VERBOSE, + ) - try: - return rev_parse('%s^{commit}' % (arg,)) - except CalledProcessError: - raise ValueError('%r does not refer to a valid git commit' % (arg,)) + def __init__(self): + self.git_dir_cache = None + def git_dir(self): + if self.git_dir_cache is None: + self.git_dir_cache = check_output( + ['git', 'rev-parse', '--git-dir'] + ).rstrip('\n') -def get_commit_parents(commit): - """Return a list containing the parents of commit.""" + return self.git_dir_cache - return check_output( - ['git', '--no-pager', 'log', '--no-walk', '--pretty=format:%P', commit] - ).strip().split() + 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 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]), - } + 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 commit_tree(tree, parents, msg, metadata=None): - """Create a commit containing the specified tree. + def iter_existing_imerge_names(self): + """Iterate over the names of existing MergeStates in this repo.""" - metadata can be author or committer information to be added to the - environment; e.g., {'GIT_AUTHOR_NAME' : 'me'}. + 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') - Return the SHA-1 of the new commit object.""" + def set_default_imerge_name(self, name): + """Set the default merge to the specified one. - cmd = ['git', 'commit-tree', tree] - for parent in parents: - cmd += ['-p', parent] + name can be None to cause the default to be cleared.""" - if metadata is not None: - env = os.environ.copy() - env.update(metadata) - else: - env = os.environ + 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]) - process = subprocess.Popen( - cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - ) - out = communicate(process, input=msg)[0] - retcode = process.poll() + def get_default_imerge_name(self): + """Get the name of the default merge, or None if it is currently unset.""" - 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) + try: + return check_output(['git', 'config', 'imerge.default']).rstrip() + except CalledProcessError: + return None - return out.strip() + 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. -def get_boundaries(tip1, tip2): - """Get the boundaries of an incremental merge. + 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'. - 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.""" + """ - 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) - ) + try: + return {'true' : True, 'false' : False}[ + check_output( + ['git', 'config', '--bool', 'imerge.editmergemessages'] + ).rstrip() + ] + except CalledProcessError: + return False - 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) - ) + def unstaged_changes(self): + """Return True iff there are unstaged changes in the working copy""" - return (merge_base, commits1, commits2) + 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.""" -class TemporaryHead(object): - """A context manager that records the current HEAD state then restores it. + try: + check_call([ + 'git', 'diff-index', '--cached', '--quiet', + '--ignore-submodules', 'HEAD', '--', + ]) + return False + except CalledProcessError: + return True - The message is used for the reflog.""" + def get_commit_sha1(self, arg): + """Convert arg into a SHA1 and verify that it refers to a commit. + + If not, raise ValueError.""" - def __enter__(self, message='imerge: restoring'): - self.message = message try: - self.head_name = check_output(['git', 'symbolic-ref', '--quiet', 'HEAD']).strip() + return self.rev_parse('%s^{commit}' % (arg,)) except CalledProcessError: - self.head_name = None - self.orig_head = get_commit_sha1('HEAD') - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.head_name: - try: - check_call([ - 'git', 'symbolic-ref', - '-m', self.message, 'HEAD', - self.head_name, - ]) - except Exception as e: - raise Failure( - 'Could not restore HEAD to %r!: %s\n' - % (self.head_name, e.message,) - ) - else: - try: - check_call(['git', 'reset', '--hard', self.orig_head]) - except Exception as e: - raise Failure( - 'Could not restore HEAD to %r!: %s\n' - % (self.orig_head, e.message,) - ) - return False + 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 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 - else: - new_commit.write(line) + 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,)) - 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 check_imerge_exists(self, name): + """Verify that a MergeState with the given name exists. - 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() + 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 -class AutomaticMergeFailed(Exception): - def __init__(self, commit1, commit2): - Exception.__init__( - self, 'Automatic merge of %s and %s failed' % (commit1, commit2,) + def read_imerge_state_dict(self, name): + state_string = check_output( + ['git', 'cat-file', 'blob', 'refs/imerge/%s/state' % (name,)], ) - self.commit1, self.commit2 = commit1, commit2 + 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 -def automerge(commit1, commit2, msg=None): - """Attempt an automatic merge of commit1 and commit2. + return state - Return the SHA1 of the resulting commit, or raise - AutomaticMergeFailed on error. This must be called with a clean - worktree.""" + def read_imerge_state(self, name): + """Read the state associated with the specified imerge. - 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') + Return the tuple + (state_dict, {(i1, i2) : (sha1, source), ...}) -class MergeRecord(object): - # Bits for the flags field: + , where source is 'auto' or 'manual'. Validity is checked only + lightly. - # There is a saved successful auto merge: - SAVED_AUTO = 0x01 + """ - # An auto merge (which may have been unsuccessful) has been done: - NEW_AUTO = 0x02 + merge_ref_re = re.compile( + r""" + ^ + refs\/imerge\/ + """ + re.escape(name) + r""" + \/(?Pauto|manual)\/ + (?P0|[1-9][0-9]*) + \- + (?P0|[1-9][0-9]*) + $ + """, + re.VERBOSE, + ) - # There is a saved successful manual merge: - SAVED_MANUAL = 0x04 + state_ref_re = re.compile( + r""" + ^ + refs\/imerge\/ + """ + re.escape(name) + r""" + \/state + $ + """, + re.VERBOSE, + ) - # A manual merge (which may have been unsuccessful) has been done: - NEW_MANUAL = 0x08 + state = None - # A merge that is currently blocking the merge frontier: - BLOCKED = 0x10 + # A map {(i1, i2) : (sha1, source)}: + merges = {} - # Some useful bit combinations: - SAVED = SAVED_AUTO | SAVED_MANUAL - NEW = NEW_AUTO | NEW_MANUAL + # refnames that were found but not understood: + unexpected = [] - AUTO = SAVED_AUTO | NEW_AUTO - MANUAL = SAVED_MANUAL | NEW_MANUAL + 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 - ALLOWED_INITIAL_FLAGS = [ - SAVED_AUTO, - SAVED_MANUAL, - NEW_AUTO, - NEW_MANUAL, - ] + 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 - def __init__(self, sha1=None, flags=0): - # The currently believed correct merge, or None if it is - # unknown or the best attempt was unsuccessful. - self.sha1 = sha1 + unexpected.append(refname) - if self.sha1 is None: - if flags != 0: + 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', 'update-ref', + '-m', 'imerge %r: Record state' % (name,), + 'refs/imerge/%s/state' % (name,), + sha1, + ]) + + def is_ancestor(self, commit1, commit2): + """Return True iff commit1 is an ancestor (or equal to) commit2.""" + + if commit1 == commit2: + return True + else: + return int( + check_output([ + 'git', 'rev-list', '--count', '--ancestry-path', + '%s..%s' % (commit1, commit2,), + ]).strip() + ) != 0 + + 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. + + """ + + 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. + + Return the SHA1 of the resulting commit, or raise + AutomaticMergeFailed on error. This must be called with a clean + worktree.""" + + call_silently(['git', 'checkout', '-f', commit1]) + 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, + ) + err = communicate(process)[1] + retcode = process.poll() + if retcode: + raise UncleanWorkTreeError(err.rstrip()) + + self.refresh_index() + + 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.') + + 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.""" + + 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 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. + + 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. + + """ + + if not self.simple_merge_in_progress(): + return False + + # 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'] + + if edit_log_msg is None: + edit_log_msg = self.get_default_edit() + + if edit_log_msg: + cmd += ['--edit'] + else: + cmd += ['--no-edit'] + + try: + check_call(cmd) + except CalledProcessError: + raise Failure('Could not commit staged changes.') + + return True + + def create_commit_chain(self, base, path): + """Point refname at the chain of commits indicated by path. + + 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. + + """ + + 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] + + [commit] = parents + return commit + + def rev_parse(self, arg): + return check_output(['git', 'rev-parse', '--verify', '--quiet', arg]).strip() + + def rev_list(self, *args): + cmd = ['git', 'rev-list'] + list(args) + return [ + l.strip() + for l in check_output(cmd).splitlines() + ] + + def rev_list_with_parents(self, *args): + """Iterate over (commit, [parent,...]).""" + + cmd = ['git', 'log', '--format=%H %P'] + list(args) + for line in check_output(cmd).splitlines(): + commits = line.strip().split() + yield (commits[0], commits[1:]) + + def summarize_commit(self, commit): + """Summarize `commit` to stdout.""" + + 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}.""" + + # 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_commit_parents(self, commit): + """Return a list containing the parents of commit.""" + + return check_output( + ['git', '--no-pager', 'log', '--no-walk', '--pretty=format:%P', commit] + ).strip().split() + + 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() + ) + + 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,) + ) + + def detach(self, msg): + """Detach HEAD. msg is used for the reflog.""" + + self.update_ref('HEAD', 'HEAD^0', msg, deref=False) + + def reset_hard(self, commit): + check_call(['git', 'reset', '--hard', commit]) + + def amend(self): + check_call(['git', 'commit', '--amend']) + + 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: + merge_bases = 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_bases: + raise Failure('%r and %r do not have a common merge base' % (tip1, tip2)) + if len(merge_bases) == 1: + return merge_bases[0] + + # 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: + raise NonlinearAncestryError(commit1, commit2) + + commits.append(commit) + commit = parent + + 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: + target = '%s^0' % (refname,) + cmd += [target] + check_call(cmd) + + def commit_tree(self, tree, parents, msg, metadata=None): + """Create a commit containing the specified tree. + + 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.""" + + 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, + ) + 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) + + 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) + + 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') + + 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): + # Bits for the flags field: + + # There is a saved successful auto merge: + SAVED_AUTO = 0x01 + + # An auto merge (which may have been unsuccessful) has been done: + NEW_AUTO = 0x02 + + # There is a saved successful manual merge: + SAVED_MANUAL = 0x04 + + # A manual merge (which may have been unsuccessful) has been done: + NEW_MANUAL = 0x08 + + # A merge that is currently blocking the merge frontier: + BLOCKED = 0x10 + + # Some useful bit combinations: + SAVED = SAVED_AUTO | SAVED_MANUAL + NEW = NEW_AUTO | NEW_MANUAL + + AUTO = SAVED_AUTO | NEW_AUTO + MANUAL = SAVED_MANUAL | NEW_MANUAL + + ALLOWED_INITIAL_FLAGS = [ + SAVED_AUTO, + SAVED_MANUAL, + NEW_AUTO, + NEW_MANUAL, + ] + + def __init__(self, sha1=None, flags=0): + # The currently believed correct merge, or None if it is + # unknown or the best attempt was unsuccessful. + self.sha1 = sha1 + + if self.sha1 is None: + if flags != 0: raise ValueError('Initial flags (%s) for sha1=None should be 0' % (flags,)) elif flags not in self.ALLOWED_INITIAL_FLAGS: raise ValueError('Initial flags (%s) is invalid' % (flags,)) @@ -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,31 +1315,173 @@ class NotABlockingCommitError(Exception): pass -class MergeFrontier(object): - """Represents the merge frontier within a Block. +def find_frontier_blocks(block): + """Iterate over the frontier blocks for the specified block. - A MergeFrontier is represented by a list of SubBlocks, each of - which is thought to be completely mergeable. The list is kept in - normalized form: + 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. - * Only non-empty blocks are retained + We make the following assumptions (using Python subscript + notation): - * Blocks are sorted from bottom left to upper right + 0. All of the merges in block[1:,0] and block[0,1:] are + already known. (This is an invariant of the Block class.) - * No redundant blocks + 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. - @staticmethod - def map_known_frontier(block): - """Return the MergeFrontier describing existing successful merges in block. + 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. - The return value only includes the part that is fully outlined - and whose outline consists of rectangles reaching back to - (0,0). + 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. - A blocked commit is *not* considered to be within the - frontier, even if a merge is registered for it. Such merges + """ + + # 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. + + A MergeFrontier is represented by a list of SubBlocks, each of + which is thought to be completely mergeable. The list is kept in + normalized form: + + * Only non-empty blocks are retained + + * Blocks are sorted from bottom left to upper right + + * No redundant blocks + + """ + + @staticmethod + def map_known_frontier(block): + """Return the MergeFrontier describing existing successful merges in block. + + The return value only includes the part that is fully outlined + and whose outline consists of rectangles reaching back to + (0,0). + + A blocked commit is *not* considered to be within the + frontier, even if a merge is registered for it. Such merges must be explicitly unblocked.""" # FIXME: This algorithm can take combinatorial time, for @@ -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 - - while True: - if i2 == 1: - break + Compute the blocks making up the boundary using bisection. See + find_frontier_blocks() for more information. - # 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('\n\n\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.+) - \/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( @@ -1875,142 +2398,71 @@ class MergeState(Block): % ('\n '.join(multiparent_commits),) ) - @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""" - \/(?Pauto|manual)\/ - (?P0|[1-9][0-9]*) - \- - (?P0|[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, - ) + # 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() + )) - state = None + blockers = state.get('blockers', []) - # A map {(i1, i2) : (sha1, source)}: - merges = {} + # Find merge_base, commits1, and commits2: + (merge_base, source) = merges.pop((0, 0)) + if source != MergeRecord.SAVED_MANUAL: + raise Failure('Merge base should be manual!') + commits1 = [] + for i1 in itertools.count(1): + try: + (sha1, source) = merges.pop((i1, 0)) + if source != MergeRecord.SAVED_MANUAL: + raise Failure('Merge %d-0 should be manual!' % (i1,)) + commits1.append(sha1) + except KeyError: + break - # 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,) - ) - - 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: - raise Failure('Merge base should be manual!') - commits1 = [] - for i1 in itertools.count(1): - try: - (sha1, source) = merges.pop((i1, 0)) - if source != MergeRecord.SAVED_MANUAL: - raise Failure('Merge %d-0 should be manual!' % (i1,)) - commits1.append(sha1) - except KeyError: - break - - commits2 = [] - for i2 in itertools.count(1): - try: - (sha1, source) = merges.pop((0, i2)) - if source != MergeRecord.SAVED_MANUAL: - raise Failure('Merge (0,%d) should be manual!' % (i2,)) - commits2.append(sha1) - except KeyError: - break + commits2 = [] + for i2 in itertools.count(1): + try: + (sha1, source) = merges.pop((0, i2)) + if source != MergeRecord.SAVED_MANUAL: + raise Failure('Merge (0,%d) should be manual!' % (i2,)) + commits2.append(sha1) + except KeyError: + break tip1 = state.get('tip1', commits1[-1]) tip2 = state.get('tip2', commits2[-1]) @@ -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: + 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. - # Create a commit, copying the old log message and author info: - commit = commit_tree( - tree, [commit], msg=get_log_message(orig), metadata=authordata, + """ + + 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 cmd_merge(parser, options): + git = GitRepository() + git.require_clean_work_tree('proceed') -def incorporate_user_merge(merge_state, edit_log_msg=None): - """If the user has done a merge for us, incorporate the results. + tip2 = options.tip2 + + 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) - 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.""" + 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' - refname = MergeState.get_scratch_refname(merge_state.name) + 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.*[^\.])(?P\.{2,})(?P[^\.].*)$', 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') - if edit_log_msg is not None: - if edit_log_msg: - cmd += ['--edit'] + +def cmd_revert(parser, options): + git = GitRepository() + git.require_clean_work_tree('proceed') + + m = re.match(r'^(?P.*[^\.])(?P\.{2,})(?P[^\.].*)$', 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,)) + + 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') + + 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.' + ) + + # Create a branch based on end that contains the inverse of the + # commits that we want to drop. This will be tip2: + + git.checkout(end) + for commit in reversed(to_revert): + git.revert(commit) + + tip2 = git.rev_parse('HEAD') + + 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,)) + 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 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: - check_call(cmd) - except CalledProcessError: - raise Failure('Could not commit staged changes.') - commit = get_commit_sha1('HEAD') + 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 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' + ) + + +def reparent_recursively(git, start_commit, parents, end_commit): + """Change the parents of start_commit and its descendants. + + 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') + + 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) - require_clean_work_tree('proceed') + sys.stdout.write('%s\n' % (new_head,)) - # This might throw ManualMergeUnusableError: - (i1, i2) = merge_state.incorporate_manual_merge(commit) - # Now detach head so that we can delete refname. - check_call([ - 'git', 'update-ref', '--no-deref', - '-m', 'Detach HEAD from %s' % (refname,), - 'HEAD', commit, - ]) +def main(args): + NAME_INIT_HELP = 'name to use for this incremental merge' - check_call([ - 'git', 'update-ref', - '-m', 'imerge %s: remove scratch reference' % (merge_state.name,), - '-d', refname, - ]) + 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) - 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) + subparser.add_argument( + '--name', action='store', default=None, help=help, ) - except NotABlockingCommitError: - raise - finally: - merge_state.save() - -def choose_merge_name(name, default_to_unique=True): - # If a name was specified, try to use it and fail if not possible: - if name is not None: - if not MergeState.check_exists(name): - raise Failure('There is no incremental merge called \'%s\'!' % (name,)) - MergeState.set_default_name(name) - return name - - # A name was not specified. Try to use the default name: - default_name = MergeState.get_default_name() - if default_name: - if MergeState.check_exists(default_name): - return default_name - else: - # There's no reason to keep the invalid default around: - MergeState.set_default_name(None) - raise Failure( - 'Warning: The default incremental merge \'%s\' has disappeared.\n' - '(The setting imerge.default has been cleared.)\n' - 'Please select an incremental merge using --name' - % (default_name,) + 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, + ) - if default_to_unique: - # If there is exactly one imerge, set it to be the default and use it. - names = list(MergeState.iter_existing_names()) - if len(names) == 1 and MergeState.check_exists(names[0]): - MergeState.set_default_name(names[0]) - return names[0] - - raise Failure('Please select an incremental merge using --name') + def 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 read_merge_state(name=None, default_to_unique=True): - return MergeState.read(choose_merge_name(name, default_to_unique=default_to_unique)) + 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', + ) -@Failure.wrap -def main(args): 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: -- cgit v1.2.3-54-g00ecf