Repository: psf/black

Issue:
Black finds root incorrectly
**Describe the bug** 
When multiple files are passed to black, it finds a root that may be incorrect with respect to some of the files it needs to format, resulting in a stack trace under certain conditions:

```
ValueError: '/home/user/project/tests/bar.py' does not start with '/home/user/project/src'
```

**To Reproduce** 
Create a directory structure as such:
```
▾ src/
    foo.py
    pyproject.toml
▾ tests/
    bar.py
  poetry.lock
  pyproject.toml -> /home/user/project/src/pyproject.toml
```
run 
```
black src/foo.py tests/bar.py
```

The soft-linked pyproject.toml is not necessary to reproduce the bug, but it will trigger a stack trace most clearly. Otherwise the root will be incorrect, but it won't cause immediate problems.

**Expected behavior**
Black should run without dumping a stack trace. Running
```
black .
```
succeeds without any problems, so the above should probably perform similarly.

**Environment (please complete the following information):**

- Version: master
- OS and Python version: Fedora 31/Python 3.7.4

**Does this bug also happen on master?**
yes

**Additional context**
As far as I can tell the bug is [here](https://github.com/psf/black/blob/88d12f88a97e5e4c8fd0d245df0a311e932fd1e1/src/black/__init__.py#L5834). I believe the ordering of python `Path` objects is purely alphabetical, which makes this work incorrectly -- it just gets the alphabetically first file you give it.

A line like this would work better, IMO:
```
common_base = max(set.intersection(*(set(path.parents) for path in paths)), key=lambda x: x.parts)
```
I can make a PR to fix this.

For context, the above trace is getting triggered when running black as a pre-commit hook on a repo where both the code and some tests have been edited in the same commit. I have a linked `pyproject.toml` in my package so that I can easily access package metadata from within the software from single source of truth. This bug is preventing me from making a commit without separating out the tests (breaking CI on the first commit) or disabling pre-commit (sad!)

Thank you all for all the work a great tool!



Discussion:

Resolving patch:
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -5825,8 +5825,8 @@ def gen_python_files(
 def find_project_root(srcs: Iterable[str]) -> Path:
     """Return a directory containing .git, .hg, or pyproject.toml.
 
-    That directory can be one of the directories passed in `srcs` or their
-    common parent.
+    That directory will be a common parent of all files and directories
+    passed in `srcs`.
 
     If no directory in the tree contains a marker that would specify it's the
     project root, the root of the file system is returned.
@@ -5834,11 +5834,20 @@ def find_project_root(srcs: Iterable[str]) -> Path:
     if not srcs:
         return Path("/").resolve()
 
-    common_base = min(Path(src).resolve() for src in srcs)
-    if common_base.is_dir():
-        # Append a fake file so `parents` below returns `common_base_dir`, too.
-        common_base /= "fake-file"
-    for directory in common_base.parents:
+    path_srcs = [Path(src).resolve() 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
+    src_parents = [
+        list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs
+    ]
+
+    common_base = max(
+        set.intersection(*(set(parents) for parents in src_parents)),
+        key=lambda path: path.parts,
+    )
+
+    for directory in (common_base, *common_base.parents):
         if (directory / ".git").exists():
             return directory
 


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -1801,6 +1801,28 @@ def __init__(self) -> None:
         self.assertEqual(config["exclude"], r"\.pyi?$")
         self.assertEqual(config["include"], r"\.py?$")
 
+    def test_find_project_root(self) -> None:
+        with TemporaryDirectory() as workspace:
+            root = Path(workspace)
+            test_dir = root / "test"
+            test_dir.mkdir()
+
+            src_dir = root / "src"
+            src_dir.mkdir()
+
+            root_pyproject = root / "pyproject.toml"
+            root_pyproject.touch()
+            src_pyproject = src_dir / "pyproject.toml"
+            src_pyproject.touch()
+            src_python = src_dir / "foo.py"
+            src_python.touch()
+
+            self.assertEqual(
+                black.find_project_root((src_dir, test_dir)), root.resolve()
+            )
+            self.assertEqual(black.find_project_root((src_dir,)), src_dir.resolve())
+            self.assertEqual(black.find_project_root((src_python,)), src_dir.resolve())
+
 
 class BlackDTestCase(AioHTTPTestCase):
     async def get_application(self) -> web.Application: