Improve test coverage for stg pull
[stgit.git] / stgit / _version.py
blob3fbfb8adbe229193739244a222d8f5d19eb1634c
1 # Adapted from https://github.com/jbweston/miniver
3 # LICENSE: CC0 1.0 Universal license.
4 # http://creativecommons.org/publicdomain/zero/1.0/
6 # This file is in the public domain.
8 # -------------------
9 # Theory of operation
10 # -------------------
12 # When in the context of a stgit git repository or a stgit git archive
13 # tarball, this code will dynamically determine the stgit version.
15 # At build-time, this file is overwritten such that stgit distributions
16 # only contain the __version__ attribute. Obtaining the version from a
17 # rewritten _version.py is extremely inexpensive at runtime.
19 # The _get_version() function in this module is capable of determining
20 # the stgit package version in a variety of contexts, including:
22 # * When running stgit from its Git worktree, the version is dynamically
23 # determined by _get_version() which runs `git describe`. As such, the
24 # version is based on the last tag.
26 # * When running `setup.py build` or `setup.py sdist` from a stgit
27 # worktree, the version is dynamically determined from `git describe`
28 # as above, BUT the generated build artifacts are seeded with an
29 # updated _version.py file containing only this dynamically determined
30 # version. Thus stgit distributions contain a static version such that
31 # the version can be determined at runtime without consulting Git or
32 # any other external resources.
34 # * When running `setup.py` from a stgit source distribution, i.e. an
35 # unpacked tarball previously obtained by running `setup.py sdist`,
36 # the version is determined statically from a rewritten version of
37 # this file (_version.py). A key feature here is that obtaining the
38 # version from _version.py is done by exec()ing that file and not
39 # importing it as a stgit submodule. This is important at pip
40 # install-time when the stgit package cannot (yet) be imported.
42 # * When running an installed version of stgit, the version is also
43 # statically determined from a rewritten _version.py.
45 # * When running `setup.py build` or `setup.py sdist` from a Git archive
46 # generated with, for example,
48 # git archive --prefix=stgit/ --output stgit.tar.gz HEAD
50 # the _version.py file is updated by `git archive` with ref names and
51 # the commit hash using the export-subst gitattribute mechanism.
52 # _get_version_from_git_archive() is able to construct the version
53 # from the substituted ref names and commit id information.
55 import os
56 import subprocess
57 from collections import namedtuple
59 __all__ = []
61 _Version = namedtuple("_Version", ("release", "dev", "labels"))
64 def _get_version():
65 package_root = os.path.dirname(os.path.realpath(__file__))
66 worktree_path = os.path.dirname(package_root)
68 version = _get_version_from_git(worktree_path)
69 if version is not None:
70 return _pep440_format(version)
72 version = _get_version_from_git_archive()
73 if version is not None:
74 return _pep440_format(version)
76 return _pep440_format(_Version("unknown", None, None))
79 def _pep440_format(version):
80 release, dev, labels = version
82 parts = [release]
83 if dev:
84 if release.endswith("-dev") or release.endswith(".dev"):
85 parts.append(dev)
86 else: # prefer PEP440 over strict adhesion to semver
87 parts.append(".dev{}".format(dev))
89 if labels:
90 parts.append("+")
91 parts.append(".".join(labels))
93 return "".join(parts)
96 def _parse_git_describe(description, worktree_path=None):
97 if description.startswith('v'):
98 description = description[1:]
100 parts = description.split('-', 2)
102 if len(parts) == 3:
103 release, dev, git_hash = parts
104 else:
105 # No tags, only the git hash
106 git_hash = 'g' + parts[0]
107 release = 'unknown'
108 dev = None
110 labels = []
111 if dev == "0":
112 dev = None
113 else:
114 labels.append(git_hash)
116 if worktree_path is not None:
117 try:
118 p = subprocess.run(["git", "diff", "--quiet"], cwd=worktree_path)
119 except OSError:
120 labels.append("confused") # This should never happen.
121 else:
122 if p.returncode == 1:
123 labels.append("dirty")
125 return _Version(release, dev, labels)
128 def _get_version_from_git(worktree_path):
129 try:
130 p = subprocess.run(
131 ["git", "rev-parse", "--show-toplevel"],
132 cwd=worktree_path,
133 stdout=subprocess.PIPE,
134 stderr=subprocess.DEVNULL,
136 except OSError:
137 return None
138 if p.returncode != 0:
139 return None
140 if not os.path.samefile(p.stdout.decode().rstrip("\n"), worktree_path):
141 # The top-level directory of the current Git worktree is not the same
142 # as the root directory of the distribution: do not extract the version
143 # from Git.
144 return None
146 # git describe --first-parent does not take into account tags from branches
147 # that were merged-in. The '--long' flag gets us the 'dev' version and
148 # git hash, '--always' returns the git hash even if there are no tags.
149 for opts in [["--first-parent"], []]:
150 try:
151 p = subprocess.run(
152 ["git", "describe", "--long", "--always"] + opts,
153 cwd=worktree_path,
154 stdout=subprocess.PIPE,
155 stderr=subprocess.PIPE,
157 except OSError:
158 return None
159 if p.returncode == 0:
160 break
161 else:
162 return None
164 description = p.stdout.decode().strip()
166 return _parse_git_describe(description, worktree_path)
169 def _get_version_from_git_archive():
170 # git archive replaces these export-subst placeholders.
171 refnames = "$Format:%D$"
172 git_hash = "$Format:%h$"
173 describe = "$Format:%(describe:match=v*)$"
175 if not describe.startswith('$Format'):
176 # As of Git 2.32, the %(describe) is a valid export-subst placeholder
177 return _parse_git_describe(describe, worktree_path=None)
179 # Older versions of Git can only provide either a tag if the archive is
180 # from a tagged commit, or the commit id without information about the
181 # preceeding tag for archives from a non-tagged commit.
183 if git_hash.startswith("$Format") or refnames.startswith("$Format"):
184 # variables not expanded during 'git archive'
185 return None
187 vtag = "tag: v"
188 refs = set(r.strip() for r in refnames.split(","))
189 version_tags = set(r[len(vtag) :] for r in refs if r.startswith(vtag))
190 if version_tags:
191 release = sorted(version_tags)[0] # prefer e.g. "2.0" over "2.0rc1"
192 return _Version(release, dev=None, labels=None)
193 else:
194 return _Version("unknown", dev=None, labels=["g" + git_hash])
197 __version__ = _get_version()