Repository: jazzband/pip-tools

Issue:
via lines often result in git conflict
#### What's the problem this feature will solve?
currently when adding and removing packages in multiple github PRs the `# via` line will conflict

#### Describe the solution you'd like
put each `# via` on a new line:
```
six==1.12.0 \
    --hash=sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c \
    --hash=sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73 \
    # via automat, bcrypt, cryptography, ecdsa, format-cef, grpcio, patchy, pathlib, prompt-toolkit, protobuf, pyhamcrest
```
instead use:
```
six==1.12.0 \
    --hash=sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c \
    --hash=sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73 \
    # via automat
    #   , bcrypt
    #   , cryptography
    #   , ecdsa
    #   , format-cef
    #   , grpcio
    #   , patchy
    #   , pathlib
    #   , prompt-toolkit
    #   , protobuf
    #   , pyhamcrest
```
<!-- Provide examples of real-world use cases that this would enable and how it solves the problem described above. -->

#### Alternative Solutions
<!-- Have you tried to workaround the problem using pip-tools or other tools? Or a different approach to solving this issue? Please elaborate here. -->

#### Additional context
<!-- Add any other context, links, etc. about the feature here. -->



Discussion:
also the missing trailing slash on deps without `# via` lines also conflict - this would be fixed with https://github.com/jazzband/pip-tools/issues/881

Resolving patch:
diff --git a/piptools/writer.py b/piptools/writer.py
--- a/piptools/writer.py
+++ b/piptools/writer.py
@@ -230,10 +230,14 @@ def _format_requirement(self, ireq, marker=None, hashes=None):
         elif ireq.comes_from:
             required_by.add(_comes_from_as_string(ireq))
         if required_by:
-            annotation = ", ".join(sorted(required_by))
-            line = "{:24}{}{}".format(
-                line,
-                " \\\n    " if ireq_hashes else "  ",
-                comment("# via " + annotation),
-            )
+            required_by = sorted(required_by)
+            if len(required_by) == 1:
+                source = required_by[0]
+                annotation = "    # via " + source
+            else:
+                annotation_lines = ["    # via"]
+                for source in required_by:
+                    annotation_lines.append("    #   " + source)
+                annotation = "\n".join(annotation_lines)
+            line = "{}\n{}".format(line, comment(annotation))
         return line


Test patch:
diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py
--- a/tests/test_cli_compile.py
+++ b/tests/test_cli_compile.py
@@ -53,12 +53,19 @@ def test_command_line_setuptools_read(pip_conf, runner):
                 """
             )
         )
-    out = runner.invoke(cli)
-
-    assert "This file is autogenerated by pip-compile" in out.stderr
-    assert (
-        "small-fake-a==0.1         # via fake-setuptools-a (setup.py)"
-        in out.stderr.splitlines()
+    out = runner.invoke(cli, ["--no-emit-find-links"])
+
+    assert out.stderr == dedent(
+        """\
+        #
+        # This file is autogenerated by pip-compile
+        # To update, run:
+        #
+        #    pip-compile --no-emit-find-links
+        #
+        small-fake-a==0.1
+            # via fake-setuptools-a (setup.py)
+        """
     )
 
     # check that pip-compile generated a configuration file
@@ -739,6 +746,83 @@ def test_generate_hashes_verbose(pip_conf, runner):
     assert expected_verbose_text in out.stderr
 
 
+@pytest.mark.network
+def test_generate_hashes_with_annotations(runner):
+    with open("requirements.in", "w") as fp:
+        fp.write("six==1.15.0")
+
+    out = runner.invoke(cli, ["--generate-hashes"])
+    assert out.stderr == dedent(
+        """\
+        #
+        # This file is autogenerated by pip-compile
+        # To update, run:
+        #
+        #    pip-compile --generate-hashes
+        #
+        six==1.15.0 \\
+            --hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 \\
+            --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced
+            # via -r requirements.in
+        """
+    )
+
+
+@pytest.mark.network
+def test_generate_hashes_with_long_annotations(runner):
+    with open("requirements.in", "w") as fp:
+        fp.write("django-debug-toolbar==1.11\n")
+        fp.write("django-storages==1.9.1\n")
+        fp.write("django-taggit==0.24.0\n")
+        fp.write("Django==1.11.29\n")
+        fp.write("pytz==2020.4\n")
+        fp.write("sqlparse==0.3.1\n")
+
+    out = runner.invoke(cli, ["--generate-hashes"])
+    assert out.stderr == dedent(
+        """\
+        #
+        # This file is autogenerated by pip-compile
+        # To update, run:
+        #
+        #    pip-compile --generate-hashes
+        #
+        django-debug-toolbar==1.11 \\
+            --hash=sha256:89d75b60c65db363fb24688d977e5fbf0e73386c67acf562d278402a10fc3736 \\
+            --hash=sha256:c2b0134119a624f4ac9398b44f8e28a01c7686ac350a12a74793f3dd57a9eea0
+            # via -r requirements.in
+        django-storages==1.9.1 \\
+            --hash=sha256:3103991c2ee8cef8a2ff096709973ffe7106183d211a79f22cf855f33533d924 \\
+            --hash=sha256:a59e9923cbce7068792f75344ed7727021ee4ac20f227cf17297d0d03d141e91
+            # via -r requirements.in
+        django-taggit==0.24.0 \\
+            --hash=sha256:710b4d15ec1996550cc68a0abbc41903ca7d832540e52b1336e6858737e410d8 \\
+            --hash=sha256:bb8f27684814cd1414b2af75b857b5e26a40912631904038a7ecacd2bfafc3ac
+            # via -r requirements.in
+        django==1.11.29 \\
+            --hash=sha256:014e3392058d94f40569206a24523ce254d55ad2f9f46c6550b0fe2e4f94cf3f \\
+            --hash=sha256:4200aefb6678019a0acf0005cd14cfce3a5e6b9b90d06145fcdd2e474ad4329c
+            # via
+            #   -r requirements.in
+            #   django-debug-toolbar
+            #   django-storages
+            #   django-taggit
+        pytz==2020.4 \\
+            --hash=sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268 \\
+            --hash=sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd
+            # via
+            #   -r requirements.in
+            #   django
+        sqlparse==0.3.1 \\
+            --hash=sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e \\
+            --hash=sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548
+            # via
+            #   -r requirements.in
+            #   django-debug-toolbar
+        """
+    )
+
+
 def test_filter_pip_markers(pip_conf, runner):
     """
     Check that pip-compile works with pip environment markers (PEP496)
@@ -831,10 +915,24 @@ def test_stdin(pip_conf, runner):
     Test compile requirements from STDIN.
     """
     out = runner.invoke(
-        cli, ["-", "--output-file", "requirements.txt", "-n"], input="small-fake-a==0.1"
+        cli,
+        ["-", "--output-file", "requirements.txt", "-n", "--no-emit-find-links"],
+        input="small-fake-a==0.1",
     )
 
-    assert "small-fake-a==0.1         # via -r -" in out.stderr.splitlines()
+    assert out.stderr == dedent(
+        """\
+        #
+        # This file is autogenerated by pip-compile
+        # To update, run:
+        #
+        #    pip-compile --no-emit-find-links --output-file=requirements.txt -
+        #
+        small-fake-a==0.1
+            # via -r -
+        Dry-run, so nothing updated.
+        """
+    )
 
 
 def test_multiple_input_files_without_output_file(runner):
@@ -858,12 +956,40 @@ def test_multiple_input_files_without_output_file(runner):
 @pytest.mark.parametrize(
     ("option", "expected"),
     (
-        (
+        pytest.param(
             "--annotate",
-            "small-fake-a==0.1         "
-            "# via -c constraints.txt, small-fake-with-deps\n",
+            """\
+            #
+            # This file is autogenerated by pip-compile
+            # To update, run:
+            #
+            #    pip-compile --no-emit-find-links
+            #
+            small-fake-a==0.1
+                # via
+                #   -c constraints.txt
+                #   small-fake-with-deps
+            small-fake-with-deps==0.1
+                # via -r requirements.in
+            Dry-run, so nothing updated.
+            """,
+            id="annotate",
+        ),
+        pytest.param(
+            "--no-annotate",
+            """\
+            #
+            # This file is autogenerated by pip-compile
+            # To update, run:
+            #
+            #    pip-compile --no-annotate --no-emit-find-links
+            #
+            small-fake-a==0.1
+            small-fake-with-deps==0.1
+            Dry-run, so nothing updated.
+            """,
+            id="no annotate",
         ),
-        ("--no-annotate", "small-fake-a==0.1\n"),
     ),
 )
 def test_annotate_option(pip_conf, runner, option, expected):
@@ -876,9 +1002,9 @@ def test_annotate_option(pip_conf, runner, option, expected):
         req_in.write("-c constraints.txt\n")
         req_in.write("small_fake_with_deps")
 
-    out = runner.invoke(cli, [option, "-n"])
+    out = runner.invoke(cli, [option, "-n", "--no-emit-find-links"])
 
-    assert expected in out.stderr
+    assert out.stderr == dedent(expected)
     assert out.exit_code == 0
 
 
@@ -1087,11 +1213,21 @@ def test_upgrade_package_doesnt_remove_annotation(pip_conf, runner):
             "small-fake-a==0.1         # via small-fake-with-deps\n"
         )
 
-    runner.invoke(cli, ["-P", "small-fake-a"])
+    runner.invoke(cli, ["-P", "small-fake-a", "--no-emit-find-links"])
     with open("requirements.txt", "r") as req_txt:
-        assert (
-            "small-fake-a==0.1         # via small-fake-with-deps"
-            in req_txt.read().splitlines()
+        assert req_txt.read() == dedent(
+            """\
+            #
+            # This file is autogenerated by pip-compile
+            # To update, run:
+            #
+            #    pip-compile --no-emit-find-links
+            #
+            small-fake-a==0.1
+                # via small-fake-with-deps
+            small-fake-with-deps==0.1
+                # via -r requirements.in
+            """
         )
 
 
diff --git a/tests/test_writer.py b/tests/test_writer.py
--- a/tests/test_writer.py
+++ b/tests/test_writer.py
@@ -53,25 +53,21 @@ def test_format_requirement_annotation_editable(from_editable, writer):
 
     assert writer._format_requirement(
         ireq
-    ) == "-e git+git://fake.org/x/y.git#egg=y  " + comment("# via xyz")
+    ) == "-e git+git://fake.org/x/y.git#egg=y\n" + comment("    # via xyz")
 
 
 def test_format_requirement_annotation(from_line, writer):
     ireq = from_line("test==1.2")
     ireq.comes_from = "xyz"
 
-    assert writer._format_requirement(ireq) == "test==1.2                 " + comment(
-        "# via xyz"
-    )
+    assert writer._format_requirement(ireq) == "test==1.2\n" + comment("    # via xyz")
 
 
 def test_format_requirement_annotation_lower_case(from_line, writer):
     ireq = from_line("Test==1.2")
     ireq.comes_from = "xyz"
 
-    assert writer._format_requirement(ireq) == "test==1.2                 " + comment(
-        "# via xyz"
-    )
+    assert writer._format_requirement(ireq) == "test==1.2\n" + comment("    # via xyz")
 
 
 def test_format_requirement_for_primary(from_line, writer):
@@ -79,9 +75,7 @@ def test_format_requirement_for_primary(from_line, writer):
     ireq = from_line("test==1.2")
     ireq.comes_from = "xyz"
 
-    assert writer._format_requirement(ireq) == "test==1.2                 " + comment(
-        "# via xyz"
-    )
+    assert writer._format_requirement(ireq) == "test==1.2\n" + comment("    # via xyz")
 
 
 def test_format_requirement_for_primary_lower_case(from_line, writer):
@@ -89,9 +83,7 @@ def test_format_requirement_for_primary_lower_case(from_line, writer):
     ireq = from_line("Test==1.2")
     ireq.comes_from = "xyz"
 
-    assert writer._format_requirement(ireq) == "test==1.2                 " + comment(
-        "# via xyz"
-    )
+    assert writer._format_requirement(ireq) == "test==1.2\n" + comment("    # via xyz")
 
 
 def test_format_requirement_environment_marker(from_line, writer):