Repository: pre-commit/pre-commit (Python)

Issue:
tags_only autoupdate picks first (alphabetical?) tag when multiple on SHA
### describe your issue

I got an autoupdate PR from pre-commit.ci at https://github.com/mblayman/atlas/pull/29/files

The autoupdate is trying to update the luacheck hook from `v0.26.0` to `latest`. This is not ideal because latest is a moving target while v0.26.0 is fixed.

As of this writing, luacheck has three tags on the latest tagged release: 
<img width="1253" alt="image" src="https://user-images.githubusercontent.com/636865/160743649-40c09abf-0644-411d-ad1b-af94bb1c46ee.png">

I inspected the autoupdate command and found the source of this issue at https://github.com/pre-commit/pre-commit/blob/db44ad3022089368301d7af9991285b815131d9a/pre_commit/commands/autoupdate.py#L40-L43

autoupdate basically runs `git describe FETCH_HEAD --tags --abbrev=0`. In the case of the luacheck repo, this returns `latest`.

```zsh
➜  luacheck git:(master) git describe FETCH_HEAD --tags --abbrev=0
latest
```

I was able to replicate the behavior of this command in the pre-commit repo itself by adding a `latest` tag to the same SHA as `v2.17.0`.

```bash
git tag latest d3bdf1403d92f8cf2dc77bd99a5da42f0a6cef17
git describe --tags FETCH_HEAD --abbrev=0 
latest
```
While `git describe FETCH_HEAD --tags --abbrev=0` is elegant in its simplicity, it can only return a single tag, so it's limited in this multiple tags case.

## Possible solution

pre-commit could look for the latest tag, then check to see if there are multiple tags on that SHA. If so, a tag could be preferred if it looks more "version-y." Maybe that's a semver style regex or something like that.

One of the tricks, I think, is to figure out how to check for multiple tags. I think the simplest way would be to get the output from `tag_cmd` and run that through `git tag --points-at <tag_cmd output>`. If there are multiple lines, then the version checking kind of stuff could occur.

Before trying this out, I wanted to check if this seems like a reasonable path or if there are other preferential ways to tackle this kind of thing.

### pre-commit --version

pre-commit 2.17.0

### .pre-commit-config.yaml

```yaml
ci:
  # The compilation time is too long so CI hits a timeout.
  skip: [luaformatter]

repos:
- repo: https://github.com/lunarmodules/luacheck
  rev: "v0.26.0"
  hooks:
  - id: luacheck
- repo: https://github.com/mblayman/LuaFormatter
  rev: "ad94bea01ca027ca46541af727e15d002aa5ce74"
  hooks:
  - id: luaformatter
```


### ~/.cache/pre-commit/pre-commit.log (if present)

_No response_


Discussion:
this is currently what pre-commit uses to consider a "version-y" string -- so that could probably be chosen preferentially (unless no such tag matches for the latest) -- https://github.com/pre-commit/pre-commit/blob/db44ad3022089368301d7af9991285b815131d9a/pre_commit/clientlib.py#L125



Patch:
diff --git a/pre_commit/commands/autoupdate.py b/pre_commit/commands/autoupdate.py
index 938c22461..d5352e5e7 100644
--- a/pre_commit/commands/autoupdate.py
+++ b/pre_commit/commands/autoupdate.py
@@ -59,6 +59,9 @@ def update(self, tags_only: bool, freeze: bool) -> RevInfo:
             except CalledProcessError:
                 cmd = (*git_cmd, 'rev-parse', 'FETCH_HEAD')
                 rev = cmd_output(*cmd, cwd=tmp)[1].strip()
+            else:
+                if tags_only:
+                    rev = git.get_best_candidate_tag(rev, tmp)
 
             frozen = None
             if freeze:
diff --git a/pre_commit/git.py b/pre_commit/git.py
index 853f4b0d0..6fff8d2a9 100644
--- a/pre_commit/git.py
+++ b/pre_commit/git.py
@@ -229,3 +229,18 @@ def check_for_cygwin_mismatch() -> None:
                 f' - python {exe_type[is_cygwin_python]}\n'
                 f' - git {exe_type[is_cygwin_git]}\n',
             )
+
+
+def get_best_candidate_tag(rev: str, git_repo: str) -> str:
+    """Get the best tag candidate.
+
+    Multiple tags can exist on a SHA. Sometimes a moving tag is attached
+    to a version tag. Try to pick the tag that looks like a version.
+    """
+    tags = cmd_output(
+        'git', *NO_FS_MONITOR, 'tag', '--points-at', rev, cwd=git_repo,
+    )[1].splitlines()
+    for tag in tags:
+        if '.' in tag:
+            return tag
+    return rev


Test patch:
diff --git a/tests/commands/autoupdate_test.py b/tests/commands/autoupdate_test.py
index 3a142661a..3806b0e48 100644
--- a/tests/commands/autoupdate_test.py
+++ b/tests/commands/autoupdate_test.py
@@ -103,6 +103,24 @@ def test_rev_info_update_tags_only_does_not_pick_tip(tagged):
     assert new_info.rev == 'v1.2.3'
 
 
+def test_rev_info_update_tags_prefers_version_tag(tagged, out_of_date):
+    cmd_output('git', 'tag', 'latest', cwd=out_of_date.path)
+    config = make_config_from_repo(tagged.path, rev=tagged.original_rev)
+    info = RevInfo.from_config(config)
+    new_info = info.update(tags_only=True, freeze=False)
+    assert new_info.rev == 'v1.2.3'
+
+
+def test_rev_info_update_tags_non_version_tag(out_of_date):
+    cmd_output('git', 'tag', 'latest', cwd=out_of_date.path)
+    config = make_config_from_repo(
+        out_of_date.path, rev=out_of_date.original_rev,
+    )
+    info = RevInfo.from_config(config)
+    new_info = info.update(tags_only=True, freeze=False)
+    assert new_info.rev == 'latest'
+
+
 def test_rev_info_update_freeze_tag(tagged):
     git_commit(cwd=tagged.path)
     config = make_config_from_repo(tagged.path, rev=tagged.original_rev)