mirror of
https://github.com/samsonjs/vdirsyncer.git
synced 2026-03-25 08:55:50 +00:00
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:
parent
e62e3a26f6
commit
22568571c2
5 changed files with 110 additions and 4 deletions
|
|
@ -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
|
||||||
==============
|
==============
|
||||||
|
|
|
||||||
|
|
@ -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::
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue