Repository: psf/black

Issue:
Present a more user-friendly error if  .gitgignore
**Is your feature request related to a problem? Please describe.**

<!-- A clear and concise description of what the problem is.
e.g. I'm always frustrated when [...] -->

Yes. When `.gitignore` is invalid, Black crashes with a confusing `IndexError` exception and it's not obvious that the `.gitignore` file is the issue. This is worsen by the fact, it's non-obvious Black uses `.gitignore` at all (maintainer note: it's been documented so it's fine, just describing how this is even worse of a problem than at first glance).

This is also a problem if say reading fails for some reason.

**Describe the solution you'd like**

<!-- A clear and concise description of what you want to
happen. -->

If an exception is raised within the `get_gitignore` function, capture it and present a more user-friendly error without an unnecessary traceback and with a quick mention of `.gitignore`.

**Describe alternatives you've considered**

<!-- A clear and concise description of any
alternative solutions or features you've considered. -->

- Continue operation even if `.gitignore` can't be parsed, just emit a warning and go on (fine, but I'm hesitant)
- Stop using `.gitignore` automatically (probably too much disruption potential)

**Additional context**

<!-- Add any other context or screenshots about the feature request
here. -->

I thought of this when triaging https://github.com/psf/black/issues/2351.


Discussion:

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -77,7 +77,7 @@ def get_long_description() -> str:
         "tomli>=0.2.6,<2.0.0",
         "typed-ast>=1.4.2; python_version < '3.8'",
         "regex>=2020.1.8",
-        "pathspec>=0.8.1, <1",
+        "pathspec>=0.9.0, <1",
         "dataclasses>=0.6; python_version < '3.7'",
         "typing_extensions>=3.10.0.0; python_version < '3.10'",
         "mypy_extensions>=0.4.3",
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -9,6 +9,7 @@
 from multiprocessing import Manager, freeze_support
 import os
 from pathlib import Path
+from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
 import regex as re
 import signal
 import sys
@@ -428,18 +429,21 @@ def main(
             content=code, fast=fast, write_back=write_back, mode=mode, report=report
         )
     else:
-        sources = get_sources(
-            ctx=ctx,
-            src=src,
-            quiet=quiet,
-            verbose=verbose,
-            include=include,
-            exclude=exclude,
-            extend_exclude=extend_exclude,
-            force_exclude=force_exclude,
-            report=report,
-            stdin_filename=stdin_filename,
-        )
+        try:
+            sources = get_sources(
+                ctx=ctx,
+                src=src,
+                quiet=quiet,
+                verbose=verbose,
+                include=include,
+                exclude=exclude,
+                extend_exclude=extend_exclude,
+                force_exclude=force_exclude,
+                report=report,
+                stdin_filename=stdin_filename,
+            )
+        except GitWildMatchPatternError:
+            ctx.exit(1)
 
         path_empty(
             sources,
diff --git a/src/black/files.py b/src/black/files.py
--- a/src/black/files.py
+++ b/src/black/files.py
@@ -18,6 +18,7 @@
 )
 
 from pathspec import PathSpec
+from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
 import tomli
 
 from black.output import err
@@ -122,7 +123,11 @@ def get_gitignore(root: Path) -> PathSpec:
     if gitignore.is_file():
         with gitignore.open(encoding="utf-8") as gf:
             lines = gf.readlines()
-    return PathSpec.from_lines("gitwildmatch", lines)
+    try:
+        return PathSpec.from_lines("gitwildmatch", lines)
+    except GitWildMatchPatternError as e:
+        err(f"Could not parse {gitignore}: {e}")
+        raise
 
 
 def normalize_path_maybe_ignore(


Test patch:
diff --git a/tests/data/invalid_gitignore_tests/.gitignore b/tests/data/invalid_gitignore_tests/.gitignore
new file mode 100644
--- /dev/null
+++ b/tests/data/invalid_gitignore_tests/.gitignore
@@ -0,0 +1 @@
+!
diff --git a/tests/data/invalid_gitignore_tests/a.py b/tests/data/invalid_gitignore_tests/a.py
new file mode 100644
diff --git a/tests/data/invalid_gitignore_tests/pyproject.toml b/tests/data/invalid_gitignore_tests/pyproject.toml
new file mode 100644
--- /dev/null
+++ b/tests/data/invalid_gitignore_tests/pyproject.toml
@@ -0,0 +1 @@
+# Empty configuration file; used in tests to avoid interference from Black's own config.
diff --git a/tests/data/invalid_nested_gitignore_tests/a.py b/tests/data/invalid_nested_gitignore_tests/a.py
new file mode 100644
diff --git a/tests/data/invalid_nested_gitignore_tests/a/.gitignore b/tests/data/invalid_nested_gitignore_tests/a/.gitignore
new file mode 100644
--- /dev/null
+++ b/tests/data/invalid_nested_gitignore_tests/a/.gitignore
@@ -0,0 +1 @@
+!
diff --git a/tests/data/invalid_nested_gitignore_tests/a/a.py b/tests/data/invalid_nested_gitignore_tests/a/a.py
new file mode 100644
diff --git a/tests/data/invalid_nested_gitignore_tests/pyproject.toml b/tests/data/invalid_nested_gitignore_tests/pyproject.toml
new file mode 100644
--- /dev/null
+++ b/tests/data/invalid_nested_gitignore_tests/pyproject.toml
@@ -0,0 +1 @@
+# Empty configuration file; used in tests to avoid interference from Black's own config.
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -1727,6 +1727,30 @@ def test_nested_gitignore(self) -> None:
         )
         self.assertEqual(sorted(expected), sorted(sources))
 
+    def test_invalid_gitignore(self) -> None:
+        path = THIS_DIR / "data" / "invalid_gitignore_tests"
+        empty_config = path / "pyproject.toml"
+        result = BlackRunner().invoke(
+            black.main, ["--verbose", "--config", str(empty_config), str(path)]
+        )
+        assert result.exit_code == 1
+        assert result.stderr_bytes is not None
+
+        gitignore = path / ".gitignore"
+        assert f"Could not parse {gitignore}" in result.stderr_bytes.decode()
+
+    def test_invalid_nested_gitignore(self) -> None:
+        path = THIS_DIR / "data" / "invalid_nested_gitignore_tests"
+        empty_config = path / "pyproject.toml"
+        result = BlackRunner().invoke(
+            black.main, ["--verbose", "--config", str(empty_config), str(path)]
+        )
+        assert result.exit_code == 1
+        assert result.stderr_bytes is not None
+
+        gitignore = path / "a" / ".gitignore"
+        assert f"Could not parse {gitignore}" in result.stderr_bytes.decode()
+
     def test_empty_include(self) -> None:
         path = THIS_DIR / "data" / "include_exclude_tests"
         report = black.Report()