Repository: psf/black

Issue:
Force Exclude does not work with symlinks
**Describe the bug**
I use the `force-exclude` feature to exclude files living in symlinked directories.
However, it looks like `black` uses the regexp from `force-exclude` **after** the symlink is resolved (not the symlinked path).

```
$ black -v /path_2_file/application/api/lib/dir_symlinked/__init__.py
Identified `/path_2_file` as project root containing a .git directory.
Using configuration from project root.
line_length: 135
target_version: ['py39']
force_exclude: (?:.*?application/api(?:-ext)?/lib/dir_symlinked/.+)

skip_string_normalization: True
Found input source: "application/lib/ref_dir/__init__.py"
/path_2_file/application/api/lib/dir_symlinked/__init__.py wasn't modified on disk since last run.

All done! ✨ 🍰 ✨
1 file left unchanged.
```

The resulting error is:

It should have ignored the file based on this path `/path_2_file/application/api/lib/dir_symlinked/__init__.py` rather than including it based on this path `application/lib/ref_dir/__init__.py`.
Said differently it used the resolved/resulting path rather than the path through the symlink

**Environment**

- Black's version: 23.7.0
- OS and Python version: MacOS Ventura 13.4.1, Python 3.9.12, 

**Additional context**

I would be equally happy if I could simply tell `black` **not** to follow symlinks.

Thank you in advance for your help!


Discussion:
Hi,

Thank you for the update, I just tried it using `black` version `23.9.1` and sadly I got the same result as described in the issue here.

@JelleZijlstra could you please reopen this ticket? Thank you
@hauntsaninja or @JelleZijlstra little bump on reopening this. Thank you!! 😄 
Thanks, I think this will be fixed once we deduplicate `get_sources` and `gen_python_files`
The problem in the concrete test case (and why it's not affected by #3846) is likely that the symlinked file is given explicitly on the command line and the command line parameters are separately normalized and checked against excludes:
https://github.com/psf/black/blob/f7174bfc431e22f38b502579d1234989c3c5ce15/src/black/__init__.py#L642-L660

Probably this code should be adjusted the same way as the code in `src/black/files.py`.


Resolving patch:
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -639,7 +639,19 @@ def get_sources(
             path = Path(s)
             is_stdin = False
 
+        # Compare the logic here to the logic in `gen_python_files`.
         if is_stdin or path.is_file():
+            root_relative_path = path.absolute().relative_to(root).as_posix()
+
+            root_relative_path = "/" + root_relative_path
+
+            # Hard-exclude any files that matches the `--force-exclude` regex.
+            if path_is_excluded(root_relative_path, force_exclude):
+                report.path_ignored(
+                    path, "matches the --force-exclude regular expression"
+                )
+                continue
+
             normalized_path: Optional[str] = normalize_path_maybe_ignore(
                 path, root, report
             )
@@ -647,16 +659,6 @@ def get_sources(
                 if verbose:
                     out(f'Skipping invalid source: "{normalized_path}"', fg="red")
                 continue
-            if verbose:
-                out(f'Found input source: "{normalized_path}"', fg="blue")
-
-            normalized_path = "/" + normalized_path
-            # Hard-exclude any files that matches the `--force-exclude` regex.
-            if path_is_excluded(normalized_path, force_exclude):
-                report.path_ignored(
-                    path, "matches the --force-exclude regular expression"
-                )
-                continue
 
             if is_stdin:
                 path = Path(f"{STDIN_PLACEHOLDER}{str(path)}")
@@ -666,6 +668,8 @@ def get_sources(
             ):
                 continue
 
+            if verbose:
+                out(f'Found input source: "{normalized_path}"', fg="blue")
             sources.add(path)
         elif path.is_dir():
             path = root / (path.resolve().relative_to(root))


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -2552,6 +2552,23 @@ def test_get_sources_with_stdin_filename_and_force_exclude(self) -> None:
             stdin_filename=stdin_filename,
         )
 
+    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
+    def test_get_sources_with_stdin_filename_and_force_exclude_and_symlink(
+        self,
+    ) -> None:
+        # Force exclude should exclude a symlink based on the symlink, not its target
+        path = THIS_DIR / "data" / "include_exclude_tests"
+        stdin_filename = str(path / "symlink.py")
+        expected = [f"__BLACK_STDIN_FILENAME__{stdin_filename}"]
+        target = path / "b/exclude/a.py"
+        with patch("pathlib.Path.resolve", return_value=target):
+            assert_collected_sources(
+                src=["-"],
+                expected=expected,
+                force_exclude=r"exclude/a\.py",
+                stdin_filename=stdin_filename,
+            )
+
 
 class TestDeFactoAPI:
     """Test that certain symbols that are commonly used externally keep working.