Repository: iterative/dvc

Issue:
Introduce check-ignore for dvc ignore command.
There should be functionality providing easy way to check whether some file is `dvc-ignored` or not. Related: #3538 

Discussion:
karajan1001: @pared 
Something to be noticed, replacing

```
if (conditions): 
    return False
```

with 

```
try: 
    ... 
except exceptions:
    return False
```

runs faster when `conditions` happens rarely, but it runs much slower when `conditions` is a common one. 
The exception handling cost lots of resources.

pared: @karajan1001 
I think the most common out of workspace use case is with external dependencies. From the development point of view, I think we should stick to conditions because they are much more readable and don't require us to leave a long comment why are we doing things in a particular way.
karajan1001: > @karajan1001
> I think the most common out of workspace use case is with external dependencies. From the development point of view, I think we should stick to conditions because they are much more readable and don't require us to leave a long comment why are we doing things in a particular way.

@pared 
Thank you, if-else is more readable and try-catch separates exception handling and function logic (All Exceptions been handled at one place and one level). The best practice might be sharing the same standard all over the project for all contributors. Because it makes us more ease to understand each other's code.
@efiop I'm not sure if this project has a universal rule or code style? 
jorgeorpinel: p.s. being able to review docs and core at the same time has some great benefits, especially when the draft doc is so thorough and with comprehensive examples so kudos and thanks again @karajan1001 !
efiop: @karajan1001 Sorry for the delay :)

> I'm not sure if this project has a universal rule or code style? Or at what condition should we raise an exception and what situation to use if-condition

There is no strict policy. As you've noticed we mix and match these at different places in the code. For example, stuff like `if self.tree.exists` is usually used because we didn't finalize tree unification yet, where we need to unify the exceptions that all tree types raise, so that we could handle them in a general way. And sometimes in places where performance doesn't matter much, we just use `if`s because they seem more readable in that particular place. So go with your gut in this case, we'll help out if we see that something could be improved. :slightly_smiling_face: 

As always, thank you for contributing amazing stuff! :pray: 
karajan1001: @pared @efiop 
According to discussions from in [Discord](https://discordapp.com/channels/485586884165107732/565699007037571084/738242622019076107)

Reverted to the previous version, but several problems still remained and need to create issues for them.

1.  Return 0 or 1 for files matching a `!` started pattern.
2. Result insistency of `__call__` ,`is_ignored_dir`, and `is_ignored_file` with paths outside the repo.

Patch:
diff --git a/dvc/cache/base.py b/dvc/cache/base.py
index 64617ef99..a6c5596d7 100644
--- a/dvc/cache/base.py
+++ b/dvc/cache/base.py
@@ -276,8 +276,6 @@ class CloudCache:
                 self.tree.scheme,
             )
 
-        if not hash_info:
-            hash_info = self.tree.save_info(path_info, tree=tree, **kwargs)
         hash_ = hash_info[self.tree.PARAM_CHECKSUM]
         return self._save(path_info, tree, hash_, save_link, **kwargs)
 
