diff --git a/docs/html/reference/pip_install.rst b/docs/html/reference/pip_install.rst
index 1418e0f58f8..3dd820f55d8 100644
--- a/docs/html/reference/pip_install.rst
+++ b/docs/html/reference/pip_install.rst
@@ -573,6 +573,9 @@ and use any packages found there. This is disabled via the same
of that is not part of the pip API. As of 7.0, pip makes a subdirectory for
each sdist that wheels are built from and places the resulting wheels inside.
+As of version 20.0, pip also caches wheels it built from Git requirements
+when such requirements reference an immutable commit hash.
+
Pip attempts to choose the best wheels from those built in preference to
building a new wheel. Note that this means when a package has both optional
C extensions and builds ``py`` tagged wheels when the C extension can't be built
diff --git a/news/6640.feature b/news/6640.feature
new file mode 100644
index 00000000000..cb7e939dabb
--- /dev/null
+++ b/news/6640.feature
@@ -0,0 +1,2 @@
+Cache wheels built from Git requirements that are considered immutable,
+because they point to a commit hash.
diff --git a/src/pip/_internal/vcs/git.py b/src/pip/_internal/vcs/git.py
index 92b84571406..28a4fbae545 100644
--- a/src/pip/_internal/vcs/git.py
+++ b/src/pip/_internal/vcs/git.py
@@ -12,7 +12,7 @@
from pip._vendor.six.moves.urllib import request as urllib_request
from pip._internal.exceptions import BadCommand
-from pip._internal.utils.misc import display_path
+from pip._internal.utils.misc import display_path, hide_url
from pip._internal.utils.subprocess import make_command
from pip._internal.utils.temp_dir import TempDirectory
from pip._internal.utils.typing import MYPY_CHECK_RUNNING
@@ -59,6 +59,13 @@ class Git(VersionControl):
def get_base_rev_args(rev):
return [rev]
+ def is_immutable_rev_checkout(self, url, dest):
+ # type: (str, str) -> bool
+ _, rev_options = self.get_url_rev_options(hide_url(url))
+ if not rev_options.rev:
+ return False
+ return self.is_commit_id_equal(dest, rev_options.rev)
+
def get_git_version(self):
VERSION_PFX = 'git version '
version = self.run_command(['version'], show_stdout=False)
diff --git a/src/pip/_internal/vcs/versioncontrol.py b/src/pip/_internal/vcs/versioncontrol.py
index 0eccf436a76..a1742cfc814 100644
--- a/src/pip/_internal/vcs/versioncontrol.py
+++ b/src/pip/_internal/vcs/versioncontrol.py
@@ -329,6 +329,20 @@ def get_base_rev_args(rev):
"""
raise NotImplementedError
+ def is_immutable_rev_checkout(self, url, dest):
+ # type: (str, str) -> bool
+ """
+ Return true if the commit hash checked out at dest matches
+ the revision in url.
+
+ Always return False, if the VCS does not support immutable commit
+ hashes.
+
+ This method does not check if there are local uncommitted changes
+ in dest after checkout, as pip currently has no use case for that.
+ """
+ return False
+
@classmethod
def make_rev_options(cls, rev=None, extra_args=None):
# type: (Optional[str], Optional[CommandArgs]) -> RevOptions
diff --git a/src/pip/_internal/wheel.py b/src/pip/_internal/wheel.py
index 71d9765ce42..be5b7b85f29 100644
--- a/src/pip/_internal/wheel.py
+++ b/src/pip/_internal/wheel.py
@@ -52,6 +52,7 @@
from pip._internal.utils.ui import open_spinner
from pip._internal.utils.unpacking import unpack_file
from pip._internal.utils.urls import path_to_url
+from pip._internal.vcs import vcs
if MYPY_CHECK_RUNNING:
from typing import (
@@ -838,7 +839,15 @@ def should_cache(
return False
if req.link and req.link.is_vcs:
- # VCS checkout. Build wheel just for this run.
+ # VCS checkout. Build wheel just for this run
+ # unless it points to an immutable commit hash in which
+ # case it can be cached.
+ assert not req.editable
+ assert req.source_dir
+ vcs_backend = vcs.get_backend_for_scheme(req.link.scheme)
+ assert vcs_backend
+ if vcs_backend.is_immutable_rev_checkout(req.link.url, req.source_dir):
+ return True
return False
link = req.link
diff --git a/tests/functional/test_vcs_git.py b/tests/functional/test_vcs_git.py
index 606cbe1ef7c..c3b23afa022 100644
--- a/tests/functional/test_vcs_git.py
+++ b/tests/functional/test_vcs_git.py
@@ -221,3 +221,20 @@ def test_is_commit_id_equal(script):
assert not Git.is_commit_id_equal(version_pkg_path, 'abc123')
# Also check passing a None value.
assert not Git.is_commit_id_equal(version_pkg_path, None)
+
+
+def test_is_immutable_rev_checkout(script):
+ version_pkg_path = _create_test_package(script)
+ commit = script.run(
+ 'git', 'rev-parse', 'HEAD',
+ cwd=version_pkg_path
+ ).stdout.strip()
+ assert Git().is_immutable_rev_checkout(
+ "git+https://g.c/o/r@" + commit, version_pkg_path
+ )
+ assert not Git().is_immutable_rev_checkout(
+ "git+https://g.c/o/r", version_pkg_path
+ )
+ assert not Git().is_immutable_rev_checkout(
+ "git+https://g.c/o/r@master", version_pkg_path
+ )