Repository: iterative/dvc

Issue:
Config: Cache dir is inconsistant on Unix and Windows
dvc version = 0.93.0
Platform = Ubuntu and Windows 10

On Windows, calling `dvc cache dir ..\xyz` results in an entry in `.dvc/config` like

    [cache]
        dir = ..\..\xyz

On Linux it results in 

    [cache]
        dir = ../../xyz

This inconsistency results in noise in the git history when you work sometimes on Windows, sometimes on Linux. 

Manually editing the file to a Posix-style path on Windows works fine. 

Discussion:
charlesbaynham: Unit test added and functional tests updates
efiop: @charlesbaynham Looks great! :tada:  Just a few minor finishing touches left.

Patch:
diff --git a/dvc/config.py b/dvc/config.py
index 1fc63acbb..224e1ac28 100644
--- a/dvc/config.py
+++ b/dvc/config.py
@@ -1,16 +1,27 @@
 """DVC config objects."""
-from contextlib import contextmanager
 import logging
 import os
 import re
+from contextlib import contextmanager
+from functools import partial
 from urllib.parse import urlparse
 
-from funcy import cached_property, re_find, walk_values, compact
 import configobj
-from voluptuous import Schema, Optional, Invalid, ALLOW_EXTRA
-from voluptuous import All, Any, Lower, Range, Coerce
+from funcy import cached_property, compact, re_find, walk_values
+from voluptuous import (
+    ALLOW_EXTRA,
+    All,
+    Any,
+    Coerce,
+    Invalid,
+    Lower,
+    Optional,
+    Range,
+    Schema,
+)
 
 from dvc.exceptions import DvcException, NotDvcRepoError
+from dvc.path_info import PathInfo
 from dvc.utils import relpath
 
 logger = logging.getLogger(__name__)
@@ -312,16 +323,19 @@ class Config(dict):
         return Config._map_dirs(conf, resolve)
 
     @staticmethod
-    def _save_paths(conf, filename):
-        conf_dir = os.path.dirname(filename)
+    def _to_relpath(conf_dir, path):
+        if re.match(r"\w+://", path):
+            return path
 
-        def rel(path):
-            if re.match(r"\w+://", path):
-                return path
+        if isinstance(path, RelPath) or not os.path.isabs(path):
+            path = relpath(path, conf_dir)
 
-            if isinstance(path, RelPath) or not os.path.isabs(path):
-                return relpath(path, conf_dir)
-            return path
+        return PathInfo(path).as_posix()
+
+    @staticmethod
+    def _save_paths(conf, filename):
+        conf_dir = os.path.dirname(filename)
+        rel = partial(Config._to_relpath, conf_dir)
 
         return Config._map_dirs(conf, rel)
 


Test patch:
diff --git a/tests/func/test_cache.py b/tests/func/test_cache.py
index 8d692938d..841e06f1c 100644
--- a/tests/func/test_cache.py
+++ b/tests/func/test_cache.py
@@ -8,8 +8,7 @@ from dvc.cache import Cache
 from dvc.main import main
 from dvc.remote.base import DirCacheError
 from dvc.utils import relpath
-from tests.basic_env import TestDir
-from tests.basic_env import TestDvc
+from tests.basic_env import TestDir, TestDvc
 
 
 class TestCache(TestDvc):
@@ -155,7 +154,7 @@ class TestCmdCacheDir(TestDvc):
         self.assertEqual(ret, 0)
 
         config = configobj.ConfigObj(self.dvc.config.files["repo"])
-        self.assertEqual(config["cache"]["dir"], dname)
+        self.assertEqual(config["cache"]["dir"], dname.replace("\\", "/"))
 
     def test_relative_path(self):
         tmpdir = self.mkdtemp()
@@ -167,7 +166,7 @@ class TestCmdCacheDir(TestDvc):
         # dir path written to config should be just one level above.
         rel = os.path.join("..", dname)
         config = configobj.ConfigObj(self.dvc.config.files["repo"])
-        self.assertEqual(config["cache"]["dir"], rel)
+        self.assertEqual(config["cache"]["dir"], rel.replace("\\", "/"))
 
         ret = main(["add", self.FOO])
         self.assertEqual(ret, 0)
diff --git a/tests/func/test_remote.py b/tests/func/test_remote.py
index 4369203dd..0d3f9e9f5 100644
--- a/tests/func/test_remote.py
+++ b/tests/func/test_remote.py
@@ -47,7 +47,9 @@ class TestRemote(TestDvc):
         # dir path written to config should be just one level above.
         rel = os.path.join("..", dname)
         config = configobj.ConfigObj(self.dvc.config.files["repo"])
-        self.assertEqual(config['remote "mylocal"']["url"], rel)
+        self.assertEqual(
+            config['remote "mylocal"']["url"], rel.replace("\\", "/")
+        )
 
     def test_overwrite(self):
         remote_name = "a"
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
new file mode 100644
index 000000000..e0899cbf3
--- /dev/null
+++ b/tests/unit/test_config.py
@@ -0,0 +1,17 @@
+import os
+import pytest
+
+from dvc.config import Config
+
+
+@pytest.mark.parametrize(
+    "path, expected",
+    [
+        ("cache", "../cache"),
+        (os.path.join("..", "cache"), "../../cache"),
+        (os.getcwd(), os.getcwd().replace("\\", "/")),
+        ("ssh://some/path", "ssh://some/path"),
+    ],
+)
+def test_to_relpath(path, expected):
+    assert Config._to_relpath(os.path.join(".", "config"), path) == expected