diff --git a/dvc/cli.py b/dvc/cli.py
index 139233204..2b020d426 100644
--- a/dvc/cli.py
+++ b/dvc/cli.py
@@ -6,6 +6,7 @@ import sys
 from .command import (
     add,
     cache,
+    check_ignore,
     checkout,
     commit,
     completion,
@@ -79,6 +80,7 @@ COMMANDS = [
     git_hook,
     plots,
     experiments,
+    check_ignore,
 ]
 
 
diff --git a/dvc/command/check_ignore.py b/dvc/command/check_ignore.py
new file mode 100644
index 000000000..ef3f1250b
--- /dev/null
+++ b/dvc/command/check_ignore.py
@@ -0,0 +1,70 @@
+import argparse
+import logging
+
+from dvc.command import completion
+from dvc.command.base import CmdBase, append_doc_link
+from dvc.exceptions import DvcException
+
+logger = logging.getLogger(__name__)
+
+
+class CmdCheckIgnore(CmdBase):
+    def __init__(self, args):
+        super().__init__(args)
+        self.ignore_filter = self.repo.tree.dvcignore
+
+    def _show_results(self, result):
+        if result.match or self.args.non_matching:
+            if self.args.details:
+                logger.info("{}\t{}".format(result.patterns[-1], result.file))
+            else:
+                logger.info(result.file)
+
+    def run(self):
+        if self.args.non_matching and not self.args.details:
+            raise DvcException("--non-matching is only valid with --details")
+
+        if self.args.quiet and self.args.details:
+            raise DvcException("cannot both --details and --quiet")
+
+        ret = 1
+        for target in self.args.targets:
+            result = self.ignore_filter.check_ignore(target)
+            self._show_results(result)
+            if result.match:
+                ret = 0
+        return ret
+
+
+def add_parser(subparsers, parent_parser):
+    ADD_HELP = "Debug DVC ignore/exclude files"
+
+    parser = subparsers.add_parser(
+        "check-ignore",
+        parents=[parent_parser],
+        description=append_doc_link(ADD_HELP, "check-ignore"),
+        help=ADD_HELP,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    parser.add_argument(
+        "-d",
+        "--details",
+        action="store_true",
+        default=False,
+        help="Show the exclude pattern together with each target path.",
+    )
+    parser.add_argument(
+        "-n",
+        "--non-matching",
+        action="store_true",
+        default=False,
+        help="Show the target paths which don’t match any pattern. "
+        "Only usable when `--details` is also employed",
+    )
+    parser.add_argument(
+        "targets",
+        nargs="+",
+        help="Exact or wildcard paths of files or directories to check "
+        "ignore patterns.",
+    ).complete = completion.FILE
+    parser.set_defaults(func=CmdCheckIgnore)
diff --git a/dvc/command/remote.py b/dvc/command/remote.py
index db622a378..1cb4a3a86 100644
--- a/dvc/command/remote.py
+++ b/dvc/command/remote.py
@@ -3,7 +3,7 @@ import logging
 
 from dvc.command.base import append_doc_link, fix_subparsers
 from dvc.command.config import CmdConfig
-from dvc.config import ConfigError
+from dvc.config import ConfigError, merge
 from dvc.utils import format_link
 
 logger = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ class CmdRemote(CmdConfig):
 
     def _check_exists(self, conf):
         if self.args.name not in conf["remote"]:
-            raise ConfigError(f"remote '{self.args.name}' doesn't exists.")
+            raise ConfigError(f"remote '{self.args.name}' doesn't exist.")
 
 
 class CmdRemoteAdd(CmdRemote):
@@ -61,7 +61,12 @@ class CmdRemoteRemove(CmdRemote):
 class CmdRemoteModify(CmdRemote):
     def run(self):
         with self.config.edit(self.args.level) as conf:
-            self._check_exists(conf)
+            merged = self.config.load_config_to_level(self.args.level)
+            merge(merged, conf)
+            self._check_exists(merged)
+
+            if self.args.name not in conf["remote"]:
+                conf["remote"][self.args.name] = {}
             section = conf["remote"][self.args.name]
             if self.args.unset:
                 section.pop(self.args.option, None)
diff --git a/dvc/config.py b/dvc/config.py
index 68cda1bec..26181e4d6 100644
--- a/dvc/config.py
+++ b/dvc/config.py
@@ -400,7 +400,7 @@ class Config(dict):
             if merge_level == level:
                 break
             if merge_level in self.files:
-                _merge(merged_conf, self.load_one(merge_level))
+                merge(merged_conf, self.load_one(merge_level))
         return merged_conf
 
     @contextmanager
@@ -414,7 +414,7 @@ class Config(dict):
         conf = self._save_paths(conf, self.files[level])
 
         merged_conf = self.load_config_to_level(level)
-        _merge(merged_conf, conf)
+        merge(merged_conf, conf)
         self.validate(merged_conf)
 
         self._save_config(level, conf)
@@ -453,11 +453,11 @@ def _pack_remotes(conf):
     return result
 
 
-def _merge(into, update):
+def merge(into, update):
     """Merges second dict into first recursively"""
     for key, val in update.items():
         if isinstance(into.get(key), dict) and isinstance(val, dict):
-            _merge(into[key], val)
+            merge(into[key], val)
         else:
             into[key] = val
 
diff --git a/dvc/dependency/repo.py b/dvc/dependency/repo.py
index eb3c58b5c..7dffd2eca 100644
--- a/dvc/dependency/repo.py
+++ b/dvc/dependency/repo.py
@@ -67,7 +67,7 @@ class RepoDependency(LocalDependency):
                     return self.repo.cache.local.tree.get_hash(path, tree=tree)
                 return tree.get_file_hash(path)
 
-    def status(self):
+    def workspace_status(self):
         current_checksum = self._get_checksum(locked=True)
         updated_checksum = self._get_checksum(locked=False)
 
@@ -76,6 +76,9 @@ class RepoDependency(LocalDependency):
 
         return {}
 
+    def status(self):
+        return self.workspace_status()
+
     def save(self):
         pass
 
diff --git a/dvc/external_repo.py b/dvc/external_repo.py
index 15332e7de..c2de3d8e8 100644
--- a/dvc/external_repo.py
+++ b/dvc/external_repo.py
@@ -90,15 +90,26 @@ class BaseExternalRepo:
         """Use the specified cache in place of default tmpdir cache for
         download operations.
         """
-        if hasattr(self, "cache"):
-            save_cache = self.cache.local
-            self.cache.local = cache
+        has_cache = hasattr(self, "cache")
+
+        if has_cache:
+            save_cache = self.cache.local  # pylint: disable=E0203
+            self.cache.local = cache  # pylint: disable=E0203
+        else:
+            from collections import namedtuple
+
+            mock_cache = namedtuple("MockCache", ["local"])(local=cache)
+            self.cache = mock_cache  # pylint: disable=W0201
+
         self._local_cache = cache
 
         yield
 
-        if hasattr(self, "cache"):
+        if has_cache:
             self.cache.local = save_cache
+        else:
+            del self.cache
+
         self._local_cache = None
 
     @cached_property
@@ -130,14 +141,17 @@ class BaseExternalRepo:
         for path in paths:
             if not self.repo_tree.exists(path):
                 raise PathMissingError(path, self.url)
-            save_info = self.local_cache.save(
+            hash_info = self.repo_tree.save_info(
+                path, download_callback=download_update
+            )
+            self.local_cache.save(
                 path,
                 self.repo_tree,
-                None,
+                hash_info,
                 save_link=False,
                 download_callback=download_update,
             )
-            save_infos.append(save_info)
+            save_infos.append(hash_info)
 
         return sum(download_results), failed, save_infos
 
diff --git a/dvc/ignore.py b/dvc/ignore.py
index a87e750c4..0e80a0210 100644
--- a/dvc/ignore.py
+++ b/dvc/ignore.py
@@ -1,6 +1,7 @@
 import logging
 import os
 import re
+from collections import namedtuple
 from itertools import groupby, takewhile
 
 from pathspec.patterns import GitWildMatchPattern
@@ -8,7 +9,7 @@ from pathspec.util import normalize_file
 from pygtrie import StringTrie
 
 from dvc.path_info import PathInfo
-from dvc.pathspec_math import merge_patterns
+from dvc.pathspec_math import PatternInfo, merge_patterns
 from dvc.system import System
 from dvc.utils import relpath
 
@@ -24,18 +25,26 @@ class DvcIgnore:
 
 class DvcIgnorePatterns(DvcIgnore):
     def __init__(self, pattern_list, dirname):
+        if pattern_list:
+            if isinstance(pattern_list[0], str):
+                pattern_list = [
+                    PatternInfo(pattern, "") for pattern in pattern_list
+                ]
 
         self.pattern_list = pattern_list
         self.dirname = dirname
         self.prefix = self.dirname + os.sep
 
-        regex_pattern_list = map(
-            GitWildMatchPattern.pattern_to_regex, pattern_list
-        )
+        self.regex_pattern_list = [
+            GitWildMatchPattern.pattern_to_regex(pattern_info.patterns)
+            for pattern_info in pattern_list
+        ]
 
         self.ignore_spec = [
             (ignore, re.compile("|".join(item[0] for item in group)))
-            for ignore, group in groupby(regex_pattern_list, lambda x: x[1])
+            for ignore, group in groupby(
+                self.regex_pattern_list, lambda x: x[1]
+            )
             if ignore is not None
         ]
 
@@ -43,9 +52,19 @@ class DvcIgnorePatterns(DvcIgnore):
     def from_files(cls, ignore_file_path, tree):
         assert os.path.isabs(ignore_file_path)
         dirname = os.path.normpath(os.path.dirname(ignore_file_path))
+        ignore_file_rel_path = os.path.relpath(
+            ignore_file_path, tree.tree_root
+        )
         with tree.open(ignore_file_path, encoding="utf-8") as fobj:
             path_spec_lines = [
-                line for line in map(str.strip, fobj.readlines()) if line
+                PatternInfo(
+                    line,
+                    "{}:{}:{}".format(ignore_file_rel_path, line_no + 1, line),
+                )
+                for line_no, line in enumerate(
+                    map(str.strip, fobj.readlines())
+                )
+                if line
             ]
 
         return cls(path_spec_lines, dirname)
@@ -56,7 +75,7 @@ class DvcIgnorePatterns(DvcIgnore):
 
         return dirs, files
 
-    def matches(self, dirname, basename, is_dir=False):
+    def _get_normalize_path(self, dirname, basename):
         # NOTE: `relpath` is too slow, so we have to assume that both
         # `dirname` and `self.dirname` are relative or absolute together.
         if dirname == self.dirname:
@@ -70,6 +89,12 @@ class DvcIgnorePatterns(DvcIgnore):
 
         if not System.is_unix():
             path = normalize_file(path)
+        return path
+
+    def matches(self, dirname, basename, is_dir=False):
+        path = self._get_normalize_path(dirname, basename)
+        if not path:
+            return False
         return self.ignore(path, is_dir)
 
     def ignore(self, path, is_dir):
@@ -85,20 +110,48 @@ class DvcIgnorePatterns(DvcIgnore):
                     result = ignore
         return result
 
+    def match_details(self, dirname, basename, is_dir=False):
+        path = self._get_normalize_path(dirname, basename)
+        if not path:
+            return False
+        return self._ignore_details(path, is_dir)
+
+    def _ignore_details(self, path, is_dir):
+        result = []
+        for ignore, pattern in zip(self.regex_pattern_list, self.pattern_list):
+            regex = re.compile(ignore[0])
+            # skip system pattern
+            if not pattern.file_info:
+                continue
+            if is_dir:
+                path_dir = f"{path}/"
+                if regex.match(path) or regex.match(path_dir):
+                    result.append(pattern.file_info)
+            else:
+                if regex.match(path):
+                    result.append(pattern.file_info)
+        return result
+
     def __hash__(self):
-        return hash(self.dirname + ":" + "\n".join(self.pattern_list))
+        return hash(self.dirname + ":" + str(self.pattern_list))
 
     def __eq__(self, other):
         if not isinstance(other, DvcIgnorePatterns):
             return NotImplemented
         return (self.dirname == other.dirname) & (
-            self.pattern_list == other.pattern_list
+            [pattern.patterns for pattern in self.pattern_list]
+            == [pattern.patterns for pattern in other.pattern_list]
         )
 
     def __bool__(self):
         return bool(self.pattern_list)
 
 
+CheckIgnoreResult = namedtuple(
+    "CheckIgnoreResult", ["file", "match", "patterns"]
+)
+
+
 class DvcIgnoreFilterNoop:
     def __init__(self, tree, root_dir):
         pass
@@ -112,6 +165,9 @@ class DvcIgnoreFilterNoop:
     def is_ignored_file(self, _):
         return False
 
+    def check_ignore(self, _):
+        return []
+
 
 class DvcIgnoreFilter:
     @staticmethod
@@ -166,20 +222,19 @@ class DvcIgnoreFilter:
     def _update_sub_repo(self, root, dirs):
         for d in dirs:
             if self._is_dvc_repo(root, d):
+                new_pattern = DvcIgnorePatterns(["/{}/".format(d)], root)
                 old_pattern = self.ignores_trie_tree.longest_prefix(root).value
                 if old_pattern:
                     self.ignores_trie_tree[root] = DvcIgnorePatterns(
                         *merge_patterns(
                             old_pattern.pattern_list,
                             old_pattern.dirname,
-                            ["/{}/".format(d)],
-                            root,
+                            new_pattern.pattern_list,
+                            new_pattern.dirname,
                         )
                     )
                 else:
-                    self.ignores_trie_tree[root] = DvcIgnorePatterns(
-                        ["/{}/".format(d)], root
-                    )
+                    self.ignores_trie_tree[root] = new_pattern
 
     def __call__(self, root, dirs, files):
         ignore_pattern = self._get_trie_pattern(root)
@@ -245,3 +300,17 @@ class DvcIgnoreFilter:
         ):
             return True
         return False
+
+    def check_ignore(self, target):
+        full_target = os.path.abspath(target)
+        if not self._outside_repo(full_target):
+            dirname, basename = os.path.split(os.path.normpath(full_target))
+            pattern = self._get_trie_pattern(dirname)
+            if pattern:
+                matches = pattern.match_details(
+                    dirname, basename, os.path.isdir(full_target)
+                )
+
+                if matches:
+                    return CheckIgnoreResult(target, True, matches)
+        return CheckIgnoreResult(target, False, ["::"])
diff --git a/dvc/output/base.py b/dvc/output/base.py
index 242499fc5..f17e3df9e 100644
--- a/dvc/output/base.py
+++ b/dvc/output/base.py
@@ -199,10 +199,7 @@ class BaseOutput:
 
         return self.cache.changed_cache(self.checksum, filter_info=filter_info)
 
-    def status(self):
-        if self.checksum and self.use_cache and self.changed_cache():
-            return {str(self): "not in cache"}
-
+    def workspace_status(self):
         if not self.exists:
             return {str(self): "deleted"}
 
@@ -214,6 +211,12 @@ class BaseOutput:
 
         return {}
 
+    def status(self):
+        if self.checksum and self.use_cache and self.changed_cache():
+            return {str(self): "not in cache"}
+
+        return self.workspace_status()
+
     def changed(self):
         status = self.status()
         logger.debug(str(status))
@@ -280,6 +283,7 @@ class BaseOutput:
         self.info = self.save_info()
 
     def commit(self):
+        assert self.info
         if self.use_cache:
             self.cache.save(self.path_info, self.cache.tree, self.info)
 
diff --git a/dvc/pathspec_math.py b/dvc/pathspec_math.py
index 5af503cd2..6191f7717 100644
--- a/dvc/pathspec_math.py
+++ b/dvc/pathspec_math.py
@@ -3,11 +3,14 @@
 # of two path specification patterns with different base
 # All the operations follow the documents of `gitignore`
 import os
+from collections import namedtuple
 
 from pathspec.util import normalize_file
 
 from dvc.utils import relpath
 
+PatternInfo = namedtuple("PatternInfo", ["patterns", "file_info"])
+
 
 def _not_ignore(rule):
     return (True, rule[1:]) if rule.startswith("!") else (False, rule)
@@ -59,7 +62,10 @@ def _change_dirname(dirname, pattern_list, new_dirname):
     if rel.startswith(".."):
         raise ValueError("change dirname can only change to parent path")
 
-    return [change_rule(rule, rel) for rule in pattern_list]
+    return [
+        PatternInfo(change_rule(rule.patterns, rel), rule.file_info)
+        for rule in pattern_list
+    ]
 
 
 def merge_patterns(pattern_a, prefix_a, pattern_b, prefix_b):
diff --git a/dvc/repo/tree.py b/dvc/repo/tree.py
index 2b7810c29..8011d0318 100644
--- a/dvc/repo/tree.py
+++ b/dvc/repo/tree.py
@@ -245,6 +245,9 @@ class RepoTree(BaseTree):  # pylint:disable=abstract-method
     Any kwargs will be passed to `DvcTree()`.
     """
 
+    scheme = "local"
+    PARAM_CHECKSUM = "md5"
+
     def __init__(self, repo, **kwargs):
         super().__init__(repo, {"url": repo.root_dir})
         if hasattr(repo, "dvc_dir"):
diff --git a/dvc/stage/__init__.py b/dvc/stage/__init__.py
index 6316793bb..0048c94fb 100644
--- a/dvc/stage/__init__.py
+++ b/dvc/stage/__init__.py
@@ -396,7 +396,7 @@ class Stage(params.StageParams):
 
     @staticmethod
     def _changed_entries(entries):
-        return [str(entry) for entry in entries if entry.changed_checksum()]
+        return [str(entry) for entry in entries if entry.workspace_status()]
 
     def _changed_stage_entry(self):
         return f"'md5' of {self} changed."
diff --git a/dvc/tree/base.py b/dvc/tree/base.py
index 700513b19..1860bafab 100644
--- a/dvc/tree/base.py
+++ b/dvc/tree/base.py
@@ -236,22 +236,17 @@ class BaseTree:
             return False
         return hash_.endswith(cls.CHECKSUM_DIR_SUFFIX)
 
-    def get_hash(self, path_info, tree=None, **kwargs):
+    def get_hash(self, path_info, **kwargs):
         assert path_info and (
             isinstance(path_info, str) or path_info.scheme == self.scheme
         )
 
-        if not tree:
-            tree = self
-
-        if not tree.exists(path_info):
+        if not self.exists(path_info):
             return None
 
-        if tree == self:
-            # pylint: disable=assignment-from-none
-            hash_ = self.state.get(path_info)
-        else:
-            hash_ = None
+        # pylint: disable=assignment-from-none
+        hash_ = self.state.get(path_info)
+
         # If we have dir hash in state db, but dir cache file is lost,
         # then we need to recollect the dir via .get_dir_hash() call below,
         # see https://github.com/iterative/dvc/issues/2219 for context
@@ -267,10 +262,10 @@ class BaseTree:
         if hash_:
             return hash_
 
-        if tree.isdir(path_info):
-            hash_ = self.get_dir_hash(path_info, tree, **kwargs)
+        if self.isdir(path_info):
+            hash_ = self.get_dir_hash(path_info, **kwargs)
         else:
-            hash_ = tree.get_file_hash(path_info)
+            hash_ = self.get_file_hash(path_info)
 
         if hash_ and self.exists(path_info):
             self.state.save(path_info, hash_)
@@ -280,12 +275,12 @@ class BaseTree:
     def get_file_hash(self, path_info):
         raise NotImplementedError
 
-    def get_dir_hash(self, path_info, tree, **kwargs):
+    def get_dir_hash(self, path_info, **kwargs):
         if not self.cache:
             raise RemoteCacheRequiredError(path_info)
 
-        dir_info = self._collect_dir(path_info, tree, **kwargs)
-        return self._save_dir_info(dir_info, path_info)
+        dir_info = self._collect_dir(path_info, **kwargs)
+        return self._save_dir_info(dir_info)
 
     def hash_to_path_info(self, hash_):
         return self.path_info / hash_[0:2] / hash_[2:]
@@ -298,29 +293,26 @@ class BaseTree:
 
         return "".join(parts)
 
-    def save_info(self, path_info, tree=None, **kwargs):
-        return {
-            self.PARAM_CHECKSUM: self.get_hash(path_info, tree=tree, **kwargs)
-        }
+    def save_info(self, path_info, **kwargs):
+        return {self.PARAM_CHECKSUM: self.get_hash(path_info, **kwargs)}
 
-    @staticmethod
-    def _calculate_hashes(file_infos, tree):
+    def _calculate_hashes(self, file_infos):
         file_infos = list(file_infos)
         with Tqdm(
             total=len(file_infos),
             unit="md5",
             desc="Computing file/dir hashes (only done once)",
         ) as pbar:
-            worker = pbar.wrap_fn(tree.get_file_hash)
-            with ThreadPoolExecutor(max_workers=tree.hash_jobs) as executor:
+            worker = pbar.wrap_fn(self.get_file_hash)
+            with ThreadPoolExecutor(max_workers=self.hash_jobs) as executor:
                 tasks = executor.map(worker, file_infos)
                 hashes = dict(zip(file_infos, tasks))
         return hashes
 
-    def _collect_dir(self, path_info, tree, **kwargs):
+    def _collect_dir(self, path_info, **kwargs):
         file_infos = set()
 
-        for fname in tree.walk_files(path_info, **kwargs):
+        for fname in self.walk_files(path_info, **kwargs):
             if DvcIgnore.DVCIGNORE_FILE == fname.name:
                 raise DvcIgnoreInCollectedDirError(fname.parent)
 
@@ -329,7 +321,7 @@ class BaseTree:
         hashes = {fi: self.state.get(fi) for fi in file_infos}
         not_in_state = {fi for fi, hash_ in hashes.items() if hash_ is None}
 
-        new_hashes = self._calculate_hashes(not_in_state, tree)
+        new_hashes = self._calculate_hashes(not_in_state)
         hashes.update(new_hashes)
 
         result = [
@@ -351,7 +343,7 @@ class BaseTree:
         # Sorting the list by path to ensure reproducibility
         return sorted(result, key=itemgetter(self.PARAM_RELPATH))
 
-    def _save_dir_info(self, dir_info, path_info):
+    def _save_dir_info(self, dir_info):
         hash_, tmp_info = self._get_dir_info_hash(dir_info)
         new_info = self.cache.tree.hash_to_path_info(hash_)
         if self.cache.changed_cache_file(hash_):
@@ -360,8 +352,6 @@ class BaseTree:
                 tmp_info, new_info, mode=self.cache.CACHE_MODE
             )
 
-        if self.exists(path_info):
-            self.state.save(path_info, hash_)
         self.state.save(new_info, hash_)
 
         return hash_
diff --git a/dvc/version.py b/dvc/version.py
index c883b6a67..02ef7104a 100644
--- a/dvc/version.py
+++ b/dvc/version.py
@@ -6,7 +6,7 @@
 import os
 import subprocess
 
-_BASE_VERSION = "1.2.3"
+_BASE_VERSION = "1.3.1"
 
 
 def _generate_version(base_version):
diff --git a/setup.py b/setup.py
index da84576f1..b90e9b05a 100644
--- a/setup.py
+++ b/setup.py
@@ -105,9 +105,7 @@ all_remotes = gs + s3 + azure + ssh + oss + gdrive + hdfs + webdav
 tests_requirements = [
     "wheel>=0.31.1",
     # Test requirements:
-    # https://github.com/pytest-dev/pytest/issues/7558
-    # FIXME: pylint complaining for pytest.mark.* on v6.0
-    "pytest>=4.6.0,<6.0",
+    "pytest>=6.0.1",
     "pytest-docker>=0.7.2",
     "pytest-timeout>=1.3.3",
     "pytest-cov>=2.6.1",


Test patch:
diff --git a/tests/func/test_check_ignore.py b/tests/func/test_check_ignore.py
new file mode 100644
index 000000000..b53f72f53
--- /dev/null
+++ b/tests/func/test_check_ignore.py
@@ -0,0 +1,109 @@
+import os
+
+import pytest
+
+from dvc.ignore import DvcIgnore
+from dvc.main import main
+
+
+@pytest.mark.parametrize(
+    "file,ret,output", [("ignored", 0, "ignored\n"), ("not_ignored", 1, "")]
+)
+def test_check_ignore(tmp_dir, dvc, file, ret, output, caplog):
+    tmp_dir.gen(DvcIgnore.DVCIGNORE_FILE, "ignored")
+
+    assert main(["check-ignore", file]) == ret
+    assert output in caplog.text
+
+
+@pytest.mark.parametrize(
+    "file,ret,output",
+    [
+        ("file", 0, "{}:1:f*\tfile\n".format(DvcIgnore.DVCIGNORE_FILE)),
+        ("foo", 0, "{}:2:!foo\tfoo\n".format(DvcIgnore.DVCIGNORE_FILE)),
+        (
+            os.path.join("dir", "foobar"),
+            0,
+            "{}:1:foobar\t{}\n".format(
+                os.path.join("dir", DvcIgnore.DVCIGNORE_FILE),
+                os.path.join("dir", "foobar"),
+            ),
+        ),
+    ],
+)
+def test_check_ignore_details(tmp_dir, dvc, file, ret, output, caplog):
+    tmp_dir.gen(DvcIgnore.DVCIGNORE_FILE, "f*\n!foo")
+    tmp_dir.gen({"dir": {DvcIgnore.DVCIGNORE_FILE: "foobar"}})
+
+    assert main(["check-ignore", "-d", file]) == ret
+    assert output in caplog.text
+
+
+@pytest.mark.parametrize(
+    "non_matching,output", [(["-n"], "::\tfile\n"), ([], "")]
+)
+def test_check_ignore_non_matching(tmp_dir, dvc, non_matching, output, caplog):
+    tmp_dir.gen(DvcIgnore.DVCIGNORE_FILE, "other")
+
+    assert main(["check-ignore", "-d"] + non_matching + ["file"]) == 1
+    assert output in caplog.text
+
+
+def test_check_ignore_non_matching_without_details(tmp_dir, dvc):
+    tmp_dir.gen(DvcIgnore.DVCIGNORE_FILE, "other")
+
+    assert main(["check-ignore", "-n", "file"]) == 255
+
+
+def test_check_ignore_details_with_quiet(tmp_dir, dvc):
+    tmp_dir.gen(DvcIgnore.DVCIGNORE_FILE, "other")
+
+    assert main(["check-ignore", "-d", "-q", "file"]) == 255
+
+
+@pytest.mark.parametrize("path,ret", [({"dir": {}}, 0), ({"dir": "files"}, 1)])
+def test_check_ignore_dir(tmp_dir, dvc, path, ret):
+    tmp_dir.gen(DvcIgnore.DVCIGNORE_FILE, "dir/")
+    tmp_dir.gen(path)
+
+    assert main(["check-ignore", "-q", "dir"]) == ret
+
+
+def test_check_ignore_default_dir(tmp_dir, dvc):
+    assert main(["check-ignore", "-q", ".dvc"]) == 1
+
+
+def test_check_ignore_out_side_repo(tmp_dir, dvc):
+    tmp_dir.gen(DvcIgnore.DVCIGNORE_FILE, "file")
+    assert main(["check-ignore", "-q", "../file"]) == 1
+
+
+def test_check_ignore_sub_repo(tmp_dir, dvc):
+    tmp_dir.gen(
+        {DvcIgnore.DVCIGNORE_FILE: "other", "dir": {".dvc": {}, "foo": "bar"}}
+    )
+
+    assert main(["check-ignore", "-q", os.path.join("dir", "foo")]) == 1
+
+
+def test_check_sub_dir_ignore_file(tmp_dir, dvc, caplog):
+    tmp_dir.gen(
+        {
+            DvcIgnore.DVCIGNORE_FILE: "other",
+            "dir": {DvcIgnore.DVCIGNORE_FILE: "bar\nfoo", "foo": "bar"},
+        }
+    )
+
+    assert main(["check-ignore", "-d", os.path.join("dir", "foo")]) == 0
+    assert (
+        "{}:2:foo\t{}".format(
+            os.path.join("dir", DvcIgnore.DVCIGNORE_FILE),
+            os.path.join("dir", "foo"),
+        )
+        in caplog.text
+    )
+
+    sub_dir = tmp_dir / "dir"
+    with sub_dir.chdir():
+        assert main(["check-ignore", "-d", "foo"]) == 0
+        assert ".dvcignore:2:foo\tfoo" in caplog.text
diff --git a/tests/func/test_commit.py b/tests/func/test_commit.py
index 6c91abff3..958a29c53 100644
--- a/tests/func/test_commit.py
+++ b/tests/func/test_commit.py
@@ -2,7 +2,9 @@ from os import fspath
 
 import pytest
 
+from dvc.dependency.base import DependencyDoesNotExistError
 from dvc.dvcfile import PIPELINE_FILE
+from dvc.output.base import OutputDoesNotExistError
 from dvc.stage.exceptions import StageCommitError
 from dvc.utils.yaml import dump_yaml, load_yaml
 
@@ -86,6 +88,24 @@ def test_commit_no_exec(tmp_dir, dvc):
     assert dvc.status(stage.path) == {}
 
 
+def test_commit_no_exec_missing_dep(tmp_dir, dvc):
+    stage = dvc.run(
+        name="my", cmd="mycmd", deps=["dep"], outs=["out"], no_exec=True
+    )
+    assert dvc.status(stage.path)
+
+    with pytest.raises(DependencyDoesNotExistError):
+        dvc.commit(stage.path, force=True)
+
+
+def test_commit_no_exec_missing_out(tmp_dir, dvc):
+    stage = dvc.run(name="my", cmd="mycmd", outs=["out"], no_exec=True)
+    assert dvc.status(stage.path)
+
+    with pytest.raises(OutputDoesNotExistError):
+        dvc.commit(stage.path, force=True)
+
+
 def test_commit_pipeline_stage(tmp_dir, dvc, run_copy):
     tmp_dir.gen("foo", "foo")
     stage = run_copy("foo", "bar", no_commit=True, name="copy-foo-bar")
diff --git a/tests/func/test_ignore.py b/tests/func/test_ignore.py
index 16afb978a..67e8f71a5 100644
--- a/tests/func/test_ignore.py
+++ b/tests/func/test_ignore.py
@@ -6,13 +6,17 @@ import pytest
 from dvc.exceptions import DvcIgnoreInCollectedDirError
 from dvc.ignore import DvcIgnore, DvcIgnorePatterns
 from dvc.path_info import PathInfo
-from dvc.pathspec_math import merge_patterns
+from dvc.pathspec_math import PatternInfo, merge_patterns
 from dvc.repo import Repo
 from dvc.utils import relpath
 from dvc.utils.fs import get_mtime_and_size
 from tests.dir_helpers import TmpDir
 
 
+def _to_pattern_info_list(str_list):
+    return [PatternInfo(a, "") for a in str_list]
+
+
 def test_ignore(tmp_dir, dvc, monkeypatch):
     tmp_dir.gen({"dir": {"ignored": "text", "other": "text2"}})
     tmp_dir.gen(DvcIgnore.DVCIGNORE_FILE, "dir/ignored")
@@ -106,9 +110,9 @@ def test_ignore_collecting_dvcignores(tmp_dir, dvc, dname):
     assert (
         DvcIgnorePatterns(
             *merge_patterns(
-                [".hg/", ".git/", ".dvc/"],
+                _to_pattern_info_list([".hg/", ".git/", ".dvc/"]),
                 os.fspath(tmp_dir),
-                [os.path.basename(dname)],
+                _to_pattern_info_list([os.path.basename(dname)]),
                 top_ignore_path,
             )
         )
@@ -309,17 +313,24 @@ def test_pattern_trie_tree(tmp_dir, dvc):
         os.fspath(tmp_dir / "top" / "first" / "middle" / "second" / "bottom")
     )
 
-    base_pattern = [".hg/", ".git/", ".dvc/"], os.fspath(tmp_dir)
+    base_pattern = (
+        _to_pattern_info_list([".hg/", ".git/", ".dvc/"]),
+        os.fspath(tmp_dir),
+    )
     first_pattern = merge_patterns(
-        *base_pattern, ["a", "b", "c"], os.fspath(tmp_dir / "top" / "first")
+        *base_pattern,
+        _to_pattern_info_list(["a", "b", "c"]),
+        os.fspath(tmp_dir / "top" / "first")
     )
     second_pattern = merge_patterns(
         *first_pattern,
-        ["d", "e", "f"],
+        _to_pattern_info_list(["d", "e", "f"]),
         os.fspath(tmp_dir / "top" / "first" / "middle" / "second")
     )
     other_pattern = merge_patterns(
-        *base_pattern, ["1", "2", "3"], os.fspath(tmp_dir / "other")
+        *base_pattern,
+        _to_pattern_info_list(["1", "2", "3"]),
+        os.fspath(tmp_dir / "other")
     )
 
     assert DvcIgnorePatterns(*base_pattern) == ignore_pattern_top
diff --git a/tests/func/test_remote.py b/tests/func/test_remote.py
index 35ce310f0..38196ffbd 100644
--- a/tests/func/test_remote.py
+++ b/tests/func/test_remote.py
@@ -232,6 +232,24 @@ def test_modify_missing_remote(tmp_dir, dvc):
     assert main(["remote", "modify", "myremote", "user", "xxx"]) == 251
 
 
+def test_remote_modify_local_on_repo_config(tmp_dir, dvc):
+    assert main(["remote", "add", "myremote", "http://example.com/path"]) == 0
+    assert (
+        main(["remote", "modify", "myremote", "user", "xxx", "--local"]) == 0
+    )
+    assert dvc.config.load_one("local")["remote"]["myremote"] == {
+        "user": "xxx"
+    }
+    assert dvc.config.load_one("repo")["remote"]["myremote"] == {
+        "url": "http://example.com/path"
+    }
+    dvc.config.load()
+    assert dvc.config["remote"]["myremote"] == {
+        "url": "http://example.com/path",
+        "user": "xxx",
+    }
+
+
 def test_external_dir_resource_on_no_cache(tmp_dir, dvc, tmp_path_factory):
     # https://github.com/iterative/dvc/issues/2647, is some situations
     # (external dir dependency) cache is required to calculate dir md5
diff --git a/tests/func/test_tree.py b/tests/func/test_tree.py
index 93b671bd7..3ab93efc9 100644
--- a/tests/func/test_tree.py
+++ b/tests/func/test_tree.py
@@ -217,7 +217,10 @@ def test_repotree_cache_save(tmp_dir, dvc, scm, erepo_dir, local_cloud):
     with erepo_dir.dvc.state:
         cache = dvc.cache.local
         with cache.tree.state:
-            cache.save(PathInfo(erepo_dir / "dir"), tree, None)
+            path_info = PathInfo(erepo_dir / "dir")
+            hash_info = cache.tree.save_info(path_info)
+            cache.save(path_info, tree, hash_info)
+
     for hash_ in expected:
         assert os.path.exists(cache.tree.hash_to_path_info(hash_))
 
diff --git a/tests/func/test_version.py b/tests/func/test_version.py
index c55955db4..753d53e6e 100644
--- a/tests/func/test_version.py
+++ b/tests/func/test_version.py
@@ -16,7 +16,7 @@ def test_info_in_repo(scm_init, tmp_dir, caplog):
 
     assert main(["version"]) == 0
 
-    assert re.search(r"DVC version: \d+\.\d+\.\d+\+.*", caplog.text)
+    assert re.search(r"DVC version: \d+\.\d+\.\d+.*", caplog.text)
     assert re.search(r"Platform: Python \d\.\d\.\d on .*", caplog.text)
     assert re.search(r"Supports: .*", caplog.text)
     assert re.search(r"Cache types: .*", caplog.text)
@@ -60,7 +60,7 @@ def test_fs_info_in_repo(tmp_dir, dvc, caplog):
 def test_info_outside_of_repo(tmp_dir, caplog):
     assert main(["version"]) == 0
 
-    assert re.search(r"DVC version: \d+\.\d+\.\d+\+.*", caplog.text)
+    assert re.search(r"DVC version: \d+\.\d+\.\d+.*", caplog.text)
     assert re.search(r"Platform: Python \d\.\d\.\d on .*", caplog.text)
     assert re.search(r"Supports: .*", caplog.text)
     assert not re.search(r"Cache types: .*", caplog.text)
diff --git a/tests/unit/test_pathspec_math.py b/tests/unit/test_pathspec_math.py
index 1cc683715..d843a71a3 100644
--- a/tests/unit/test_pathspec_math.py
+++ b/tests/unit/test_pathspec_math.py
@@ -1,6 +1,6 @@
 import pytest
 
-from dvc.pathspec_math import _change_dirname
+from dvc.pathspec_math import PatternInfo, _change_dirname
 
 
 @pytest.mark.parametrize(
@@ -69,4 +69,6 @@ from dvc.pathspec_math import _change_dirname
     ],
 )
 def test_dvcignore_pattern_change_dir(tmp_dir, patterns, dirname, changed):
-    assert _change_dirname(dirname, [patterns], "/") == [changed]
+    assert _change_dirname(dirname, [PatternInfo(patterns, "")], "/") == [
+        PatternInfo(changed, "")
+    ]