From 22568571c2c7c74ebaaf27edd9b4cc2f613e2c53 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Mon, 19 Sep 2016 16:32:35 +0200 Subject: [PATCH] Introduce conflict resolution via commands Fix #127 More options of conflict resolution are discussed there, but they all require extra dependencies. This new API allows the user to plug in third-party scripts to do those. --- CHANGELOG.rst | 2 ++ docs/config.rst | 14 ++++++++-- tests/cli/test_sync.py | 40 ++++++++++++++++++++++++++++ vdirsyncer/cli/config.py | 56 +++++++++++++++++++++++++++++++++++++++- vdirsyncer/cli/tasks.py | 2 +- 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 60a664d..bea9d5f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,8 @@ Version 0.13.0 cannot have a storage section ``[storage foo]`` and a pair ``[pair foo]`` in your config, they have to have different names. This is done such that console output is always unambigous. See :gh:`459`. +- Custom commands can now be used for conflict resolution during sync. See + :gh:`127`. Version 0.12.1 ============== diff --git a/docs/config.rst b/docs/config.rst index b8ba8e9..449421e 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -87,9 +87,19 @@ Pair Section Valid values are: + - ``null``, where an error is shown and no changes are done. - ``"a wins"`` and ``"b wins"``, where the whole item is taken from one side. - Vdirsyncer will not attempt to merge the two items. - - ``null``, the default, where an error is shown and no changes are done. + - ``["command", "vimdiff"]``: ``vimdiff `` will be called where + ```` and ```` are temporary files that contain the item of each side + respectively. The files need to be exactly the same when the command + returns. + + - ``vimdiff`` can be replaced with any other command. For example, in POSIX + ``["command", "cp"]`` is equivalent to ``"a wins"``. + - Additional list items will be forwarded as arguments. For example, + ``["command", "vimdiff", "--noplugin"]`` runs ``vimdiff --noplugin``. + + Vdirsyncer never attempts to "automatically merge" the two items. - ``metadata``: Metadata keys that should be synchronized when ``vdirsyncer metasync`` is executed. Example:: diff --git a/tests/cli/test_sync.py b/tests/cli/test_sync.py index 843ca34..0c823a6 100644 --- a/tests/cli/test_sync.py +++ b/tests/cli/test_sync.py @@ -409,3 +409,43 @@ def test_no_configured_pairs(tmpdir, runner, cmd): result = runner.invoke([cmd]) assert result.output == 'critical: Nothing to do.\n' assert result.exception.code == 5 + + +@pytest.mark.parametrize('resolution,expect_foo,expect_bar', [ + (['command', 'cp'], 'UID:lol\nfööcontent', 'UID:lol\nfööcontent') +]) +def test_conflict_resolution(tmpdir, runner, resolution, expect_foo, + expect_bar): + runner.write_with_general(dedent(''' + [pair foobar] + a = foo + b = bar + collections = null + conflict_resolution = {val} + + [storage foo] + type = filesystem + fileext = .txt + path = {base}/foo + + [storage bar] + type = filesystem + fileext = .txt + path = {base}/bar + '''.format(base=str(tmpdir), val=json.dumps(resolution)))) + + foo = tmpdir.join('foo') + bar = tmpdir.join('bar') + fooitem = foo.join('lol.txt').ensure() + fooitem.write('UID:lol\nfööcontent') + baritem = bar.join('lol.txt').ensure() + baritem.write('UID:lol\nbööcontent') + + r = runner.invoke(['discover']) + assert not r.exception + + r = runner.invoke(['sync']) + assert not r.exception + + assert fooitem.read() == expect_foo + assert baritem.read() == expect_bar diff --git a/vdirsyncer/cli/config.py b/vdirsyncer/cli/config.py index a33fd2b..91e86d0 100644 --- a/vdirsyncer/cli/config.py +++ b/vdirsyncer/cli/config.py @@ -3,6 +3,8 @@ import os import string from itertools import chain +from click_threading import get_ui_worker + from . import cli_logger from .fetchparams import expand_fetch_params from .. import PROJECT_HOME, exceptions @@ -251,12 +253,32 @@ class PairConfig(object): self.name_b = options.pop('b') self.options = options - conflict_resolution = self.options.get('conflict_resolution', None) + self._set_conflict_resolution() + self._set_collections() + + def _set_conflict_resolution(self): + conflict_resolution = self.options.pop('conflict_resolution', None) if conflict_resolution in (None, 'a wins', 'b wins'): self.conflict_resolution = conflict_resolution + elif isinstance(conflict_resolution, list) and \ + len(conflict_resolution) > 1 and \ + conflict_resolution[0] == 'command': + def resolve(a, b): + a_name = self.config_a['instance_name'] + b_name = self.config_b['instance_name'] + command = conflict_resolution[1:] + def inner(): + return resolve_conflict_via_command(a, b, command, a_name, + b_name) + + ui_worker = get_ui_worker() + ui_worker.put(inner) + + self.conflict_resolution = resolve else: raise ValueError('Invalid value for `conflict_resolution`.') + def _set_collections(self): try: collections = self.options['collections'] except KeyError: @@ -279,3 +301,35 @@ class CollectionConfig(object): self.name = name self.config_a = config_a self.config_b = config_b + + +def resolve_conflict_via_command(a, b, command, a_name, b_name): + import tempfile + import shutil + import subprocess + + from ..utils.vobject import Item + + dir = tempfile.mkdtemp(prefix='vdirsyncer-conflict.') + try: + a_tmp = os.path.join(dir, a_name) + b_tmp = os.path.join(dir, b_name) + + with open(a_tmp, 'w') as f: + f.write(a.raw) + with open(b_tmp, 'w') as f: + f.write(b.raw) + + subprocess.check_call(command + [a_tmp, b_tmp]) + + with open(a_tmp) as f: + new_a = f.read() + with open(b_tmp) as f: + new_b = f.read() + + if new_a != new_b: + raise exceptions.UserError('The two files are not completely ' + 'equal.') + return Item(new_a) + finally: + shutil.rmtree(dir) diff --git a/vdirsyncer/cli/tasks.py b/vdirsyncer/cli/tasks.py index 96e5574..ea52234 100644 --- a/vdirsyncer/cli/tasks.py +++ b/vdirsyncer/cli/tasks.py @@ -58,7 +58,7 @@ def sync_collection(wq, collection, general, force_delete): b = storage_instance_from_config(collection.config_b) sync( a, b, status, - conflict_resolution=pair.options.get('conflict_resolution', None), + conflict_resolution=pair.conflict_resolution, force_delete=force_delete ) except: