Skip to content

Commit

Permalink
Merge pull request #94 from koordinates/merge_continue
Browse files Browse the repository at this point in the history
Support `git merge --continue`
  • Loading branch information
olsen232 authored May 28, 2020
2 parents dc7a9aa + fd3ee92 commit 5540ed9
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 141 deletions.
15 changes: 4 additions & 11 deletions sno/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@
query,
upgrade,
)
from .cli_util import call_and_exit_flag
from .context import Context
from .exec import execvp


def print_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return

def print_version(ctx):
import apsw
import osgeo
import rtree
Expand Down Expand Up @@ -84,13 +82,8 @@ def print_version(ctx, param, value):
default=None,
metavar="PATH",
)
@click.option(
"--version",
is_flag=True,
callback=print_version,
expose_value=False,
is_eager=True,
help="Show version information and exit.",
@call_and_exit_flag(
"--version", callback=print_version, help="Show version information and exit.",
)
@click.option("-v", "--verbose", count=True, help="Repeat for more verbosity")
@click.pass_context
Expand Down
23 changes: 23 additions & 0 deletions sno/cli_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,26 @@ def do_json_option(func):
default=False,
help="Whether to format the out output as JSON instead the default text output.",
)(func)


def call_and_exit_flag(*args, callback, **kwargs):
"""
Add an is_flag option that, when set, eagerly calls the given callback with only the context as a parameter.
The callback may want to exit the program once it has completed, using ctx.exit(0)
Usage:
@call_and_exit_flag("--version", callback=print_version, help="Print the version number")
"""

def actual_callback(ctx, param, value):
if value and not ctx.resilient_parsing:
callback(ctx)
ctx.exit()

