diff --git a/git-imerge b/git-imerge index 457fdab..9caa9e9 100755 --- a/git-imerge +++ b/git-imerge @@ -545,7 +545,7 @@ def reparent(commit, parent_sha1s, msg=None): return out.strip() -class AutomaticMergeFailed(Exception): +class LogicalFailure(Exception): def __init__(self, commit1, commit2): Exception.__init__( self, 'Automatic merge of %s and %s failed' % (commit1, commit2,) @@ -553,7 +553,76 @@ class AutomaticMergeFailed(Exception): self.commit1, self.commit2 = commit1, commit2 -def automerge(commit1, commit2, msg=None): +class AutomaticMergeFailed(LogicalFailure): + pass + + +class AutomaticTestFailed(LogicalFailure): + pass + + +class GitConfigError(Exception): + def __init__(self, returncode, output): + Exception.__init__( + self, 'Git config failed with exit code %s: %s' % (returncode, output,) + ) + + +def memo(obj): + cache = {} + @functools.wraps(obj) + def wrap(*args, **kwds): + if args not in cache: + cache[args] = obj(*args, **kwds) + return cache[args] + return wrap + + +@memo +class GitConfigStore(object): + def __init__(self, name, config_prefix='imerge'): + self.config_prefix = config_prefix + self.config = self._get_all_keys() + + def _get_all_keys(self): + d = {} + try: + items_with_prefix = check_output( + ['git', 'config', '--get-regex', self.config_prefix] + ).rstrip().split('\n') + for row in items_with_prefix: + k, v = row.split() + d[k[len(self.config_prefix + '.'):]] = v + return d + except CalledProcessError: + return {} + + def get(self, key): + return self.config.get(key) + + def set(self, key, value): + self.config[key] = value + config_key = '.'.join([self.config_prefix, key]) + try: + check_call(['git', 'config', config_key, value]) + except CalledProcessError as e: + raise GitConfigError(e.returncode, e.output) + + def unset(self, key): + if key in self.config: + del self.config[key] + config_key = '.'.join([self.config_prefix, key]) + try: + check_call(['git', 'config', '--unset', config_key]) + except CalledProcessError as e: + if e.returncode == 5: + # Value was not set + pass + else: + raise GitConfigError(e.returncode, e.output) + + +def automerge(commit1, commit2, msg=None, test_command=None): """Attempt an automatic merge of commit1 and commit2. Return the SHA1 of the resulting commit, or raise @@ -572,8 +641,14 @@ def automerge(commit1, commit2, msg=None): # added in git version 1.7.4. call_silently(['git', 'reset', '--merge']) raise AutomaticMergeFailed(commit1, commit2) - else: - return get_commit_sha1('HEAD') + + if test_command is not None: + try: + check_call(['/bin/sh', '-c', test_command]) + except CalledProcessError as e: + raise AutomaticTestFailed(commit1, commit2) + + return get_commit_sha1('HEAD') class MergeRecord(object): @@ -1374,6 +1449,7 @@ class Block(object): self.name = name self.len1 = len1 self.len2 = len2 + self.gcs = GitConfigStore(name) def get_merge_state(self): """Return the MergeState instance containing this Block.""" @@ -1474,11 +1550,17 @@ class Block(object): 'Attempting automerge of %d-%d...' % self.get_original_indexes(i1, i2) ) try: - automerge(self[i1, 0].sha1, self[0, i2].sha1) + print("Automerging from is_mergeable") + automerge(self[i1, 0].sha1, self[0, i2].sha1, + test_command=self.gcs.get(self.name + '.testcommand'), + ) sys.stderr.write('success.\n') return True except AutomaticMergeFailed: - sys.stderr.write('failure.\n') + sys.stderr.write('merge failure.\n') + return False + except AutomaticTestFailed: + sys.stderr.write('test failure.\n') return False def auto_outline(self): @@ -1497,7 +1579,10 @@ class Block(object): sys.stderr.write(msg % (i1orig, i2orig)) logmsg = 'imerge \'%s\': automatic merge %d-%d' % (self.name, i1orig, i2orig) try: - merge = automerge(commit1, commit2, msg=logmsg) + print("Automerging from auto_outline") + merge = automerge(commit1, commit2, msg=logmsg, + test_command=self.gcs.get(self.name + '.testcommand'), + ) sys.stderr.write('success.\n') except AutomaticMergeFailed as e: sys.stderr.write('unexpected conflict. Backtracking...\n') @@ -1571,10 +1656,12 @@ 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: + print("Automerging from auto_fill_micromerge") merge = automerge( self[i1, i2 - 1].sha1, self[i1 - 1, i2].sha1, msg=logmsg, + test_command=self.gcs.get(self.name + '.testcommand'), ) sys.stderr.write('success.\n') except AutomaticMergeFailed: @@ -1778,6 +1865,8 @@ class MergeState(Block): re.VERBOSE, ) + DEFAULT_TEST_COMMAND = None + @staticmethod def iter_existing_names(): """Iterate over the names of existing MergeStates in this repo.""" @@ -1838,26 +1927,19 @@ class MergeState(Block): """Set the default merge to the specified one. name can be None to cause the default to be cleared.""" - + gcs = GitConfigStore(name) 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 + gcs.unset("default") else: - check_call(['git', 'config', 'imerge.default', name]) + gcs.set("default", name) @staticmethod def get_default_name(): """Get the name of the default merge, or None if none is currently set.""" - + gcs = GitConfigStore(None) try: - return check_output(['git', 'config', 'imerge.default']).rstrip() - except CalledProcessError: + return gcs.get("default") + except GitConfigError: return None @staticmethod @@ -1891,7 +1973,7 @@ class MergeState(Block): name, merge_base, tip1, commits1, tip2, commits2, - goal=DEFAULT_GOAL, manual=False, branch=None, + goal=DEFAULT_GOAL, manual=False, branch=None, test_command=None, ): """Create and return a new MergeState object.""" @@ -1915,6 +1997,7 @@ class MergeState(Block): goal=goal, manual=manual, branch=branch, + test_command=test_command, ) @staticmethod @@ -2109,6 +2192,7 @@ class MergeState(Block): goal=DEFAULT_GOAL, manual=False, branch=None, + test_command=None, ): Block.__init__(self, name, len(commits1) + 1, len(commits2) + 1) self.tip1 = tip1 @@ -2647,6 +2731,20 @@ def read_merge_state(name=None, default_to_unique=True): @Failure.wrap def main(args): + def add_test_command_argument(subparser): + subparser.add_argument( + '--test-command', + action='store', default=None, + help=( + 'in addition to identifying for textual conflicts, run the test ' + 'or test script specified by TEST to identify where logical ' + 'conflicts are introduced. The test script is expected to return 0 ' + 'if the source is good, exit with code 1-127 if the source is bad, ' + 'except for exit code 125 which indicates the source code can not ' + 'be built or tested.' + ), + ) + parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, @@ -2675,6 +2773,7 @@ def main(args): action='store', default=None, help='the name of the branch to which the result will be stored', ) + add_test_command_argument(subparser) subparser.add_argument( '--manual', action='store_true', default=False, @@ -2716,6 +2815,7 @@ def main(args): action='store', default=None, help='the name of the branch to which the result will be stored', ) + add_test_command_argument(subparser) subparser.add_argument( '--manual', action='store_true', default=False, @@ -2754,6 +2854,7 @@ def main(args): action='store', default=None, help='the name of the branch to which the result will be stored', ) + add_test_command_argument(subparser) subparser.add_argument( '--manual', action='store_true', default=False, @@ -2894,6 +2995,7 @@ def main(args): action='store', default=None, help='the name of the branch to which the result will be stored', ) + add_test_command_argument(subparser) subparser.add_argument( '--manual', action='store_true', default=False, @@ -3040,9 +3142,14 @@ def main(args): tip2, commits2, goal=options.goal, manual=options.manual, branch=(options.branch or options.name), + test_command=options.test_command, ) merge_state.save() MergeState.set_default_name(options.name) + gcs = GitConfigStore(options.name) + if options.test_command is not None: + gcs.set(options.name + '.testcommand', options.test_command) + elif options.subcommand == 'start': require_clean_work_tree('proceed') @@ -3068,9 +3175,13 @@ def main(args): tip2, commits2, goal=options.goal, manual=options.manual, branch=(options.branch or options.name), + test_command=options.test_command, ) merge_state.save() MergeState.set_default_name(options.name) + gcs = GitConfigStore(options.name) + if options.test_command is not None: + gcs.set(options.name + '.testcommand', options.test_command) try: merge_state.auto_complete_frontier() @@ -3126,9 +3237,13 @@ def main(args): tip2, commits2, goal=options.goal, manual=options.manual, branch=options.branch, + test_command=options.test_command, ) merge_state.save() MergeState.set_default_name(name) + gcs = GitConfigStore(options.name) + if options.test_command is not None: + gcs.set(options.name + '.testcommand', options.test_command) try: merge_state.auto_complete_frontier() @@ -3188,9 +3303,13 @@ def main(args): tip2, commits2, goal=options.goal, manual=options.manual, branch=options.branch, + test_command=options.test_command, ) merge_state.save() MergeState.set_default_name(options.name) + gcs = GitConfigStore(options.name) + if options.test_command is not None: + gcs.set(options.name + '.testcommand', options.test_command) try: merge_state.auto_complete_frontier() @@ -3265,6 +3384,8 @@ def main(args): merge_state.save() merge_state.simplify(refname, force=options.force) MergeState.remove(merge_state.name) + gcs = GitConfigStore(merge_state.name) + gcs.unset(merge_state.name + '.testcommand') elif options.subcommand == 'diagram': if not (options.commits or options.frontier): options.frontier = True diff --git a/t/create-test-repo b/t/create-test-repo index d9e10a4..98230e7 100755 --- a/t/create-test-repo +++ b/t/create-test-repo @@ -1,6 +1,7 @@ #! /bin/sh set -e +set -x DESCRIPTION="git-imerge test repository" @@ -11,6 +12,20 @@ modify() { git add "$filename" } +modify_buildable() { + filename="$1" + text="$2" + cat > $filename << EOF +#include + +int main() +{ + $text +} +EOF + git add "$filename" +} + BASE="$(dirname "$(cd $(dirname "$0") && pwd)")" TMP="$BASE/t/tmp" @@ -79,6 +94,42 @@ do git commit -m "d$i" done +############### +# Build setup # +############### +git checkout -b build master -- +modify_buildable hello.c printf\(\"0\\n\"\)\; +cat > make_script << EOF +#/bin/bash -ex +gcc -o hello hello.c +EOF +chmod +x make_script +git add make_script +git commit -m "Hello World" + +git checkout -b build-left build -- +for i in $(seq 10) +do + modify_buildable hello.c 'printf("'$i' top\n"); + printf("'$i' bot\n");' + git commit -m "build $i" +done + +git checkout -b build-right build -- +modify_buildable hello.c printf\(\"2\\n\"\)\; +git commit -m "Conflicts" +modify_buildable hello.c printf\(\"2\\n\"\; +git commit -m "Conflicts, breaks build" +modify_buildable hello.c 'printf("'2' top\n"); + printf("'2' bot\n"); + typo' +git commit -m "No conflict, breaks build" +for i in $(seq 4 6) +do + modify_buildable hello.c printf\(\"$i\\n\"\)\; + git commit -m "build $i" +done + git checkout master -- ln -s ../../imerge.css diff --git a/t/reset-test-repo b/t/reset-test-repo index 03645b9..7acf006 100755 --- a/t/reset-test-repo +++ b/t/reset-test-repo @@ -20,3 +20,8 @@ do git update-ref -d refs/heads/$b done +"$GIT_IMERGE" remove --name=build-left-right +for b in build-left-right +do + git update-ref -d refs/heads/$b +done diff --git a/t/test-build b/t/test-build new file mode 100755 index 0000000..365acf5 --- /dev/null +++ b/t/test-build @@ -0,0 +1,60 @@ +#! /bin/sh + +# This should be executed in a clean working copy of the test repo. + +set -e +set -x + +modify_buildable() { + filename="$1" + text="$2" + cat > $filename << EOF +#include + +int main() +{ + $text +} +EOF + git add "$filename" +} + +BASE="$(dirname "$(cd $(dirname "$0") && pwd)")" +TMP="$BASE/t/tmp" +GIT_IMERGE="$BASE/git-imerge" + +cd "$TMP" + +# Clean up detritus from possible previous runs of this test: +git checkout master +"$GIT_IMERGE" remove --name=build-left-right || true +for b in build-left-right-full +do + git branch -D $b || true +done + +git checkout build-left +"$GIT_IMERGE" start --goal=rebase-with-history --first-parent \ + --test-command="./make_script" \ + --name=build-left-right --branch=build-left-right-full build-right + +# Resolve conflict +modify_buildable hello.c 'printf("'1' top\n"); + printf("'1' bot\n");' +GIT_EDITOR=cat git commit +"$GIT_IMERGE" continue --no-edit +# Resolve conflict which breaks build +modify_buildable hello.c printf\(\"2\\n\"\; +GIT_EDITOR=cat git commit +"$GIT_IMERGE" continue --no-edit +# Resolve the build breakage, no conflict +modify_buildable hello.c 'printf("'2' top\n"); + printf("'2' bot\n");' +GIT_EDITOR=cat git commit +"$GIT_IMERGE" continue --no-edit +# modify_buildable hello.c 'printf("'1' top\n"); +# printf("'1' bot\n");' +# GIT_EDITOR=cat git commit +# "$GIT_IMERGE" continue --no-edit + +# "$GIT_IMERGE" finish