Git commit versions bugfix: Environments and Concretization (#29717)

Spack added support in #24639 for ad-hoc Git-commit-hash-based
versions: A user can install a package x@hash, where X is a package
that stores its source code in a Git repository, and the hash refers
to a commit in that repository which is not recorded as an explicit
version in the package.py file for X.

A couple issues were found relating to this:

* If an environment defines an alternative package repo (i.e. with
  repos.yaml), and spack.yaml contains user Specs with ad-hoc
  Git-commit-hash-based versions for packages in that repo,
  then as part of retrieving the data needed for version comparisons
  it will attempt to retrieve the package before the environment's
  configuration is instantiated.
* The bookkeeping information added to compare ad-hoc git versions was
  being stripped from Specs during concretization (such that user
  Specs which succeeded before concretizing would then fail after)

This addresses the issues:

* The first issue is resolved by deferring access to the associated
  Package until the versions are actually compared to one another.
* The second issue is resolved by ensuring that the Git bookkeeping
  information is explicitly applied to Specs after they are concretized.

This also:

* Resolves an ambiguity in the mock_git_version_info fixture used to
  create a tree of Git commits and provide a list where each index
  maps to a known commit.
* Isolates the cache used for Git repositories in tests using the
  mock_git_version_info fixture
* Adds a TODO which points out that if the remote Git repository
  overwrites tags, that Spack will then fail when using
  ad-hoc Git-commit-hash-based versions
This commit is contained in:
Peter Scheibel 2022-04-12 09:58:26 -07:00 committed by GitHub
parent 17e2fb0ef6
commit 2aec5b65f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 129 additions and 32 deletions

View file

@ -1595,7 +1595,7 @@ def for_package_version(pkg, version):
# if it's a commit, we must use a GitFetchStrategy # if it's a commit, we must use a GitFetchStrategy
if version.is_commit and hasattr(pkg, "git"): if version.is_commit and hasattr(pkg, "git"):
# Populate the version with comparisons to other commits # Populate the version with comparisons to other commits
version.generate_commit_lookup(pkg) version.generate_commit_lookup(pkg.name)
fetcher = GitFetchStrategy(git=pkg.git, commit=str(version)) fetcher = GitFetchStrategy(git=pkg.git, commit=str(version))
return fetcher return fetcher

View file

@ -2050,6 +2050,14 @@ def build_specs(self, function_tuples):
for s in self._specs.values(): for s in self._specs.values():
spack.spec.Spec.ensure_no_deprecated(s) spack.spec.Spec.ensure_no_deprecated(s)
# Add git version lookup info to concrete Specs (this is generated for
# abstract specs as well but the Versions may be replaced during the
# concretization process)
for root in self._specs.values():
for spec in root.traverse():
if spec.version.is_commit:
spec.version.generate_commit_lookup(spec.fullname)
return self._specs return self._specs

View file

@ -5047,9 +5047,7 @@ def do_parse(self):
spec.name and spec.versions.concrete and spec.name and spec.versions.concrete and
isinstance(spec.version, vn.Version) and spec.version.is_commit isinstance(spec.version, vn.Version) and spec.version.is_commit
): ):
pkg = spec.package spec.version.generate_commit_lookup(spec.fullname)
if hasattr(pkg, 'git'):
spec.version.generate_commit_lookup(pkg)
return specs return specs

View file

@ -263,6 +263,7 @@ def test_install_commit(
'git', 'file://%s' % repo_path, 'git', 'file://%s' % repo_path,
raising=False) raising=False)
# Use the earliest commit in the respository
commit = commits[-1] commit = commits[-1]
spec = spack.spec.Spec('git-test-commit@%s' % commit) spec = spack.spec.Spec('git-test-commit@%s' % commit)
spec.concretize() spec.concretize()

View file

@ -75,7 +75,17 @@ def write_file(filename, contents):
@pytest.fixture @pytest.fixture
def mock_git_version_info(tmpdir, scope="function"): def override_git_repos_cache_path(tmpdir):
saved = spack.paths.user_repos_cache_path
tmp_path = tmpdir.mkdir('git-repo-cache-path-for-tests')
spack.paths.user_repos_cache_path = str(tmp_path)
yield
spack.paths.user_repos_cache_path = saved
@pytest.fixture
def mock_git_version_info(tmpdir, override_git_repos_cache_path,
scope="function"):
"""Create a mock git repo with known structure """Create a mock git repo with known structure
The structure of commits in this repo is as follows:: The structure of commits in this repo is as follows::
@ -116,10 +126,16 @@ def commit(message):
git('config', 'user.name', 'Spack') git('config', 'user.name', 'Spack')
git('config', 'user.email', 'spack@spack.io') git('config', 'user.email', 'spack@spack.io')
commits = []
def latest_commit():
return git('rev-list', '-n1', 'HEAD', output=str, error=str).strip()
# Add two commits on main branch # Add two commits on main branch
write_file(filename, '[]') write_file(filename, '[]')
git('add', filename) git('add', filename)
commit('first commit') commit('first commit')
commits.append(latest_commit())
# Get name of default branch (differs by git version) # Get name of default branch (differs by git version)
main = git('rev-parse', '--abbrev-ref', 'HEAD', output=str, error=str).strip() main = git('rev-parse', '--abbrev-ref', 'HEAD', output=str, error=str).strip()
@ -127,37 +143,42 @@ def commit(message):
# Tag second commit as v1.0 # Tag second commit as v1.0
write_file(filename, "[1, 0]") write_file(filename, "[1, 0]")
commit('second commit') commit('second commit')
commits.append(latest_commit())
git('tag', 'v1.0') git('tag', 'v1.0')
# Add two commits and a tag on 1.x branch # Add two commits and a tag on 1.x branch
git('checkout', '-b', '1.x') git('checkout', '-b', '1.x')
write_file(filename, "[1, 0, '', 1]") write_file(filename, "[1, 0, '', 1]")
commit('first 1.x commit') commit('first 1.x commit')
commits.append(latest_commit())
write_file(filename, "[1, 1]") write_file(filename, "[1, 1]")
commit('second 1.x commit') commit('second 1.x commit')
commits.append(latest_commit())
git('tag', 'v1.1') git('tag', 'v1.1')
# Add two commits and a tag on main branch # Add two commits and a tag on main branch
git('checkout', main) git('checkout', main)
write_file(filename, "[1, 0, '', 1]") write_file(filename, "[1, 0, '', 1]")
commit('third main commit') commit('third main commit')
commits.append(latest_commit())
write_file(filename, "[2, 0]") write_file(filename, "[2, 0]")
commit('fourth main commit') commit('fourth main commit')
commits.append(latest_commit())
git('tag', 'v2.0') git('tag', 'v2.0')
# Add two more commits on 1.x branch to ensure we aren't cheating by using time # Add two more commits on 1.x branch to ensure we aren't cheating by using time
git('checkout', '1.x') git('checkout', '1.x')
write_file(filename, "[1, 1, '', 1]") write_file(filename, "[1, 1, '', 1]")
commit('third 1.x commit') commit('third 1.x commit')
commits.append(latest_commit())
write_file(filename, "[1, 2]") write_file(filename, "[1, 2]")
commit('fourth 1.x commit') commit('fourth 1.x commit')
commits.append(latest_commit())
git('tag', '1.2') # test robust parsing to different syntax, no v git('tag', '1.2') # test robust parsing to different syntax, no v
# Get the commits in topo order # The commits are ordered with the last commit first in the list
log = git('log', '--all', '--pretty=format:%H', '--topo-order', commits = list(reversed(commits))
output=str, error=str)
commits = [c for c in log.split('\n') if c]
# Return the git directory to install, the filename used, and the commits # Return the git directory to install, the filename used, and the commits
yield repo_path, filename, commits yield repo_path, filename, commits

View file

@ -607,6 +607,36 @@ def test_versions_from_git(mock_git_version_info, monkeypatch, mock_packages):
assert str(comparator) == expected assert str(comparator) == expected
@pytest.mark.skipif(sys.platform == 'win32',
reason="Not supported on Windows (yet)")
def test_git_hash_comparisons(
mock_git_version_info, install_mockery, mock_packages, monkeypatch):
"""Check that hashes compare properly to versions
"""
repo_path, filename, commits = mock_git_version_info
monkeypatch.setattr(spack.package.PackageBase,
'git', 'file://%s' % repo_path,
raising=False)
# Spec based on earliest commit
spec0 = spack.spec.Spec('git-test-commit@%s' % commits[-1])
spec0.concretize()
assert spec0.satisfies('@:0')
assert not spec0.satisfies('@1.0')
# Spec based on second commit (same as version 1.0)
spec1 = spack.spec.Spec('git-test-commit@%s' % commits[-2])
spec1.concretize()
assert spec1.satisfies('@1.0')
assert not spec1.satisfies('@1.1:')
# Spec based on 4th commit (in timestamp order)
spec4 = spack.spec.Spec('git-test-commit@%s' % commits[-4])
spec4.concretize()
assert spec4.satisfies('@1.1')
assert spec4.satisfies('@1.0:1.2')
def test_version_range_nonempty(): def test_version_range_nonempty():
assert Version('1.2.9') in VersionRange('1.2.0', '1.2') assert Version('1.2.9') in VersionRange('1.2.0', '1.2')
assert Version('1.1.1') in ver('1.0:1') assert Version('1.1.1') in ver('1.0:1')

View file

@ -171,7 +171,7 @@ class Version(object):
"version", "version",
"separators", "separators",
"string", "string",
"commit_lookup", "_commit_lookup",
"is_commit", "is_commit",
"commit_version", "commit_version",
] ]
@ -188,7 +188,7 @@ def __init__(self, string):
raise ValueError("Bad characters in version string: %s" % string) raise ValueError("Bad characters in version string: %s" % string)
# An object that can lookup git commits to compare them to versions # An object that can lookup git commits to compare them to versions
self.commit_lookup = None self._commit_lookup = None
self.commit_version = None self.commit_version = None
segments = SEGMENT_REGEX.findall(string) segments = SEGMENT_REGEX.findall(string)
self.version = tuple( self.version = tuple(
@ -467,7 +467,13 @@ def intersection(self, other):
else: else:
return VersionList() return VersionList()
def generate_commit_lookup(self, pkg): @property
def commit_lookup(self):
if self._commit_lookup:
self._commit_lookup.get(self.string)
return self._commit_lookup
def generate_commit_lookup(self, pkg_name):
""" """
Use the git fetcher to look up a version for a commit. Use the git fetcher to look up a version for a commit.
@ -483,17 +489,13 @@ def generate_commit_lookup(self, pkg):
fetcher: the fetcher to use. fetcher: the fetcher to use.
versions: the known versions of the package versions: the known versions of the package
""" """
if self.commit_lookup:
return
# Sanity check we have a commit # Sanity check we have a commit
if not self.is_commit: if not self.is_commit:
tty.die("%s is not a commit." % self) tty.die("%s is not a commit." % self)
# Generate a commit looker-upper # Generate a commit looker-upper
self.commit_lookup = CommitLookup(pkg) self._commit_lookup = CommitLookup(pkg_name)
self.commit_lookup.get(self.string)
self.commit_lookup.save()
class VersionRange(object): class VersionRange(object):
@ -991,24 +993,55 @@ class CommitLookup(object):
Version.is_commit returns True to allow for comparisons between git commits Version.is_commit returns True to allow for comparisons between git commits
and versions as represented by tags in the git repository. and versions as represented by tags in the git repository.
""" """
def __init__(self, pkg): def __init__(self, pkg_name):
self.pkg = pkg self.pkg_name = pkg_name
# We require the full git repository history
import spack.fetch_strategy # break cycle
fetcher = spack.fetch_strategy.GitFetchStrategy(git=pkg.git)
fetcher.get_full_repo = True
self.fetcher = fetcher
self.data = {} self.data = {}
# Cache data in misc_cache self._pkg = None
key_base = 'git_metadata' self._fetcher = None
if not self.repository_uri.startswith('/'): self._cache_key = None
key_base += '/' self._cache_path = None
self.cache_key = key_base + self.repository_uri
spack.caches.misc_cache.init_entry(self.cache_key) # The following properties are used as part of a lazy reference scheme
self.cache_path = spack.caches.misc_cache.cache_path(self.cache_key) # to avoid querying the package repository until it is necessary (and
# in particular to wait until after the configuration has been
# assembled)
@property
def cache_key(self):
if not self._cache_key:
key_base = 'git_metadata'
if not self.repository_uri.startswith('/'):
key_base += '/'
self._cache_key = key_base + self.repository_uri
# Cache data in misc_cache
# If this is the first lazy access, initialize the cache as well
spack.caches.misc_cache.init_entry(self.cache_key)
return self._cache_key
@property
def cache_path(self):
if not self._cache_path:
self._cache_path = spack.caches.misc_cache.cache_path(
self.cache_key)
return self._cache_path
@property
def pkg(self):
if not self._pkg:
self._pkg = spack.repo.get(self.pkg_name)
return self._pkg
@property
def fetcher(self):
if not self._fetcher:
# We require the full git repository history
import spack.fetch_strategy # break cycle
fetcher = spack.fetch_strategy.GitFetchStrategy(git=self.pkg.git)
fetcher.get_full_repo = True
self._fetcher = fetcher
return self._fetcher
@property @property
def repository_uri(self): def repository_uri(self):
@ -1073,6 +1106,10 @@ def lookup_commit(self, commit):
# Lookup commit info # Lookup commit info
with working_dir(dest): with working_dir(dest):
# TODO: we need to update the local tags if they changed on the
# remote instance, simply adding '-f' may not be sufficient
# (if commits are deleted on the remote, this command alone
# won't properly update the local rev-list)
self.fetcher.git("fetch", '--tags') self.fetcher.git("fetch", '--tags')
# Ensure commit is an object known to git # Ensure commit is an object known to git

View file

@ -17,6 +17,8 @@ class GitTestCommit(Package):
version('2.0', tag='v2.0') version('2.0', tag='v2.0')
def install(self, spec, prefix): def install(self, spec, prefix):
# It is assumed for the test which installs this package, that it will
# be using the earliest commit, which is contained in the range @:0
assert spec.satisfies('@:0') assert spec.satisfies('@:0')
mkdir(prefix.bin) mkdir(prefix.bin)