return click.option(
*args,
is_flag=True,
callback=actual_callback,
expose_value=False,
is_eager=True,
**kwargs,
)
21 changes: 7 additions & 14 deletions sno/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from sno import is_windows
from . import gpkg, checkout, structure
from .core import check_git_user
from .cli_util import do_json_option
from .cli_util import call_and_exit_flag, do_json_option
from .exceptions import (
InvalidOperation,
NotFound,
Expand Down Expand Up @@ -503,12 +503,10 @@ def build_meta_info(self):
yield from gpkg.get_meta_info(db, layer=self.table)


def list_import_formats(ctx, param, value):
def list_import_formats(ctx):
"""
List the supported import formats
"""
if not value or ctx.resilient_parsing:
return
names = set()
for prefix, ogr_driver_name in FORMAT_TO_OGR_MAP.items():
d = gdal.GetDriverByName(ogr_driver_name)
Expand All @@ -519,7 +517,6 @@ def list_import_formats(ctx, param, value):
names.add(prefix)
for n in sorted(names):
click.echo(n)
ctx.exit()


@click.command("import")
Expand All @@ -538,21 +535,17 @@ def list_import_formats(ctx, param, value):
@click.option(
"--list", "do_list", is_flag=True, help="List all tables present in the source path"
)
@click.option(
"--list-formats",
is_flag=True,
help="List available import formats, and then exit",
# https://click.palletsprojects.com/en/7.x/options/#callbacks-and-eager-options
is_eager=True,
expose_value=False,
callback=list_import_formats,
)
@click.option(
"--version",
type=click.Choice(structure.DatasetStructure.version_numbers()),
default=structure.DatasetStructure.version_numbers()[0],
hidden=True,
)
@call_and_exit_flag(
"--list-formats",
callback=list_import_formats,
help="List available import formats, and then exit",
)
@do_json_option
def import_table(ctx, source, directory, table, do_list, do_json, version):
"""
Expand Down
207 changes: 125 additions & 82 deletions sno/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import click

from .cli_util import do_json_option
from .cli_util import do_json_option, call_and_exit_flag
from .conflicts import (
list_conflicts,
conflicts_json_as_text,
Expand All @@ -12,14 +12,11 @@
from .merge_util import AncestorOursTheirs, MergeIndex, MergeContext
from .output_util import dump_json_output
from .repo_files import (
ORIG_HEAD,
MERGE_HEAD,
MERGE_MSG,
MERGE_INDEX,
MERGE_LABELS,
repo_file_path,
write_repo_file,
remove_repo_file,
read_repo_file,
remove_all_merge_repo_files,
is_ongoing_merge,
repo_file_exists,
)
Expand All @@ -31,75 +28,6 @@
L = logging.getLogger("sno.merge")


def merge_abort(ctx, param, value):
if value:
repo = ctx.obj.repo
abort_merging_state(repo)
ctx.exit()


@click.command()
@click.option(
"--ff/--no-ff",
default=True,
help=(
"When the merge resolves as a fast-forward, only update the branch pointer, without creating a merge commit. "
"With --no-ff create a merge commit even when the merge resolves as a fast-forward."
),
)
@click.option(
"--ff-only",
default=False,
is_flag=True,
help=(
"Refuse to merge and exit with a non-zero status unless the current HEAD is already up to date "
"or the merge can be resolved as a fast-forward."
),
)
@click.option(
"--dry-run",
is_flag=True,
help="Don't perform a merge - just show what would be done",
)
@click.option(
'--abort',
is_flag=True,
callback=merge_abort,
expose_value=False,
is_eager=True,
help="Abandon an ongoing merge, revert repository to the state before the merge began",
)
@click.argument("commit", required=True, metavar="COMMIT")
@do_json_option
@click.pass_context
def merge(ctx, ff, ff_only, dry_run, do_json, commit):
""" Incorporates changes from the named commits (usually other branch heads) into the current branch. """

repo = ctx.obj.repo
if is_ongoing_merge(repo):
raise InvalidOperation("A merge is already ongoing")

merge_jdict = do_merge(repo, ff, ff_only, dry_run, commit)
no_op = merge_jdict.get("noOp", False) or merge_jdict.get("dryRun", False)
conflicts = merge_jdict.get("conflicts", None)

if not no_op and not conflicts:
# Update working copy.
# TODO - maybe lock the working copy during a merge?
repo_structure = RepositoryStructure(repo)
wc = repo_structure.working_copy
if wc:
L.debug(f"Updating {wc.path} ...")
merge_commit = repo[merge_jdict["mergeCommit"]]
wc.reset(merge_commit, repo_structure)

if do_json:
jdict = {"sno.merge/v1": merge_jdict}
dump_json_output(jdict, sys.stdout)
else:
output_merge_json_as_text(merge_jdict)


def do_merge(repo, ff, ff_only, dry_run, commit):
"""Does a merge, but doesn't update the working copy."""
if ff_only and not ff:
Expand Down Expand Up @@ -207,24 +135,73 @@ def move_repo_to_merging_state(repo, merge_index, merge_context, merge_message):
write_repo_file(repo, MERGE_MSG, merge_message)


def abort_merging_state(repo):
def abort_merging_state(ctx):
"""
Put things back how they were before the merge began.
Tries to be robust against failure, in case the user has messed up the repo's state.
"""
repo = ctx.obj.repo
is_ongoing_merge = repo_file_exists(repo, MERGE_HEAD)
# If we are in a merge, we now need to delete all the MERGE_* files.
# If we are not in a merge, we should clean them up anyway.
remove_repo_file(repo, MERGE_HEAD)
remove_repo_file(repo, MERGE_MSG)
remove_repo_file(repo, MERGE_INDEX)
remove_repo_file(repo, MERGE_LABELS)
remove_all_merge_repo_files(repo)

if not is_ongoing_merge:
raise InvalidOperation('Repository is not in "merging" state.')

# TODO - maybe restore HEAD to ORIG_HEAD.
# Not sure if it matters - we don't modify HEAD when we move into merging state.

def complete_merging_state(ctx):
"""
Completes a merge that had conflicts - commits the result of the merge, and
moves the repo from merging state back into the normal state, with the branch
HEAD now at the merge commit. Only works if all conflicts have been resolved.
"""
repo = ctx.obj.repo
if not is_ongoing_merge(repo):
raise InvalidOperation('Repository is not in "merging" state.')
merge_index = MergeIndex.read_from_repo(repo)
if merge_index.unresolved_conflicts:
raise InvalidOperation(
"Merge cannot be completed until conflicts are resolved."
)

merge_context = MergeContext.read_from_repo(repo)
commit_ids = merge_context.versions.map(lambda v: v.repo_structure.id)
merge_message = read_repo_file(repo, MERGE_MSG)

merge_jdict = {
"branch": CommitWithReference.resolve(repo, "HEAD").shorthand,
"ancestor": commit_ids.ancestor,
"ours": commit_ids.ours,
"theirs": commit_ids.theirs,
"message": merge_message,
}

merge_tree_id = merge_index.write_resolved_tree(repo)
L.debug(f"Merge tree: {merge_tree_id}")

user = repo.default_signature
merge_commit_id = repo.create_commit(
repo.head.name,
user,
user,
merge_message,
merge_tree_id,
[commit_ids.ours, commit_ids.theirs],
)

L.debug(f"Merge commit: {merge_commit_id}")
merge_jdict["mergeCommit"] = merge_commit_id.hex

repo_structure = RepositoryStructure(repo)
wc = repo_structure.working_copy
if wc:
L.debug(f"Updating {wc.path} ...")
merge_commit = repo[merge_commit_id]
wc.reset(merge_commit, repo_structure)

# TODO - support json output
output_merge_json_as_text(merge_jdict)


def output_merge_json_as_text(jdict):
Expand Down Expand Up @@ -267,3 +244,69 @@ def output_merge_json_as_text(jdict):
# TODO: explain how to resolve conflicts, when this is possible
click.echo("Sorry, resolving merge conflicts is not yet supported", err=True)
click.echo("Use `sno merge --abort` to abort this merge", err=True)


@click.command()
@click.option(
"--ff/--no-ff",
default=True,
help=(
"When the merge resolves as a fast-forward, only update the branch pointer, without creating a merge commit. "
"With --no-ff create a merge commit even when the merge resolves as a fast-forward."
),
)
@click.option(
"--ff-only",
default=False,
is_flag=True,
help=(
"Refuse to merge and exit with a non-zero status unless the current HEAD is already up to date "
"or the merge can be resolved as a fast-forward."
),
)
@click.option(
"--dry-run",
is_flag=True,
help="Don't perform a merge - just show what would be done",
)
@call_and_exit_flag(
'--abort',
callback=abort_merging_state,
help="Abandon an ongoing merge, revert repository to the state before the merge began",
)
@call_and_exit_flag(
'--continue',
callback=complete_merging_state,
help="Completes and commits a merge once all conflicts are resolved and leaves the merging state",
)
@click.argument("commit", required=True, metavar="COMMIT")
@do_json_option
@click.pass_context
def merge(ctx, ff, ff_only, dry_run, do_json, commit):
""" Incorporates changes from the named commits (usually other branch heads) into the current branch. """

repo = ctx.obj.repo
if is_ongoing_merge(repo):
raise InvalidOperation(
"A merge is already ongoing - see `sno merge --abort` or `sno merge --continue`"
)

merge_jdict = do_merge(repo, ff, ff_only, dry_run, commit)
no_op = merge_jdict.get("noOp", False) or merge_jdict.get("dryRun", False)
conflicts = merge_jdict.get("conflicts", None)

if not no_op and not conflicts:
# Update working copy.
# TODO - maybe lock the working copy during a merge?
repo_structure = RepositoryStructure(repo)
wc = repo_structure.working_copy
if wc:
L.debug(f"Updating {wc.path} ...")
merge_commit = repo[merge_jdict["mergeCommit"]]
wc.reset(merge_commit, repo_structure)

if do_json:
jdict = {"sno.merge/v1": merge_jdict}
dump_json_output(jdict, sys.stdout)
else:
output_merge_json_as_text(merge_jdict)
Loading

0 comments on commit 5540ed9

Please sign in to comment.