Repository: iterative/dvc

Issue:
api: does not work with git only local repository
```sh
➜ cd "$(mktemp -d)" 
➜ git init
Initialized empty Git repository in /tmp/tmp.TGV6SVXYsz/.git/

➜ echo "foo" > foo
➜ git add foo
➜ git -c commit.gpgsign=false commit -m "first"
[master (root-commit) 136fd94] first
 1 file changed, 1 insertion(+)
 create mode 100644 foo

➜ python -c '
➜ import dvc.api
➜ with dvc.api.open(path="foo", repo=".") as fd:
➜     print(fd.read())
➜ '
Traceback (most recent call last):
  File "<string>", line 3, in <module>
  File "/home/saugat/.pyenv/versions/3.6.6/lib/python3.6/contextlib.py", line 81, in __enter__
    return next(self.gen)
  File "/home/saugat/repos/iterative/dvc/dvc/api.py", line 95, in _open
    with _make_repo(repo, rev=rev) as _repo:
  File "/home/saugat/.pyenv/versions/3.6.6/lib/python3.6/contextlib.py", line 81, in __enter__
    return next(self.gen)
  File "/home/saugat/repos/iterative/dvc/dvc/api.py", line 113, in _make_repo
    yield Repo(repo_url)
  File "/home/saugat/repos/iterative/dvc/dvc/repo/__init__.py", line 76, in __init__
    root_dir = self.find_root(root_dir)
  File "/home/saugat/repos/iterative/dvc/dvc/repo/__init__.py", line 144, in find_root
    raise NotDvcRepoError(root)
dvc.exceptions.NotDvcRepoError: you are not inside of a dvc repository (checked up to mount point '/tmp')
```

This is because of the recent changes in `_make_repo` that instead of falling back to external repo on error, it assumes the local path to be a DVC repository.
https://github.com/iterative/dvc/blob/e9caa44b554daf7f0f21dc2bb9bb0df028369fb1/dvc/api.py#L112-L113

