diff --git a/dunamai/__init__.py b/dunamai/__init__.py index 0cd245f..4b1c4fb 100644 --- a/dunamai/__init__.py +++ b/dunamai/__init__.py @@ -10,7 +10,19 @@ from enum import Enum from functools import total_ordering from pathlib import Path -from typing import Any, Callable, Mapping, NamedTuple, Optional, Sequence, Tuple, TypeVar, Union +from textwrap import dedent +from typing import ( + Any, + Callable, + List, + Mapping, + NamedTuple, + Optional, + Sequence, + Tuple, + TypeVar, + Union, +) _VERSION_PATTERN = r""" (?x) (?# ignore whitespace) @@ -379,9 +391,16 @@ def from_git(cls, pattern: str = _VERSION_PATTERN, latest_tag: bool = False) -> if msg.strip() != "": dirty = True + tag_topo_lookup = cls._from_git_tag_topo_order() + code, msg = _run_cmd( 'git for-each-ref "refs/tags/*" --merged HEAD' - ' --format "%(refname)@{%(creatordate:iso-strict)@{%(*committerdate:iso-strict)"' + ' --format "%(refname)' + "@{%(objectname)" + "@{%(creatordate:iso-strict)" + "@{%(*committerdate:iso-strict)" + "@{%(taggerdate:iso-strict)" + '"' ) if not msg: try: @@ -390,20 +409,66 @@ def from_git(cls, pattern: str = _VERSION_PATTERN, latest_tag: bool = False) -> except Exception: distance = 0 return cls("0.0.0", distance=distance, commit=commit, dirty=dirty) - detailed_tags = [] + + class GitRefInfo: + def __init__( + self, ref: str, commit: str, creatordate: str, committerdate: str, taggerdate: str + ): + self.fullref = ref + self.commit = commit + self.creatordate = self.normalize_git_dt(creatordate) + self.committerdate = self.normalize_git_dt(committerdate) + self.taggerdate = self.normalize_git_dt(taggerdate) + + @staticmethod + def normalize_git_dt(dtstr: str) -> Optional[dt.datetime]: + if dtstr == "": + return None + else: + return _parse_git_timestamp_iso_strict(dtstr) + + def __repr__(self): + return "{ref} of:{offset} {created} {committed} {tagged}".format( + ref=self.fullref, + offset=self.commit_offset, + created=self.creatordate, + committed=self.committerdate, + tagged=self.taggerdate, + ) + + def best_date(self): + if self.taggerdate is not None: + return self.taggerdate + elif self.committerdate is not None: + return self.committerdate + else: + return self.creatordate + + @property + def commit_offset(self): + try: + return tag_topo_lookup[self.fullref] + except KeyError: + print(tag_topo_lookup) + raise + + @property + def sort_key(self): + return -self.commit_offset, self.best_date() + + @property + def ref(self): + return self.fullref.replace("refs/tags/", "") + + detailed_tags = [] # type: List[GitRefInfo] + for line in msg.strip().splitlines(): parts = line.split("@{") - detailed_tags.append( - ( - parts[0].replace("refs/tags/", "", 1), - _parse_git_timestamp_iso_strict(parts[1]), - None if parts[2] == "" else _parse_git_timestamp_iso_strict(parts[2]), - ) - ) - tags = [ - t[0] - for t in reversed(sorted(detailed_tags, key=lambda x: x[1] if x[2] is None else x[2])) - ] + detailed_tags.append(GitRefInfo(*parts)) + + detailed_tags.sort(key=lambda t: t.sort_key) + detailed_tags.reverse() + tags = [t.ref for t in detailed_tags] tag, base, stage, unmatched, tagged_metadata = _match_version_pattern( pattern, tags, latest_tag ) @@ -423,6 +488,48 @@ def from_git(cls, pattern: str = _VERSION_PATTERN, latest_tag: bool = False) -> version._newer_unmatched_tags = unmatched return version + @classmethod + def _from_git_tag_topo_order(cls) -> Mapping[str, int]: + """""" + code, logmsg = _run_cmd( + dedent( + """ + git log + --simplify-by-decoration + --topo-order + --decorate=full + "--decorate-refs=refs/tags/*" + HEAD + "--format=%H%d" + """ + ) + ) + tag_lookup = {} + + def noralize_tag_ref(tagref: str) -> str: + """Older versions of git do not correctly respect --decorate-refs""" + if tagref.startswith("refs/tags/"): + return tagref + else: + return "refs/tags/{}".format(tagref) + + for tag_offset, line in enumerate(logmsg.strip().splitlines(keepends=False)): + # lines have the pattern + # (tag: refs/tags/v1.2.0b1, tag: refs/tags/v1.2.0) + commit, _, tags = line.partition("(") + commit = commit.strip() + if tags: + # remove trailing ')' + tags = tags[:-1] + taglist = [ + tag.strip() for tag in tags.split(",") if tag.strip().startswith("tag: ") + ] + taglist = [tag.split()[-1] for tag in taglist] + taglist = [noralize_tag_ref(tag) for tag in taglist] + for tag in taglist: + tag_lookup[tag] = tag_offset + return tag_lookup + @classmethod def from_mercurial(cls, pattern: str = _VERSION_PATTERN, latest_tag: bool = False) -> "Version": r""" diff --git a/tests/integration/test_dunamai.py b/tests/integration/test_dunamai.py index fa9daeb..c4d2746 100644 --- a/tests/integration/test_dunamai.py +++ b/tests/integration/test_dunamai.py @@ -113,6 +113,9 @@ def test__version__from_git__with_annotated_tags(tmp_path) -> None: with pytest.raises(ValueError): from_vcs(latest_tag=True) + # check that we find the expected tag that has the most recent tag creation time + avoid_identical_ref_timestamps() + run("git tag v0.2.0b1 -m Annotated") avoid_identical_ref_timestamps() run("git tag v0.2.0 -m Annotated") avoid_identical_ref_timestamps()