Repository: conan-io/conan

Issue:
[feature] Support inverse match with .conanignore
### What is your suggestion?

As recommended, we store our Conan config in a distinct Git repository. Since we use many automation tools, we end up with numerous config files and other dotfiles in the repository unrelated to Conan. This repo also serves as a template for other project maintainers, who might add more non-Conan related files. `.conanignore` files are helpful for filtering files to be installed. However, they would be more useful if they supported inverse matching, just like `.gitignore` or `.dockerignore` do. This is especially true for the latter, where it's common practice to ignore all files and only explicitly add those required, e.g.:

```ignore
*
!profiles/
!global.conf
!remotes.json
```

I believe this would make maintaining such a config repository much easier. If third-party libraries are an option, there are [gitignore-compatible parsers](https://pypi.org/project/gitignore-parser/) already available for Python.

### Have you read the CONTRIBUTING guide?

- [x] I've read the CONTRIBUTING guide


Discussion:
Hi @skycaptain thanks a lot for your request. I think this feature makes sense (having conan config remotes alongside other configs is not something we considered when implementing this feature, but I'll let @memsharded chime in about the approach itself!), so I've proposed https://github.com/conan-io/conan/pull/17123

It still needs team review and the approach could be changed in the meanwhile, but this should be released with Conan 2.9 at some point early next month :) 
Yeah, makes sense, this can be added, thanks for the feedback!



Patch:
diff --git a/conan/internal/api/config/config_installer.py b/conan/internal/api/config/config_installer.py
index ad4ac297191..3a7e63679dd 100644
--- a/conan/internal/api/config/config_installer.py
+++ b/conan/internal/api/config/config_installer.py
@@ -16,16 +16,30 @@ class _ConanIgnoreMatcher:
     def __init__(self, conanignore_path, ignore=None):
         conanignore_path = os.path.abspath(conanignore_path)
         self._ignored_entries = {".conanignore"}
+        self._included_entries = set()
         if os.path.exists(conanignore_path):
             with open(conanignore_path, 'r') as conanignore:
                 for line in conanignore:
                     line_content = line.split("#", maxsplit=1)[0].strip()
                     if line_content:
-                        self._ignored_entries.add(line_content)
+                        if line_content.startswith("!"):
+                            self._included_entries.add(line_content[1:])
+                        else:
+                            self._ignored_entries.add(line_content)
         if ignore:
             self._ignored_entries.update(ignore)
 
     def matches(self, path):
+        """Returns whether the path should be ignored
+
+        It's ignored if:
+         - The path does not match any of the included entries
+         - And the path matches any of the ignored entries
+
+        In any other, the path is not ignored"""
+        for include_entry in self._included_entries:
+            if fnmatch.fnmatch(path, include_entry):
+                return False
         for ignore_entry in self._ignored_entries:
             if fnmatch.fnmatch(path, ignore_entry):
                 return True


Test patch:
diff --git a/test/integration/command/config_test.py b/test/integration/command/config_test.py
index 04b749a4728..a85a51e2b84 100644
--- a/test/integration/command/config_test.py
+++ b/test/integration/command/config_test.py
@@ -73,14 +73,18 @@ def test_config_install_conanignore():
     tests/*
     # Next line is commented out, so it should be ignored
     # other_tests/*
+    !b/c/important_file
+    !b/c/important_folder/*
     """)
     tc.save({
         'config_folder/.conanignore': conanignore,
-        "config_folder/a/test": '',
+        'config_folder/a/test': '',
         'config_folder/abracadabra': '',
         'config_folder/b/bison': '',
         'config_folder/b/a/test2': '',
         'config_folder/b/c/helmet': '',
+        'config_folder/b/c/important_file': '',
+        'config_folder/b/c/important_folder/contents': '',
         'config_folder/d/prix': '',
         'config_folder/d/foo/bar': '',
         'config_folder/foo': '',
@@ -107,7 +111,10 @@ def _assert_config_not_exists(path):
     _assert_config_exists("b/bison")
     _assert_config_exists("b/a/test2")
     _assert_config_not_exists("b/c/helmet")
-    _assert_config_not_exists("b/c")
+
+    _assert_config_exists("b/c")
+    _assert_config_exists("b/c/important_file")
+    _assert_config_exists("b/c/important_folder/contents")
 
     _assert_config_not_exists("d/prix")
     _assert_config_not_exists("d/foo/bar")
@@ -118,7 +125,39 @@ def _assert_config_not_exists(path):
     _assert_config_not_exists("tests/tester")
     _assert_config_exists("other_tests/tester2")
 
-    os.listdir(tc.current_folder)
+
+def test_config_install_conanignore_ignore_all_allow_specific_workflow():
+    tc = TestClient()
+    conanignore = textwrap.dedent("""
+    *
+    !important_folder/*
+    !important_file
+    # We can even include the conanignore that we skip by default!
+    !.conanignore
+    """)
+    tc.save({
+        'config_folder/.conanignore': conanignore,
+        'config_folder/a/test': '',
+        'config_folder/abracadabra': '',
+        'config_folder/important_folder/contents': '',
+        'config_folder/important_file': '',
+    })
+
+    def _assert_config_exists(path):
+        assert os.path.exists(os.path.join(tc.cache_folder, path))
+
+    def _assert_config_not_exists(path):
+        assert not os.path.exists(os.path.join(tc.cache_folder, path))
+
+    tc.run('config install config_folder')
+
+    _assert_config_exists(".conanignore")
+
+    _assert_config_not_exists("a")
+    _assert_config_not_exists("abracadabra")
+
+    _assert_config_exists("important_folder/contents")
+    _assert_config_exists("important_file")
 
 
 def test_config_show():