Discussion:
efiop: @skshetry Please check the tests, they've failed.
codecov[bot]: # [Codecov](https://codecov.io/gh/iterative/dvc/pull/3222?src=pr&el=h1) Report
> Merging [#3222](https://codecov.io/gh/iterative/dvc/pull/3222?src=pr&el=desc) into [master](https://codecov.io/gh/iterative/dvc/commit/e9caa44b554daf7f0f21dc2bb9bb0df028369fb1?src=pr&el=desc) will **decrease** coverage by `0.36%`.
> The diff coverage is `80%`.

[![Impacted file tree graph](https://codecov.io/gh/iterative/dvc/pull/3222/graphs/tree.svg?width=650&token=VOfa1EJWC4&height=150&src=pr)](https://codecov.io/gh/iterative/dvc/pull/3222?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #3222      +/-   ##
==========================================
- Coverage   91.68%   91.31%   -0.37%     
==========================================
  Files         139      139              
  Lines        8681     8681              
==========================================
- Hits         7959     7927      -32     
- Misses        722      754      +32
```


| [Impacted Files](https://codecov.io/gh/iterative/dvc/pull/3222?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [dvc/cache.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL2NhY2hlLnB5) | `98.48% <ø> (ø)` | :arrow_up: |
| [dvc/remote/config.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL3JlbW90ZS9jb25maWcucHk=) | `98.3% <ø> (ø)` | :arrow_up: |
| [dvc/command/daemon.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL2NvbW1hbmQvZGFlbW9uLnB5) | `66.66% <ø> (ø)` | :arrow_up: |
| [dvc/scm/git/\_\_init\_\_.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL3NjbS9naXQvX19pbml0X18ucHk=) | `88.39% <ø> (ø)` | :arrow_up: |
| [dvc/command/pipeline.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL2NvbW1hbmQvcGlwZWxpbmUucHk=) | `95.45% <ø> (ø)` | :arrow_up: |
| [dvc/progress.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL3Byb2dyZXNzLnB5) | `98.11% <ø> (ø)` | :arrow_up: |
| [dvc/scm/\_\_init\_\_.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL3NjbS9fX2luaXRfXy5weQ==) | `100% <ø> (ø)` | :arrow_up: |
| [dvc/config.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL2NvbmZpZy5weQ==) | `95.1% <ø> (ø)` | :arrow_up: |
| [dvc/lock.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL2xvY2sucHk=) | `68.05% <ø> (ø)` | :arrow_up: |
| [dvc/command/cache.py](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree#diff-ZHZjL2NvbW1hbmQvY2FjaGUucHk=) | `100% <ø> (ø)` | :arrow_up: |
| ... and [15 more](https://codecov.io/gh/iterative/dvc/pull/3222/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/iterative/dvc/pull/3222?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/iterative/dvc/pull/3222?src=pr&el=footer). Last update [e9caa44...908f5c6](https://codecov.io/gh/iterative/dvc/pull/3222?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/dvc/api.py b/dvc/api.py
index 2ae39bae7..7efaf524a 100644
--- a/dvc/api.py
+++ b/dvc/api.py
@@ -10,7 +10,7 @@ import ruamel.yaml
 from voluptuous import Schema, Required, Invalid
 
 from dvc.repo import Repo
-from dvc.exceptions import DvcException
+from dvc.exceptions import DvcException, NotDvcRepoError
 from dvc.external_repo import external_repo
 
 
@@ -108,12 +108,16 @@ def read(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
 
 
 @contextmanager
-def _make_repo(repo_url, rev=None):
-    if rev is None and (not repo_url or os.path.exists(repo_url)):
-        yield Repo(repo_url)
-    else:
-        with external_repo(url=repo_url, rev=rev) as repo:
-            yield repo
+def _make_repo(repo_url=None, rev=None):
+    repo_url = repo_url or os.getcwd()
+    if rev is None and os.path.exists(repo_url):
+        try:
+            yield Repo(repo_url)
+            return
+        except NotDvcRepoError:
+            pass  # fallthrough to external_repo
+    with external_repo(url=repo_url, rev=rev) as repo:
+        yield repo
 
 
 def summon(name, repo=None, rev=None, summon_file="dvcsummon.yaml", args=None):
diff --git a/dvc/external_repo.py b/dvc/external_repo.py
index 470646c6e..306e7d7c6 100644
--- a/dvc/external_repo.py
+++ b/dvc/external_repo.py
@@ -128,10 +128,10 @@ class ExternalGitRepo:
             raise PathMissingError(path, self.url)
 
     @contextmanager
-    def open_by_relpath(self, path, mode="r", encoding=None):
+    def open_by_relpath(self, path, mode="r", encoding=None, **kwargs):
         try:
             abs_path = os.path.join(self.root_dir, path)
-            with open(abs_path, mode, encoding) as fd:
+            with open(abs_path, mode, encoding=encoding) as fd:
                 yield fd
         except FileNotFoundError:
             raise PathMissingError(path, self.url)
diff --git a/dvc/scm/git/__init__.py b/dvc/scm/git/__init__.py
index 2d334a649..3872c8e72 100644
--- a/dvc/scm/git/__init__.py
+++ b/dvc/scm/git/__init__.py
@@ -250,7 +250,7 @@ class Git(Base):
     def _install_hook(self, name, cmd):
         command = (
             '[ "$3" = "0" ]'
-            ' || [ -z "$(git ls-files --full-name .dvc)" ]'
+            ' || [ -z "$(git ls-files .dvc)" ]'
             " || exec dvc {}".format(cmd)
         )
 


Test patch:
diff --git a/tests/func/test_add.py b/tests/func/test_add.py
index 1ce44a0b0..18d77d39e 100644
--- a/tests/func/test_add.py
+++ b/tests/func/test_add.py
@@ -9,7 +9,7 @@ import colorama
 import pytest
 from mock import patch
 
-import dvc as dvc_module
+import dvc
 from dvc.cache import Cache
 from dvc.exceptions import DvcException
 from dvc.exceptions import RecursiveAddingWhileUsingFilename
@@ -28,6 +28,7 @@ from dvc.utils.fs import path_isin
 from dvc.utils.stage import load_stage_file
 from tests.basic_env import TestDvc
 from tests.utils import get_gitignore_content
+from tests.utils import spy
 
 
 def test_add(tmp_dir, dvc):
@@ -244,66 +245,66 @@ class TestDoubleAddUnchanged(TestDvc):
         self.assertEqual(ret, 0)
 
 
-def test_should_update_state_entry_for_file_after_add(mocker, dvc, tmp_dir):
-    file_md5_counter = mocker.spy(dvc_module.remote.local, "file_md5")
-    tmp_dir.gen("foo", "foo")
-
-    ret = main(["config", "cache.type", "copy"])
-    assert ret == 0
-
-    ret = main(["add", "foo"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 1
+class TestShouldUpdateStateEntryForFileAfterAdd(TestDvc):
+    def test(self):
+        file_md5_counter = spy(dvc.remote.local.file_md5)
+        with patch.object(dvc.remote.local, "file_md5", file_md5_counter):
+            ret = main(["config", "cache.type", "copy"])
+            self.assertEqual(ret, 0)
 
-    ret = main(["status"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 1
+            ret = main(["add", self.FOO])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 1)
 
-    ret = main(["run", "-d", "foo", "echo foo"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 1
+            ret = main(["status"])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 1)
 
-    os.rename("foo", "foo.back")
-    ret = main(["checkout"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 1
+            ret = main(["run", "-d", self.FOO, "echo foo"])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 1)
 
-    ret = main(["status"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 1
+            os.rename(self.FOO, self.FOO + ".back")
+            ret = main(["checkout"])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 1)
 
+            ret = main(["status"])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 1)
 
-def test_should_update_state_entry_for_directory_after_add(
-    mocker, dvc, tmp_dir
-):
-    file_md5_counter = mocker.spy(dvc_module.remote.local, "file_md5")
 
-    tmp_dir.gen({"data/data": "foo", "data/data_sub/sub_data": "foo"})
+class TestShouldUpdateStateEntryForDirectoryAfterAdd(TestDvc):
+    def test(self):
+        file_md5_counter = spy(dvc.remote.local.file_md5)
+        with patch.object(dvc.remote.local, "file_md5", file_md5_counter):
 
-    ret = main(["config", "cache.type", "copy"])
-    assert ret == 0
+            ret = main(["config", "cache.type", "copy"])
+            self.assertEqual(ret, 0)
 
-    ret = main(["add", "data"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 3
+            ret = main(["add", self.DATA_DIR])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 3)
 
-    ret = main(["status"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 3
+            ret = main(["status"])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 3)
 
-    ls = "dir" if os.name == "nt" else "ls"
-    ret = main(["run", "-d", "data", "{} {}".format(ls, "data")])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 3
+            ls = "dir" if os.name == "nt" else "ls"
+            ret = main(
+                ["run", "-d", self.DATA_DIR, "{} {}".format(ls, self.DATA_DIR)]
+            )
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 3)
 
-    os.rename("data", "data" + ".back")
-    ret = main(["checkout"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 3
+            os.rename(self.DATA_DIR, self.DATA_DIR + ".back")
+            ret = main(["checkout"])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 3)
 
-    ret = main(["status"])
-    assert ret == 0
-    assert file_md5_counter.mock.call_count == 3
+            ret = main(["status"])
+            self.assertEqual(ret, 0)
+            self.assertEqual(file_md5_counter.mock.call_count, 3)
 
 
 class TestAddCommit(TestDvc):
@@ -319,18 +320,23 @@ class TestAddCommit(TestDvc):
         self.assertEqual(len(os.listdir(self.dvc.cache.local.cache_dir)), 1)
 
 
-def test_should_collect_dir_cache_only_once(mocker, tmp_dir, dvc):
-    tmp_dir.gen({"data/data": "foo"})
-    get_dir_checksum_counter = mocker.spy(RemoteLOCAL, "get_dir_checksum")
-    ret = main(["add", "data"])
-    assert ret == 0
+class TestShouldCollectDirCacheOnlyOnce(TestDvc):
+    def test(self):
+        from dvc.remote.local import RemoteLOCAL
 
-    ret = main(["status"])
-    assert ret == 0
+        get_dir_checksum_counter = spy(RemoteLOCAL.get_dir_checksum)
+        with patch.object(
+            RemoteLOCAL, "get_dir_checksum", get_dir_checksum_counter
+        ):
+            ret = main(["add", self.DATA_DIR])
+            self.assertEqual(0, ret)
 
-    ret = main(["status"])
-    assert ret == 0
-    assert get_dir_checksum_counter.mock.call_count == 1
+            ret = main(["status"])
+            self.assertEqual(0, ret)
+
+            ret = main(["status"])
+            self.assertEqual(0, ret)
+        self.assertEqual(1, get_dir_checksum_counter.mock.call_count)
 
 
 class SymlinkAddTestBase(TestDvc):
@@ -473,15 +479,17 @@ def test_failed_add_cleanup(tmp_dir, scm, dvc):
     assert "/bar" not in gitignore_content
 
 
-def test_should_not_track_git_internal_files(mocker, dvc, tmp_dir):
-    stage_creator_spy = mocker.spy(dvc_module.repo.add, "_create_stages")
+class TestShouldNotTrackGitInternalFiles(TestDvc):
+    def test(self):
+        stage_creator_spy = spy(dvc.repo.add._create_stages)
 
-    ret = main(["add", "-R", dvc.root_dir])
-    assert ret == 0
+        with patch.object(dvc.repo.add, "_create_stages", stage_creator_spy):
+            ret = main(["add", "-R", self.dvc.root_dir])
+            self.assertEqual(0, ret)
 
-    created_stages_filenames = stage_creator_spy.mock.call_args[0][1]
-    for fname in created_stages_filenames:
-        assert ".git" not in fname
+        created_stages_filenames = stage_creator_spy.mock.call_args[0][0]
+        for fname in created_stages_filenames:
+            self.assertNotIn(".git", fname)
 
 
 class TestAddUnprotected(TestDvc):
@@ -566,7 +574,8 @@ def test_readding_dir_should_not_unprotect_all(tmp_dir, dvc, mocker):
     dvc.add("dir")
     tmp_dir.gen("dir/new_file", "new_file_content")
 
-    unprotect_spy = mocker.spy(RemoteLOCAL, "unprotect")
+    unprotect_spy = spy(RemoteLOCAL.unprotect)
+    mocker.patch.object(RemoteLOCAL, "unprotect", unprotect_spy)
     dvc.add("dir")
 
     assert not unprotect_spy.mock.called
@@ -580,7 +589,8 @@ def test_should_not_checkout_when_adding_cached_copy(tmp_dir, dvc, mocker):
 
     shutil.copy("bar", "foo")
 
-    copy_spy = mocker.spy(dvc.cache.local, "copy")
+    copy_spy = spy(dvc.cache.local.copy)
+    mocker.patch.object(dvc.cache.local, "copy", copy_spy)
 
     dvc.add("foo")
 
diff --git a/tests/func/test_api.py b/tests/func/test_api.py
index 687476d61..04f707826 100644
--- a/tests/func/test_api.py
+++ b/tests/func/test_api.py
@@ -8,7 +8,7 @@ import pytest
 from dvc import api
 from dvc.api import SummonError, UrlNotDvcRepoError
 from dvc.compat import fspath
-from dvc.exceptions import FileMissingError, NotDvcRepoError
+from dvc.exceptions import FileMissingError
 from dvc.main import main
 from dvc.path_info import URLInfo
 from dvc.remote.config import RemoteConfig
@@ -54,7 +54,7 @@ def test_get_url_external(erepo_dir, remote_url):
 def test_get_url_requires_dvc(tmp_dir, scm):
     tmp_dir.scm_gen({"foo": "foo"}, commit="initial")
 
-    with pytest.raises(NotDvcRepoError, match="not inside of a DVC repo"):
+    with pytest.raises(UrlNotDvcRepoError, match="not a DVC repository"):
         api.get_url("foo", repo=fspath(tmp_dir))
 
     with pytest.raises(UrlNotDvcRepoError):
diff --git a/tests/func/test_data_cloud.py b/tests/func/test_data_cloud.py
index fe3aa432f..137fd674c 100644
--- a/tests/func/test_data_cloud.py
+++ b/tests/func/test_data_cloud.py
@@ -6,6 +6,7 @@ import uuid
 from unittest import SkipTest
 
 import pytest
+from mock import patch
 
 from dvc.cache import NamedCache
 from dvc.compat import fspath
@@ -28,6 +29,7 @@ from dvc.utils import file_md5
 from dvc.utils.stage import dump_stage_file
 from dvc.utils.stage import load_stage_file
 from tests.basic_env import TestDvc
+from tests.utils import spy
 
 from tests.remotes import (
     Azure,
@@ -584,21 +586,24 @@ class TestRecursiveSyncOperations(Local, TestDataCloudBase):
         self._test_recursive_pull()
 
 
-def test_checksum_recalculation(mocker, dvc, tmp_dir):
-    tmp_dir.gen({"foo": "foo"})
-    test_get_file_checksum = mocker.spy(RemoteLOCAL, "get_file_checksum")
-    url = Local.get_url()
-    ret = main(["remote", "add", "-d", TEST_REMOTE, url])
-    assert ret == 0
-    ret = main(["config", "cache.type", "hardlink"])
-    assert ret == 0
-    ret = main(["add", "foo"])
-    assert ret == 0
-    ret = main(["push"])
-    assert ret == 0
-    ret = main(["run", "-d", "foo", "echo foo"])
-    assert ret == 0
-    assert test_get_file_checksum.mock.call_count == 1
+class TestCheckSumRecalculation(TestDvc):
+    def test(self):
+        test_get_file_checksum = spy(RemoteLOCAL.get_file_checksum)
+        with patch.object(
+            RemoteLOCAL, "get_file_checksum", test_get_file_checksum
+        ):
+            url = Local.get_url()
+            ret = main(["remote", "add", "-d", TEST_REMOTE, url])
+            self.assertEqual(ret, 0)
+            ret = main(["config", "cache.type", "hardlink"])
+            self.assertEqual(ret, 0)
+            ret = main(["add", self.FOO])
+            self.assertEqual(ret, 0)
+            ret = main(["push"])
+            self.assertEqual(ret, 0)
+            ret = main(["run", "-d", self.FOO, "echo foo"])
+            self.assertEqual(ret, 0)
+        self.assertEqual(test_get_file_checksum.mock.call_count, 1)
 
 
 class TestShouldWarnOnNoChecksumInLocalAndRemoteCache(TestDvc):
diff --git a/tests/func/test_import_url.py b/tests/func/test_import_url.py
index bf042e32b..ae59e3aa5 100644
--- a/tests/func/test_import_url.py
+++ b/tests/func/test_import_url.py
@@ -2,12 +2,13 @@ import os
 from uuid import uuid4
 
 import pytest
+from mock import patch
 
-from dvc.stage import Stage
+import dvc
 from dvc.main import main
 from dvc.utils.fs import makedirs
-from dvc.compat import fspath
 from tests.basic_env import TestDvc
+from tests.utils import spy
 
 
 class TestCmdImport(TestDvc):
@@ -40,14 +41,23 @@ class TestDefaultOutput(TestDvc):
             self.assertEqual(fd.read(), "content")
 
 
-def test_should_remove_outs_before_import(mocker, erepo_dir):
-    erepo_dir.gen({"foo": "foo"})
-
-    remove_outs_call_counter = mocker.spy(Stage, "remove_outs")
-    ret = main(["import-url", fspath(erepo_dir / "foo")])
+class TestShouldRemoveOutsBeforeImport(TestDvc):
+    def setUp(self):
+        super().setUp()
+        tmp_dir = self.mkdtemp()
+        self.external_source = os.path.join(tmp_dir, "file")
+        with open(self.external_source, "w") as fobj:
+            fobj.write("content")
 
-    assert ret == 0
-    assert remove_outs_call_counter.mock.call_count == 1
+    def test(self):
+        remove_outs_call_counter = spy(dvc.stage.Stage.remove_outs)
+        with patch.object(
+            dvc.stage.Stage, "remove_outs", remove_outs_call_counter
+        ):
+            ret = main(["import-url", self.external_source])
+            self.assertEqual(0, ret)
+
+        self.assertEqual(1, remove_outs_call_counter.mock.call_count)
 
 
 class TestImportFilename(TestDvc):
diff --git a/tests/unit/utils/test_fs.py b/tests/unit/utils/test_fs.py
index 07c62e0a2..0c0e19a0b 100644
--- a/tests/unit/utils/test_fs.py
+++ b/tests/unit/utils/test_fs.py
@@ -22,6 +22,7 @@ from dvc.utils.fs import path_isin, remove
 from dvc.utils.fs import makedirs
 from dvc.utils.fs import walk_files
 from tests.basic_env import TestDir
+from tests.utils import spy
 
 
 class TestMtimeAndSize(TestDir):
@@ -69,6 +70,21 @@ class TestContainsLink(TestCase):
         )
         dirname_patch.assert_called_once()
 
+    @patch.object(System, "is_symlink", return_value=False)
+    def test_should_call_recursive_on_no_condition_matched(self, _):
+        contains_symlink_spy = spy(contains_symlink_up_to)
+        with patch.object(
+            dvc.utils.fs, "contains_symlink_up_to", contains_symlink_spy
+        ):
+
+            # call from full path to match contains_symlink_spy patch path
+            self.assertFalse(
+                dvc.utils.fs.contains_symlink_up_to(
+                    os.path.join("foo", "path"), "foo"
+                )
+            )
+            self.assertEqual(2, contains_symlink_spy.mock.call_count)
+
     @patch.object(System, "is_symlink", return_value=True)
     def test_should_return_false_when_base_path_is_symlink(self, _):
         base_path = "foo"
@@ -93,18 +109,6 @@ class TestContainsLink(TestCase):
         )
 
 
-def test_should_call_recursive_on_no_condition_matched(mocker):
-    mocker.patch.object(System, "is_symlink", return_value=False)
-
-    contains_symlink_spy = mocker.spy(dvc.utils.fs, "contains_symlink_up_to")
-
-    # call from full path to match contains_symlink_spy patch path
-    assert not dvc.utils.fs.contains_symlink_up_to(
-        os.path.join("foo", "path"), "foo"
-    )
-    assert contains_symlink_spy.mock.call_count == 2
-
-
 @pytest.mark.skipif(os.name != "nt", reason="Windows specific")
 def test_relpath_windows_different_drives():
     path1 = os.path.join("A:", os.sep, "some", "path")
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
index 9e5448abb..a40a91f2c 100644
--- a/tests/utils/__init__.py
+++ b/tests/utils/__init__.py
@@ -2,9 +2,22 @@ import os
 from contextlib import contextmanager
 from filecmp import dircmp
 
+from mock import MagicMock
+
 from dvc.scm import Git
 
 
+def spy(method_to_decorate):
+    mock = MagicMock()
+
+    def wrapper(self, *args, **kwargs):
+        mock(*args, **kwargs)
+        return method_to_decorate(self, *args, **kwargs)
+
+    wrapper.mock = mock
+    return wrapper
+
+
 def get_gitignore_content():
     with open(Git.GITIGNORE, "r") as gitignore:
         return gitignore.read().splitlines()