Repository: psf/black

Issue:
`black` ignores file in current folder if current folder is `git` workspace and (parent of) current folder is a symbolic link
**Describe the bug**

`black` incorrectly ignores a file in the current folder

**To Reproduce**

```
(.venv) C:\>dir

12/21/2023  10:19 AM    <JUNCTION>     Code [C:\ws]
...
02/02/2024  08:33 AM    <DIR>          ws

(.venv) C:\>cd Code

(.venv) C:\Code>mkdir Bug

(.venv) C:\Code>cd Bug

(.venv) C:\Code\Bug>git init
Initialized empty Git repository in C:/ws/Bug/.git/

(.venv) C:\Code\Bug>echo 1 > bug.py

(.venv) C:\Code\Bug>black --verbose bug.py
Identified `C:\ws\Bug` as project root containing a .git directory.
bug.py ignored: is a symbolic link that points outside C:\ws\Bug
No Python files are present to be formatted. Nothing to do 😴
```

**Expected behavior**
`black` should not ignore `bug.py`

**Environment**

black, 24.1.2.dev11+g632f44bd68 (compiled: no)
Python (CPython) 3.12.1
Windows 10 22H2
stdin-filename doesn't support UNC path in python 3.8/3.9 on Windows
<!--
Please make sure that the bug is not already fixed either in newer versions or the
current development version. To confirm this, you have three options:

1. Update Black's version if a newer release exists: `pip install -U black`
2. Use the online formatter at <https://black.vercel.app/?version=main>, which will use
   the latest main branch.
3. Or run _Black_ on your machine:
   - create a new virtualenv (make sure it's the same Python version);
   - clone this repository;
   - run `pip install -e .[d]`;
   - run `pip install -r test_requirements.txt`
   - make sure it's sane by running `python -m pytest`; and
   - run `black` like you did last time.
-->

**Describe the bug**

In Windows, the UNC path is not supported when using as `--stdin-filename`. For example, let's say one path is: `\\?\D:\git\OLive\setup.py`

When running 
```bash
python.exe -mblack --stdin-filename \\?\D:\git\OLive\setup.py -v -
```
The following error message will be shown.
![image](https://github.com/psf/black/assets/817030/7b47d183-ec6f-46bc-9b53-e7e98e88d949)


**To Reproduce**

<!--
Minimal steps to reproduce the behavior with source code and Black's configuration.
-->

Windows Python pathlib doesn't support UNC path. 
**Expected behavior**
UNC path `\\?\D:\git\OLive\setup.py` should be supported in Windows allow as stdin-filename

**Environment**
* Windows
* Python 3.8.18
* black: 24.1.1
* 

<!-- Please complete the following information: -->

- Black's version: <!-- e.g. [main] -->
- OS and Python version: <!-- e.g. [Linux/Python 3.7.4rc1] -->

**Additional context**

<!-- Add any other context about the problem here. -->



Discussion:
Hm, I tried the following and couldn't repro:
```
. λ mkdir actual
. λ ln -sf actual symlink
. λ cd symlink
./symlink λ ls
./symlink λ mkdir bug
./symlink λ cd bug
./symlink/bug λ git init
Initialized empty Git repository in /Users/shantanu/dev/black/actual/bug/.git/
./symlink/bug λ echo "x     = 'asdf'" > bug.py
./symlink/bug λ black --verbose bug.py
Identified `/Users/shantanu/dev/black/actual/bug` as project root containing a .git directory.
Found input source: "bug.py"
reformatted bug.py

All done! ✨ 🍰 ✨
1 file reformatted.
```
Perhaps something Windows/junction-specific is happening?

I also wasn't able to repro with any `--stdin-filename` variants
Okay, while looking into `--stdin-filename` variants I noticed relative path vs absolute path is an issue. When located in `~/dev/black/symlink/bug`:
```
~/dev/black/symlink/bug main λ black bug.py -v --diff
Identified `/Users/shantanu/dev/black/actual/bug` as project root containing a .git directory.
Found input source: "bug.py"
--- bug.py	2024-02-11 00:29:53.369145+00:00
+++ bug.py	2024-02-11 02:12:44.358110+00:00
@@ -1 +1 @@
-x     = 'asdf'
+x = "asdf"
would reformat bug.py

All done! ✨ 🍰 ✨
1 file would be reformatted.

~/dev/black/symlink/bug main λ black ~/dev/black/symlink/bug/bug.py -v --diff
Identified `/Users/shantanu/dev/black/actual/bug` as project root containing a .git directory.
/Users/shantanu/dev/black/symlink/bug/bug.py ignored: is a symbolic link that points outside /Users/shantanu/dev/black/actual/bug
No Python files are present to be formatted. Nothing to do 😴
```

This could pretty easily account for https://github.com/microsoft/vscode-black-formatter/issues/444


Resolving patch:
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -44,12 +44,12 @@
     STDIN_PLACEHOLDER,
 )
 from black.files import (
+    best_effort_relative_path,
     find_project_root,
     find_pyproject_toml,
     find_user_pyproject_toml,
     gen_python_files,
     get_gitignore,
-    get_root_relative_path,
     parse_pyproject_toml,
     path_is_excluded,
     resolves_outside_root_or_cannot_stat,
@@ -734,6 +734,7 @@ def get_sources(
     """Compute the set of files to be formatted."""
     sources: Set[Path] = set()
 
+    assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
     using_default_exclude = exclude is None
     exclude = re_compile_maybe_verbose(DEFAULT_EXCLUDES) if exclude is None else exclude
     gitignore: Optional[Dict[Path, PathSpec]] = None
@@ -749,11 +750,12 @@ def get_sources(
 
         # Compare the logic here to the logic in `gen_python_files`.
         if is_stdin or path.is_file():
-            root_relative_path = get_root_relative_path(path, root, report)
-
-            if root_relative_path is None:
+            if resolves_outside_root_or_cannot_stat(path, root, report):
+                if verbose:
+                    out(f'Skipping invalid source: "{path}"', fg="red")
                 continue
 
+            root_relative_path = best_effort_relative_path(path, root).as_posix()
             root_relative_path = "/" + root_relative_path
 
             # Hard-exclude any files that matches the `--force-exclude` regex.
@@ -763,11 +765,6 @@ def get_sources(
                 )
                 continue
 
-            if resolves_outside_root_or_cannot_stat(path, root, report):
-                if verbose:
-                    out(f'Skipping invalid source: "{path}"', fg="red")
-                continue
-
             if is_stdin:
                 path = Path(f"{STDIN_PLACEHOLDER}{str(path)}")
 
diff --git a/src/black/files.py b/src/black/files.py
--- a/src/black/files.py
+++ b/src/black/files.py
@@ -48,6 +48,11 @@ def _load_toml(path: Union[Path, str]) -> Dict[str, Any]:
         return tomllib.load(f)
 
 
+@lru_cache
+def _cached_resolve(path: Path) -> Path:
+    return path.resolve()
+
+
 @lru_cache
 def find_project_root(
     srcs: Sequence[str], stdin_filename: Optional[str] = None
@@ -67,9 +72,9 @@ def find_project_root(
     if stdin_filename is not None:
         srcs = tuple(stdin_filename if s == "-" else s for s in srcs)
     if not srcs:
-        srcs = [str(Path.cwd().resolve())]
+        srcs = [str(_cached_resolve(Path.cwd()))]
 
-    path_srcs = [Path(Path.cwd(), src).resolve() for src in srcs]
+    path_srcs = [_cached_resolve(Path(Path.cwd(), src)) for src in srcs]
 
     # A list of lists of parents for each 'src'. 'src' is included as a
     # "parent" of itself if it is a directory
@@ -236,7 +241,7 @@ def find_user_pyproject_toml() -> Path:
     else:
         config_root = os.environ.get("XDG_CONFIG_HOME", "~/.config")
         user_config_path = Path(config_root).expanduser() / "black"
-    return user_config_path.resolve()
+    return _cached_resolve(user_config_path)
 
 
 @lru_cache
@@ -266,27 +271,31 @@ def resolves_outside_root_or_cannot_stat(
     try:
         if sys.version_info < (3, 8, 6):
             path = path.absolute()  # https://bugs.python.org/issue33660
-        resolved_path = path.resolve()
-        return get_root_relative_path(resolved_path, root, report) is None
+        resolved_path = _cached_resolve(path)
     except OSError as e:
         if report:
             report.path_ignored(path, f"cannot be read because {e}")
         return True
-
-
-def get_root_relative_path(
-    path: Path,
-    root: Path,
-    report: Optional[Report] = None,
-) -> Optional[str]:
-    """Returns the file path relative to the 'root' directory"""
     try:
-        root_relative_path = path.absolute().relative_to(root).as_posix()
+        resolved_path.relative_to(root)
     except ValueError:
         if report:
             report.path_ignored(path, f"is a symbolic link that points outside {root}")
-        return None
-    return root_relative_path
+        return True
+    return False
+
+
+def best_effort_relative_path(path: Path, root: Path) -> Path:
+    # Precondition: resolves_outside_root_or_cannot_stat(path, root) is False
+    try:
+        return path.absolute().relative_to(root)
+    except ValueError:
+        pass
+    root_parent = next((p for p in path.parents if _cached_resolve(p) == root), None)
+    if root_parent is not None:
+        return path.relative_to(root_parent)
+    # something adversarial, fallback to path guaranteed by precondition
+    return _cached_resolve(path).relative_to(root)
 
 
 def _path_is_ignored(
@@ -339,7 +348,8 @@ def gen_python_files(
 
     assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
     for child in paths:
-        root_relative_path = child.absolute().relative_to(root).as_posix()
+        assert child.is_absolute()
+        root_relative_path = child.relative_to(root).as_posix()
 
         # First ignore files matching .gitignore, if passed
         if gitignore_dict and _path_is_ignored(


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -2567,32 +2567,32 @@ def test_symlinks(self) -> None:
         gitignore = PathSpec.from_lines("gitwildmatch", [])
 
         regular = MagicMock()
-        regular.absolute.return_value = root / "regular.py"
+        regular.relative_to.return_value = Path("regular.py")
         regular.resolve.return_value = root / "regular.py"
         regular.is_dir.return_value = False
         regular.is_file.return_value = True
 
         outside_root_symlink = MagicMock()
-        outside_root_symlink.absolute.return_value = root / "symlink.py"
+        outside_root_symlink.relative_to.return_value = Path("symlink.py")
         outside_root_symlink.resolve.return_value = Path("/nowhere")
         outside_root_symlink.is_dir.return_value = False
         outside_root_symlink.is_file.return_value = True
 
         ignored_symlink = MagicMock()
-        ignored_symlink.absolute.return_value = root / ".mypy_cache" / "symlink.py"
+        ignored_symlink.relative_to.return_value = Path(".mypy_cache") / "symlink.py"
         ignored_symlink.is_dir.return_value = False
         ignored_symlink.is_file.return_value = True
 
         # A symlink that has an excluded name, but points to an included name
         symlink_excluded_name = MagicMock()
-        symlink_excluded_name.absolute.return_value = root / "excluded_name"
+        symlink_excluded_name.relative_to.return_value = Path("excluded_name")
         symlink_excluded_name.resolve.return_value = root / "included_name.py"
         symlink_excluded_name.is_dir.return_value = False
         symlink_excluded_name.is_file.return_value = True
 
         # A symlink that has an included name, but points to an excluded name
         symlink_included_name = MagicMock()
-        symlink_included_name.absolute.return_value = root / "included_name.py"
+        symlink_included_name.relative_to.return_value = Path("included_name.py")
         symlink_included_name.resolve.return_value = root / "excluded_name"
         symlink_included_name.is_dir.return_value = False
         symlink_included_name.is_file.return_value = True
@@ -2626,39 +2626,100 @@ def test_symlinks(self) -> None:
         outside_root_symlink.resolve.assert_called_once()
         ignored_symlink.resolve.assert_not_called()
 
+    def test_get_sources_symlink_and_force_exclude(self) -> None:
+        with TemporaryDirectory() as tempdir:
+            tmp = Path(tempdir).resolve()
+            actual = tmp / "actual"
+            actual.mkdir()
+            symlink = tmp / "symlink"
+            symlink.symlink_to(actual)
+
+            actual_proj = actual / "project"
+            actual_proj.mkdir()
+            (actual_proj / "module.py").write_text("print('hello')", encoding="utf-8")
+
+            symlink_proj = symlink / "project"
+
+            with change_directory(symlink_proj):
+                assert_collected_sources(
+                    src=["module.py"],
+                    root=symlink_proj.resolve(),
+                    expected=["module.py"],
+                )
+
+                absolute_module = symlink_proj / "module.py"
+                assert_collected_sources(
+                    src=[absolute_module],
+                    root=symlink_proj.resolve(),
+                    expected=[absolute_module],
+                )
+
+                # a few tricky tests for force_exclude
+                flat_symlink = symlink_proj / "symlink_module.py"
+                flat_symlink.symlink_to(actual_proj / "module.py")
+                assert_collected_sources(
+                    src=[flat_symlink],
+                    root=symlink_proj.resolve(),
+                    force_exclude=r"/symlink_module.py",
+                    expected=[],
+                )
+
+                target = actual_proj / "target"
+                target.mkdir()
+                (target / "another.py").write_text("print('hello')", encoding="utf-8")
+                (symlink_proj / "nested").symlink_to(target)
+
+                assert_collected_sources(
+                    src=[symlink_proj / "nested" / "another.py"],
+                    root=symlink_proj.resolve(),
+                    force_exclude=r"nested",
+                    expected=[],
+                )
+                assert_collected_sources(
+                    src=[symlink_proj / "nested" / "another.py"],
+                    root=symlink_proj.resolve(),
+                    force_exclude=r"target",
+                    expected=[symlink_proj / "nested" / "another.py"],
+                )
+
     def test_get_sources_with_stdin_symlink_outside_root(
         self,
     ) -> None:
         path = THIS_DIR / "data" / "include_exclude_tests"
         stdin_filename = str(path / "b/exclude/a.py")
         outside_root_symlink = Path("/target_directory/a.py")
+        root = Path("target_dir/").resolve().absolute()
         with patch("pathlib.Path.resolve", return_value=outside_root_symlink):
             assert_collected_sources(
-                root=Path("target_directory/"),
+                root=root,
                 src=["-"],
                 expected=[],
                 stdin_filename=stdin_filename,
             )
 
-    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin(self) -> None:
         src = ["-"]
         expected = ["-"]
-        assert_collected_sources(src, expected, include="", exclude=r"/exclude/|a\.py")
+        assert_collected_sources(
+            src,
+            root=THIS_DIR.resolve(),
+            expected=expected,
+            include="",
+            exclude=r"/exclude/|a\.py",
+        )
 
-    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin_filename(self) -> None:
         src = ["-"]
         stdin_filename = str(THIS_DIR / "data/collections.py")
         expected = [f"__BLACK_STDIN_FILENAME__{stdin_filename}"]
         assert_collected_sources(
             src,
-            expected,
+            root=THIS_DIR.resolve(),
+            expected=expected,
             exclude=r"/exclude/a\.py",
             stdin_filename=stdin_filename,
         )
 
-    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin_filename_and_exclude(self) -> None:
         # Exclude shouldn't exclude stdin_filename since it is mimicking the
         # file being passed directly. This is the same as
@@ -2669,12 +2730,12 @@ def test_get_sources_with_stdin_filename_and_exclude(self) -> None:
         expected = [f"__BLACK_STDIN_FILENAME__{stdin_filename}"]
         assert_collected_sources(
             src,
-            expected,
+            root=THIS_DIR.resolve(),
+            expected=expected,
             exclude=r"/exclude/|a\.py",
             stdin_filename=stdin_filename,
         )
 
-    @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin_filename_and_extend_exclude(self) -> None:
         # Extend exclude shouldn't exclude stdin_filename since it is mimicking the
         # file being passed directly. This is the same as
@@ -2685,12 +2746,12 @@ def test_get_sources_with_stdin_filename_and_extend_exclude(self) -> None:
         expected = [f"__BLACK_STDIN_FILENAME__{stdin_filename}"]
         assert_collected_sources(
             src,
-            expected,
+            root=THIS_DIR.resolve(),
+            expected=expected,
             extend_exclude=r"/exclude/|a\.py",
             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(self) -> None:
         # Force exclude should exclude the file when passing it through
         # stdin_filename
@@ -2698,27 +2759,32 @@ def test_get_sources_with_stdin_filename_and_force_exclude(self) -> None:
         stdin_filename = str(path / "b/exclude/a.py")
         assert_collected_sources(
             src=["-"],
+            root=THIS_DIR.resolve(),
             expected=[],
             force_exclude=r"/exclude/|a\.py",
             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,
-            )
+        with TemporaryDirectory() as tempdir:
+            tmp = Path(tempdir).resolve()
+            (tmp / "exclude").mkdir()
+            (tmp / "exclude" / "a.py").write_text("print('hello')", encoding="utf-8")
+            (tmp / "symlink.py").symlink_to(tmp / "exclude" / "a.py")
+
+            stdin_filename = str(tmp / "symlink.py")
+            expected = [f"__BLACK_STDIN_FILENAME__{stdin_filename}"]
+            with change_directory(tmp):
+                assert_collected_sources(
+                    src=["-"],
+                    root=tmp,
+                    expected=expected,
+                    force_exclude=r"exclude/a\.py",
+                    stdin_filename=stdin_filename,
+                )
 
 
 class TestDeFactoAPI: