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.
This commit is contained in:
Markus Unterwaditzer 2016-09-19 16:32:35 +02:00
parent e62e3a26f6
commit 22568571c2
5 changed files with 110 additions and 4 deletions

View file

@ -17,6 +17,8 @@ Version 0.13.0
cannot have a storage section ``[storage foo]`` and a pair ``[pair foo]`` in 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 your config, they have to have different names. This is done such that
console output is always unambigous. See :gh:`459`. 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 Version 0.12.1
============== ==============

View file

@ -87,9 +87,19 @@ Pair Section
Valid values are: 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. - ``"a wins"`` and ``"b wins"``, where the whole item is taken from one side.
Vdirsyncer will not attempt to merge the two items. - ``["command", "vimdiff"]``: ``vimdiff <a> <b>`` will be called where
- ``null``, the default, where an error is shown and no changes are done. ``<a>`` and ``<b>`` 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 - ``metadata``: Metadata keys that should be synchronized when ``vdirsyncer
metasync`` is executed. Example:: metasync`` is executed. Example::

View file

@ -409,3 +409,43 @@ def test_no_configured_pairs(tmpdir, runner, cmd):
result = runner.invoke([cmd]) result = runner.invoke([cmd])
assert result.output == 'critical: Nothing to do.\n' assert result.output == 'critical: Nothing to do.\n'
assert result.exception.code == 5 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

View file

@ -3,6 +3,8 @@ import os
import string import string
from itertools import chain from itertools import chain
from click_threading import get_ui_worker
from . import cli_logger from . import cli_logger
from .fetchparams import expand_fetch_params from .fetchparams import expand_fetch_params
from .. import PROJECT_HOME, exceptions from .. import PROJECT_HOME, exceptions
@ -251,12 +253,32 @@ class PairConfig(object):
self.name_b = options.pop('b') self.name_b = options.pop('b')
self.options = options 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'): if conflict_resolution in (None, 'a wins', 'b wins'):
self.conflict_resolution = conflict_resolution 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: else:
raise ValueError('Invalid value for `conflict_resolution`.') raise ValueError('Invalid value for `conflict_resolution`.')
def _set_collections(self):
try: try:
collections = self.options['collections'] collections = self.options['collections']
except KeyError: except KeyError:
@ -279,3 +301,35 @@ class CollectionConfig(object):
self.name = name self.name = name
self.config_a = config_a self.config_a = config_a
self.config_b = config_b 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)

View file

@ -58,7 +58,7 @@ def sync_collection(wq, collection, general, force_delete):
b = storage_instance_from_config(collection.config_b) b = storage_instance_from_config(collection.config_b)
sync( sync(
a, b, status, a, b, status,
conflict_resolution=pair.options.get('conflict_resolution', None), conflict_resolution=pair.conflict_resolution,
force_delete=force_delete force_delete=force_delete
) )
except: except: