Repository: iterative/dvc

Issue:
Negation "!" in .dvcignore doesn't unignore
# Bug Report

## Issue name

Negation "!", commonly used in `.gitignore` to "unignore" files, doesn't work in DVC.

## Description

Given 2 files:
- `ignore.txt`
- `no-ignore.txt`

We can **git** ignore them using this `.gitignore` file
```
ignore.txt
!no-ignore.txt
```

However, in DVC, it doesn't "unignore".

### Reproduce

Output shown in comments
```shell
touch ignore.txt no-ignore.txt
echo -e 'ignore.txt\n!no-ignore.txt' > .dvcignore
echo -e 'ignore.txt\n!no-ignore.txt' > .gitignore
cat .dvcignore
# ignore.txt
# !no-ignore.txt
git check-ignore ignore.txt no-ignore.txt
# ignore.txt
dvc check-ignore ignore.txt no-ignore.txt
# ignore.txt
# no-ignore.txt
```

### Expected

Ideally, both `check-ignores` should be the same

### Environment information

**Output of `dvc doctor`:**

```console
$ dvc doctor
DVC version: 3.30.1 (pip)
-------------------------
Platform: Python 3.10.12 on Linux-5.15.133.1-microsoft-standard-WSL2-x86_64-with-glibc2.35
Subprojects:
        dvc_data = 2.22.0
        dvc_objects = 1.2.0
        dvc_render = 0.6.0
        dvc_task = 0.3.0
        scmrepo = 1.4.1
Supports:
        gs (gcsfs = 2023.10.0),
        http (aiohttp = 3.9.0, aiohttp-retry = 2.8.3),
        https (aiohttp = 3.9.0, aiohttp-retry = 2.8.3)
Config:
        Global: /home/jc/.config/dvc
        System: /etc/xdg/dvc
Cache types: hardlink, symlink
Cache directory: 9p on drvfs
Caches: local
Remotes: gs
Workspace directory: 9p on drvfs
Repo: dvc, git
Repo.site_cache_dir: /var/tmp/dvc/repo/67bc00a31a88271f0f2653ea5494f098
```

**Additional Information (if any):**


check-ignore: .dvcignore doesn't handle directory ignores properly
# Bug Report

<!--
## Issue name

Issue names must follow the pattern `command: description` where the command is the dvc command that you are trying to run. The description should describe the consequence of the bug.

Example: `repro: doesn't detect input changes`
-->

## Description

Ignore patterns with a trailing slash should only apply to directories. However, (at least when paired with `**`), they also match to files. This does not match git behavior.

This is important for repos with a large amount of junk or derived outputs, where we want to ignore everything except very specific input folders.

<!--
A clear and concise description of what the bug is.
-->

### Reproduce

```bash
mkdir -p /tmp/test
cd /tmp/test
git init
dvc init
mkdir -p 1/2/3
touch 1/2/shouldIgnore.txt
touch 1/2/3/shouldKeep.txt
cat <<EOF | tee .gitignore .dvcignore
# Ignore everything
1/**
# Except directories (leaves all files ignored)
!1/**/
# Don't ignore files in 3
!seq/**/3/**
EOF

dvc check-ignore -d 1/2/*
git check-ignore -v 1/2/*
dvc check-ignore -d 1/2/3/*
```

<!--
Step list of how to reproduce the bug
-->

<!--
Example:

1. dvc init
2. Copy dataset.zip to the directory
3. dvc add dataset.zip
4. dvc run -d dataset.zip -o model ./train.sh
5. modify dataset.zip
6. dvc repro
-->

### Expected

First check should ignore shouldIgnore.txt, but instead it is included under the rule `!1/**/`:
```
dvc check-ignore -d 1/2/*
.dvcignore:4:!1/**/	1/2/3
.dvcignore:4:!1/**/	1/2/shouldIgnore.txt
```

Git ignores it as expected.

```
git check-ignore -v 1/2/*
.gitignore:4:!1/**/	1/2/3
.gitignore:2:1/**	1/2/shouldIgnore.txt
```

2nd dvc check correctly includes shouldKeep.txt under the last rule.


<!--
A clear and concise description of what you expect to happen.
-->

### Environment information

<!--
This is required to ensure that we can reproduce the bug.
-->

**Output of `dvc doctor`:**

```console
DVC version: 3.56.0
-------------------
Platform: Python 3.12.8 on Linux-6.6.78-x86_64-with-glibc2.40
Subprojects:
	dvc_data = 3.16.7
	dvc_objects = 5.1.0
	dvc_render = 1.0.2
	dvc_task = 0.40.2
	scmrepo = 3.3.8
Supports:
	http (aiohttp = 3.10.10, aiohttp-retry = 2.8.3),
	https (aiohttp = 3.10.10, aiohttp-retry = 2.8.3)
Config:
	Global: /root/.config/dvc
	System: /root/.config/kdedefaults/dvc
Cache types: <https://error.dvc.org/no-dvc-cache>
Caches: local
Remotes: None
Workspace directory: ext4 on /dev/mapper/luks-59bb1234-ec63-4d49-a38a-f6adaef20fee
Repo: dvc, git
Repo.site_cache_dir: /var/cache/dvc/repo/35a50912b07ad0907dc968426d9e9409
```

**Additional Information (if any):**

<!--
Please check https://github.com/iterative/dvc/wiki/Debugging-DVC on ways to gather more information regarding the issue.

If applicable, please also provide a `--verbose` output of the command, eg: `dvc add --verbose`.
If the issue is regarding the performance, please attach the profiling information and the benchmark comparisons.
-->




Discussion:
@Eve-ning I think dvcignore feature ie the patterns is working fine, the **bug is in `dvc check-ignore` command**. `dvc add` unignores the files marked with `!`.  You can try the following. 

```
file: .dvcignore
to_ignore*
!to_ignore-NOT.txt

$ mkdir data
$ cat '1' > data/to_ignore.txt
$ cat '1' > data/to_ignore-NOT.txt

$ dvc add data

$ tree .dvc/cache/files/md5

└── c1
    └── ba58b05f6245f221ad65391fa6690b    <<-- md5 for data/to_ignore-NOT.txt is added to cache

$ md5 data/*

MD5 (data/to_ignore-NOT.txt) = c1ba58b05f6245f221ad65391fa6690b
MD5 (data/to_ignore.txt) = 919d117956d3135c4c683ff021352f5c
```
---

It looks like this is the expected behaviour of check-ignore. [this](https://github.com/iterative/dvc/blob/ad70dd322b329af4e0a2b8475ca5617c3158b7c6/tests/func/test_check_ignore.py#L123) is the test for the behaviour.

@dberenbaum Do you think this test is correct, or am I missing something here? Tagging you since the last activity on this ticket was yours.
Yes, I think that is correct. It looks related to a previous issue in https://github.com/iterative/dvc/issues/5046. DVC uses both of these methods and they don't seem to always be consistent:

https://github.com/iterative/dvc/blob/9b5772fab8ad6ca7e885c97d094043b6ac2e34a9/dvc/ignore.py#L395-L409

https://github.com/iterative/dvc/blob/9b5772fab8ad6ca7e885c97d094043b6ac2e34a9/dvc/ignore.py#L411-L424
`git check-ignore` and `dvc check-ignore` behaves differently for the same ignore patterns.

For `.dvcignore` and `.gitignore` -

```
data/data1
to_ignore*
!to_ignore-NOT.txt
```

Following outputs differ -

```sh
dvc-demo-2 on  master [!?] via 🐍 v3.11.4 (.venv)
❯ git check-ignore data/*
data/data1
data/to_ignore.txt

dvc-demo-2 on  master [!?] via 🐍 v3.11.4 (.venv)
❯ dvc check-ignore data/*
== I am LOCAL DVC ==
data/data1
data/to_ignore-NOT.txt
data/to_ignore.txt
```

@dberenbaum This definitely seems like a bug. I believe the test cases are wrong and needs to be updated. Can the team confirm? 
@anunayasri good catch. Yes, I think DVC check-ignore should behave the same way as `git`.

> I believe the test cases are wrong and needs to be updated. Can the team confirm?

only test case, or the implementation as well?
@shcheklein Of course, we have to update both the test case and the implementation. By test case I meant that the expected behaviour is misaligned.  
I working on another issue. Will try to fix this post that.





Patch:
diff --git a/dvc/commands/check_ignore.py b/dvc/commands/check_ignore.py
index cf49d6550c..3398f8ef1b 100644
--- a/dvc/commands/check_ignore.py
+++ b/dvc/commands/check_ignore.py
@@ -1,23 +1,28 @@
+from typing import TYPE_CHECKING
+
 from dvc.cli import completion, formatter
 from dvc.cli.command import CmdBase
 from dvc.cli.utils import append_doc_link
 from dvc.ui import ui
 
+if TYPE_CHECKING:
+    from dvc.ignore import CheckIgnoreResult
+
 
 class CmdCheckIgnore(CmdBase):
     def __init__(self, args):
         super().__init__(args)
         self.ignore_filter = self.repo.dvcignore
 
-    def _show_results(self, result):
+    def _show_results(self, result: "CheckIgnoreResult"):
         if not result.match and not self.args.details:
             return
 
-        if not result.patterns and not self.args.non_matching:
-            return
-
         if self.args.details:
-            patterns = result.patterns or ["::"]
+            pattern_infos = result.pattern_infos
+            patterns = [str(pi) for pi in pattern_infos]
+            if not patterns and self.args.non_matching:
+                patterns = ["::"]
             if not self.args.all:
                 patterns = patterns[-1:]
 
@@ -25,7 +30,7 @@ def _show_results(self, result):
                 ui.write(pattern, result.file, sep="\t")
         else:
             ui.write(result.file)
-        return bool(result.patterns)
+        return bool(result.pattern_infos)
 
     def _check_one_file(self, target):
         result = self.ignore_filter.check_ignore(target)
@@ -77,6 +82,8 @@ def run(self):
 
 
 def add_parser(subparsers, parent_parser):
+    import argparse
+
     ADD_HELP = "Check whether files or directories are excluded due to `.dvcignore`."
 
     parser = subparsers.add_parser(
@@ -94,14 +101,7 @@ def add_parser(subparsers, parent_parser):
         help="Show the exclude patterns along with each target path.",
     )
     parser.add_argument(
-        "-a",
-        "--all",
-        action="store_true",
-        default=False,
-        help=(
-            "Include the target paths which don't match any pattern "
-            "in the `--details` list."
-        ),
+        "-a", "--all", action="store_true", default=False, help=argparse.SUPPRESS
     )
     parser.add_argument(
         "-n",
diff --git a/dvc/ignore.py b/dvc/ignore.py
index f3a75ef1ee..e102ec5f0b 100644
--- a/dvc/ignore.py
+++ b/dvc/ignore.py
@@ -1,3 +1,4 @@
+import functools
 import os
 import re
 from collections.abc import Iterable, Iterator
@@ -41,21 +42,58 @@ def __init__(
         ]
 
         self.sep = sep
-        self.pattern_list: list[PatternInfo] = pattern_infos
+        self.pattern_list: list[PatternInfo] = []
         self.dirname = dirname
+        self.find_matching_pattern = functools.cache(self._find_matching_pattern)
 
-        self.regex_pattern_list: list[tuple[str, bool]] = []
-        for count, pattern in enumerate(pattern_infos):
-            regex, ignore = GitWildMatchPattern.pattern_to_regex(pattern.patterns)
+        regex_pattern_list: list[tuple[str, bool, bool, PatternInfo]] = []
+        for count, pattern_info in enumerate(pattern_infos):
+            regex, ignore = GitWildMatchPattern.pattern_to_regex(pattern_info.patterns)
             if regex is not None and ignore is not None:
+                self.pattern_list.append(pattern_info)
                 regex = regex.replace(f"<{_DIR_MARK}>", f"<{_DIR_MARK}{count}>")
-                self.regex_pattern_list.append((regex, ignore))
+                regex_pattern_list.append(
+                    (regex, ignore, pattern_info.patterns.endswith("/"), pattern_info)
+                )
+
+        def keyfunc(item: tuple[str, bool, bool, PatternInfo]) -> tuple[bool, bool]:
+            _, ignore, dir_only_pattern, _ = item
+            return ignore, dir_only_pattern
 
-        self.ignore_spec = [
-            (ignore, re.compile("|".join(regex for regex, _ in group)))
-            for ignore, group in groupby(self.regex_pattern_list, lambda x: x[1])
-            if ignore is not None
+        self.ignore_spec: list[
+            tuple[
+                re.Pattern[str],
+                bool,
+                bool,
+                dict[Optional[str], tuple[str, PatternInfo]],
+            ]
         ]
+        self.ignore_spec = []
+        for (ignore, dir_only_pattern), group in groupby(
+            regex_pattern_list, key=keyfunc
+        ):
+            if ignore:
+                # For performance, we combine all exclude patterns.
+                # But we still need to figure out which pattern matched which rule,
+                # (eg: to show in `dvc check-ignore`).
+                # So, we use named groups and keep a map of group name to pattern.
+                pattern_map: dict[Optional[str], tuple[str, PatternInfo]] = {
+                    f"rule_{i}": (regex, pi)
+                    for i, (regex, _, _, pi) in enumerate(group)
+                }
+                combined_regex = "|".join(
+                    f"(?P<{name}>{regex})" for name, (regex, _) in pattern_map.items()
+                )
+                self.ignore_spec.append(
+                    (re.compile(combined_regex), ignore, dir_only_pattern, pattern_map)
+                )
+            else:
+                # unignored patterns are not combined with `|`.
+                for regex, _, _, pi in group:
+                    pattern_map = {None: (regex, pi)}
+                    self.ignore_spec.append(
+                        (re.compile(regex), ignore, dir_only_pattern, pattern_map)
+                    )
 
     @classmethod
     def from_file(cls, path: str, fs: "FileSystem", name: str) -> "Self":
@@ -113,60 +151,78 @@ def matches(
         basename: str,
         is_dir: bool = False,
         details: Literal[True] = ...,
-    ) -> tuple[bool, list[str]]: ...
+    ) -> tuple[bool, list[PatternInfo]]: ...
 
     @overload
     def matches(
-        self, dirname: str, basename: str, is_dir: bool = False, details: bool = False
-    ) -> Union[bool, tuple[bool, list[str]]]: ...
+        self,
+        dirname: str,
+        basename: str,
+        is_dir: bool = False,
+        details: bool = False,
+    ) -> Union[bool, tuple[bool, list[PatternInfo]]]: ...
 
     def matches(
-        self, dirname: str, basename: str, is_dir: bool = False, details: bool = False
-    ) -> Union[bool, tuple[bool, list[str]]]:
+        self,
+        dirname: str,
+        basename: str,
+        is_dir: bool = False,
+        details: bool = False,
+    ) -> Union[bool, tuple[bool, list[PatternInfo]]]:
         path = self._get_normalize_path(dirname, basename)
-        if not path:
-            return (False, []) if details else False
-        if details:
-            return self._ignore_details(path, is_dir)
-        return self.ignore(path, is_dir)
-
-    def ignore(self, path: str, is_dir: bool) -> bool:
-        def matches(pattern, path, is_dir) -> bool:
-            matches_ = bool(pattern.match(path))
-
-            if is_dir:
-                matches_ |= bool(pattern.match(f"{path}/"))
-
-            return matches_
-
-        result = False
-
-        for ignore, pattern in self.ignore_spec[::-1]:
-            if matches(pattern, path, is_dir):
-                result = ignore
-                break
-        return result
-
-    def _ignore_details(self, path: str, is_dir: bool) -> tuple[bool, list[str]]:
         result = False
-        matched_patterns = []
-        for (regex, ignore), pattern_info in list(
-            zip(self.regex_pattern_list, self.pattern_list)
+        _match: list[PatternInfo] = []
+        if path:
+            result, _match = self._ignore(path, is_dir)
+        return (result, _match) if details else result
+
+    def _find_matching_pattern(
+        self, path: str, is_dir: bool
+    ) -> tuple[bool, list[PatternInfo]]:
+        paths = [path]
+        if is_dir and not path.endswith("/"):
+            paths.append(f"{path}/")
+
+        for pattern, ignore, dir_only_pattern, pattern_map in reversed(
+            self.ignore_spec
         ):
-            # skip system pattern
-            if not pattern_info.file_info:
+            if dir_only_pattern and not is_dir:
                 continue
-
-            pattern = re.compile(regex)
-
-            matches = bool(pattern.match(path))
-            if is_dir:
-                matches |= bool(pattern.match(f"{path}/"))
-
-            if matches:
-                matched_patterns.append(pattern_info.file_info)
-                result = ignore
-        return result, matched_patterns
+            for p in paths:
+                match = pattern.match(p)
+                if not match:
+                    continue
+                if ignore:
+                    group_name, _match = next(
+                        (
+                            (name, _match)
+                            for name, _match in match.groupdict().items()
+                            if name.startswith("rule_") and _match is not None
+                        )
+                    )
+                else:
+                    # unignored patterns are not combined with `|`,
+                    # so there are no groups.
+                    group_name = None
+                _regex, pattern_info = pattern_map[group_name]
+                return ignore, [pattern_info]
+        return False, []
+
+    def _ignore(self, path: str, is_dir: bool) -> tuple[bool, list[PatternInfo]]:
+        parts = path.split("/")
+        result = False
+        matches: list[PatternInfo] = []
+        for i in range(1, len(parts) + 1):
+            rel_path = "/".join(parts[:i])
+            result, _matches = self.find_matching_pattern(
+                rel_path, is_dir or i < len(parts)
+            )
+            if i < len(parts) and not result:
+                continue
+            matches.extend(_matches)
+            if result:
+                break
+        return result, matches
 
     def __hash__(self) -> int:
         return hash(self.dirname + ":" + str(self.pattern_list))
@@ -186,7 +242,7 @@ def __bool__(self) -> bool:
 class CheckIgnoreResult(NamedTuple):
     file: str
     match: bool
-    patterns: list[str]
+    pattern_infos: list[PatternInfo]
 
 
 class DvcIgnoreFilter:
@@ -454,14 +510,14 @@ def check_ignore(self, target: str) -> CheckIgnoreResult:
         # NOTE: can only be used in `dvc check-ignore`, see
         # https://github.com/iterative/dvc/issues/5046
         full_target = self.fs.abspath(target)
-        matched_patterns: list[str] = []
+        matched_patterns: list[PatternInfo] = []
         ignore = False
         if not self._outside_repo(full_target):
             dirname, basename = self.fs.split(self.fs.normpath(full_target))
             pattern = self._get_trie_pattern(dirname)
             if pattern:
                 ignore, matched_patterns = pattern.matches(
-                    dirname, basename, self.fs.isdir(full_target), True
+                    dirname, basename, self.fs.isdir(full_target), details=True
                 )
         return CheckIgnoreResult(target, ignore, matched_patterns)
 
diff --git a/dvc/output.py b/dvc/output.py
index 6a615549a8..485bfe4787 100644
--- a/dvc/output.py
+++ b/dvc/output.py
@@ -45,7 +45,7 @@
     from dvc_data.hashfile.obj import HashFile
     from dvc_data.index import DataIndexKey
 
-    from .ignore import DvcIgnoreFilter
+    from .ignore import CheckIgnoreResult, DvcIgnoreFilter
 
 logger = logger.getChild(__name__)
 
@@ -267,9 +267,9 @@ def __init__(self, path):
 
 
 class OutputIsIgnoredError(DvcException):
-    def __init__(self, match):
-        lines = "\n".join(match.patterns)
-        super().__init__(f"Path '{match.file}' is ignored by\n{lines}")
+    def __init__(self, result: "CheckIgnoreResult"):
+        pi = result.pattern_infos[-1]
+        super().__init__(f"Path {result.file!r} is ignored by {pi!s}")
 
 
 class CheckoutCallback(TqdmCallback):
@@ -1203,8 +1203,9 @@ def _validate_output_path(self, path, stage=None):
         if stage:
             abs_path = os.path.join(stage.wdir, path)
             if self._is_path_dvcignore(abs_path):
-                check = stage.repo.dvcignore.check_ignore(abs_path)
-                raise self.IsIgnoredError(check)
+                check: CheckIgnoreResult = stage.repo.dvcignore.check_ignore(abs_path)
+                if check.match:
+                    raise self.IsIgnoredError(check)
 
     def _check_can_merge(self, out):
         if self.protocol != out.protocol:
diff --git a/dvc/pathspec_math.py b/dvc/pathspec_math.py
index 5d73c6125b..59e84b626e 100644
--- a/dvc/pathspec_math.py
+++ b/dvc/pathspec_math.py
@@ -13,6 +13,9 @@ class PatternInfo(NamedTuple):
     patterns: str
     file_info: str
 
+    def __str__(self) -> str:
+        return self.file_info or f":{self.patterns}"
+
 
 def _not_ignore(rule):
     return (True, rule[1:]) if rule.startswith("!") else (False, rule)


Test patch:
diff --git a/tests/func/test_check_ignore.py b/tests/func/test_check_ignore.py
index 3e00d0a344..2d02cd1417 100644
--- a/tests/func/test_check_ignore.py
+++ b/tests/func/test_check_ignore.py
@@ -78,7 +78,7 @@ def test_check_ignore_dir(tmp_dir, dvc, path, ret):
 
 
 def test_check_ignore_default_dir(tmp_dir, dvc):
-    assert main(["check-ignore", "-q", ".dvc"]) == 1
+    assert main(["check-ignore", "-q", ".dvc"]) == 0
 
 
 def test_check_ignore_out_side_repo(tmp_dir, dvc):
@@ -125,7 +125,6 @@ def test_check_ignore_details_all(tmp_dir, dvc, capsys):
 
     assert main(["check-ignore", "-d", "-a", "foo"]) == 0
     out, _ = capsys.readouterr()
-    assert f"{DvcIgnore.DVCIGNORE_FILE}:1:f*\tfoo\n" in out
     assert f"{DvcIgnore.DVCIGNORE_FILE}:2:!foo\tfoo\n" in out
 
 
diff --git a/tests/unit/test_ignore.py b/tests/unit/test_ignore.py
index e24a39e798..3dfa458cd0 100644
--- a/tests/unit/test_ignore.py
+++ b/tests/unit/test_ignore.py
@@ -1,4 +1,5 @@
 import os
+from os.path import join
 
 import pytest
 
@@ -202,3 +203,154 @@ def test_should_ignore_dir(omit_dir, sub_dir):
 
     assert set(new_dirs) == {"dir1", "dir2"}
     assert set(new_files) == {"file1", "file2", omit_dir}
+
+
+def test_ignore_complex(tmp_dir, dvc):
+    from dvc.fs import localfs
+
+    spec = """\
+# Ignore everything
+1/**
+# Except directories (leaves all files ignored)
+!1/**/
+# Don't ignore files in 3
+!seq/**/3/**
+
+data/
+!data/keep.csv
+
+data2/**
+!data2/**/
+!data2/**/*.csv
+
+ignore.txt
+!no-ignore.txt
+"""
+    (tmp_dir / ".dvcignore").write_text(spec)
+    (tmp_dir / "1" / "2" / "3").mkdir(parents=True, exist_ok=True)
+    (tmp_dir / "1" / "2" / "shouldIgnore.txt").touch()
+    (tmp_dir / "1" / "2" / "3" / "shouldKeep.txt").touch()
+    (tmp_dir / "data" / "subdir").mkdir(parents=True, exist_ok=True)
+    (tmp_dir / "data2" / "subdir").mkdir(parents=True, exist_ok=True)
+    (tmp_dir / "data" / "keep.csv").touch()
+    (tmp_dir / "data" / "other.csv").touch()
+    (tmp_dir / "data" / "subdir" / "file.txt").touch()
+    (tmp_dir / "data2" / "keep.csv").touch()
+    (tmp_dir / "data2" / "other.txt").touch()
+    (tmp_dir / "data2" / "subdir" / "keep.csv").touch()
+    (tmp_dir / "data2" / "subdir" / "other.txt").touch()
+    (tmp_dir / "ignore.txt").touch()
+    (tmp_dir / "no-ignore.txt").touch()
+
+    ignore_file = DvcIgnorePatterns.from_file(
+        os.fspath(tmp_dir / ".dvcignore"), localfs, ".dvcignore"
+    )
+    dvc.__dict__.pop("dvcignore", None)
+
+    def matches(path):
+        result, _matches = ignore_file.matches(
+            os.fspath(tmp_dir), path, (tmp_dir / path).is_dir(), details=True
+        )
+        return result, [str(m) for m in _matches]
+
+    for path, *expected in [
+        ("1", False, [".dvcignore:4:!1/**/"]),
+        (join("1", ""), False, [".dvcignore:4:!1/**/"]),
+        (join("1", "2"), False, [".dvcignore:4:!1/**/"]),
+        (join("1", "2", ""), False, [".dvcignore:4:!1/**/"]),
+        (join("1", "2", "shouldIgnore.txt"), True, [".dvcignore:2:1/**"]),
+        (join("1", "2", "3"), False, [".dvcignore:4:!1/**/"]),
+        (join("1", "2", "3", ""), False, [".dvcignore:4:!1/**/"]),
+        (join("1", "2", "3", "shouldKeep.txt"), True, [".dvcignore:2:1/**"]),
+        ("data", True, [".dvcignore:8:data/"]),
+        (join("data", ""), True, [".dvcignore:8:data/"]),
+        (join("data", "keep.csv"), True, [".dvcignore:8:data/"]),
+        (join("data", "other.csv"), True, [".dvcignore:8:data/"]),
+        (join("data", "subdir", "file.txt"), True, [".dvcignore:8:data/"]),
+        ("data2", False, [".dvcignore:12:!data2/**/"]),
+        (join("data2", ""), False, [".dvcignore:12:!data2/**/"]),
+        (join("data2", "keep.csv"), False, [".dvcignore:13:!data2/**/*.csv"]),
+        (join("data2", "other.txt"), True, [".dvcignore:11:data2/**"]),
+        (join("data2", "subdir"), False, [".dvcignore:12:!data2/**/"]),
+        (join("data2", "subdir", ""), False, [".dvcignore:12:!data2/**/"]),
+        (join("data2", "subdir", "keep.csv"), False, [".dvcignore:13:!data2/**/*.csv"]),
+        (join("data2", "subdir", "other.txt"), True, [".dvcignore:11:data2/**"]),
+        ("ignore.txt", True, [".dvcignore:15:ignore.txt"]),
+        ("no-ignore.txt", False, [".dvcignore:16:!no-ignore.txt"]),
+    ]:
+        assert matches(path) == tuple(expected), f"for {path}"
+
+    def sorted_walk(path):
+        for root, dirs, files in dvc.dvcignore.walk(localfs, path):
+            dirs.sort()
+            files.sort()
+            yield root, dirs, files
+
+    assert list(sorted_walk(os.curdir)) == [
+        (
+            os.curdir,
+            ["1", "data2"],
+            [".dvcignore", "no-ignore.txt"],
+        ),
+        ("1", ["2"], []),
+        (join("1", "2"), ["3"], []),
+        (join("1", "2", "3"), [], []),
+        ("data2", ["subdir"], ["keep.csv"]),
+        (join("data2", "subdir"), [], ["keep.csv"]),
+    ]
+
+
+def test_ignore_unignore_from_git_example(tmp_dir, dvc, scm):
+    from dvc.fs import localfs
+
+    spec = """\
+# exclude everything except directory foo/bar
+/*
+!/foo
+/foo/*
+!/foo/bar
+"""
+    (tmp_dir / ".dvcignore").write_text(spec)
+    for d in [
+        tmp_dir,
+        tmp_dir / "foo",
+        tmp_dir / "foo" / "bar",
+        tmp_dir / "foo" / "baz",
+        tmp_dir / "foobar",
+    ]:
+        d.mkdir(parents=True, exist_ok=True)
+        (d / "myfile").touch()
+
+    ignore_file = DvcIgnorePatterns.from_file(
+        os.fspath(tmp_dir / ".dvcignore"), localfs, ".dvcignore"
+    )
+    dvc.__dict__.pop("dvcignore", None)
+
+    def matches(path):
+        result, _matches = ignore_file.matches(
+            os.fspath(tmp_dir), path, (tmp_dir / path).is_dir(), details=True
+        )
+        return result, [str(m) for m in _matches]
+
+    for path, *expected in [
+        ("foo", False, [".dvcignore:3:!/foo"]),
+        (join("foo", ""), False, [".dvcignore:3:!/foo"]),
+        (join("foo", "myfile"), True, [".dvcignore:4:/foo/*"]),
+        (join("foo", "bar"), False, [".dvcignore:5:!/foo/bar"]),
+        (join("foo", "bar", ""), False, [".dvcignore:5:!/foo/bar"]),
+        # matching pattern differs from git for foo/bar/myfile
+        (join("foo", "bar", "myfile"), False, [".dvcignore:5:!/foo/bar"]),
+        (join("foo", "baz"), True, [".dvcignore:4:/foo/*"]),
+        (join("foo", "baz", ""), True, [".dvcignore:4:/foo/*"]),
+        (join("foo", "baz", "myfile"), True, [".dvcignore:4:/foo/*"]),
+        (join("foo", "foobar"), True, [".dvcignore:4:/foo/*"]),
+        (join("foo", "foobar", ""), True, [".dvcignore:4:/foo/*"]),
+        (join("foo", "foobar", "myfile"), True, [".dvcignore:4:/foo/*"]),
+    ]:
+        assert matches(path) == tuple(expected), f"for {path}"
+
+    assert sorted(dvc.dvcignore.walk(localfs, os.curdir), key=lambda r: r[0]) == [
+        (os.curdir, ["foo"], []),
+        ("foo", ["bar"], []),
+        (join("foo", "bar"), [], ["myfile"]),
+    ]