Coverage for src/rhiza_hooks/check_go_version.py: 100%
80 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 06:14 +0000
1#!/usr/bin/env python3
2"""Check that the Go version is consistent across project files.
4A Go project states its version in up to three places:
6* ``go.mod`` — the ``go`` directive, the minimum language version the module
7 requires;
8* ``go.mod`` — the optional ``toolchain`` directive, the toolchain the ``go``
9 command switches to for this module;
10* ``.go-version`` — the toolchain pin honoured by goenv and ``actions/setup-go``.
12The hook enforces the three relationships between them: the ``toolchain``
13directive may not be below the ``go`` directive (the go command itself rejects
14that), ``.go-version`` may not be below the ``go`` directive (the pinned
15toolchain could not build the module), and ``.go-version`` must name the same
16version as the ``toolchain`` directive when both are present.
18Values that are not dotted-numeric (``toolchain default``, ``toolchain local``)
19carry no version number, so they are accepted without comparison. A leading
20``go`` prefix (``go1.22.5``) is stripped before parsing.
21"""
23from __future__ import annotations
25import argparse
26import re
27import sys
28from pathlib import Path
30from rhiza_hooks._repo import find_repo_root
31from rhiza_hooks._version import parse_version, same_version, version_at_least
33GO_MOD_FILE = "go.mod"
34GO_VERSION_FILE = ".go-version"
36# Top-level ``go``/``toolchain`` directives. Anchored and requiring whitespace
37# after the keyword, so a module path such as ``go.uber.org/zap v1.27.0`` inside
38# a require block never matches.
39_DIRECTIVE = re.compile(r"^(go|toolchain)\s+(\S+)")
42def _normalize(value: str) -> str:
43 """Strip surrounding whitespace and the ``go`` version prefix (``go1.22`` -> ``1.22``)."""
44 return value.strip().removeprefix("go")
47def parse_go_mod(text: str) -> dict[str, str]:
48 r"""Extract the ``go`` and ``toolchain`` directives from ``go.mod`` text.
50 Parenthesised blocks (``require (`` … ``)``) are skipped so their contents
51 can never be mistaken for a top-level directive. A directive repeated at top
52 level — which the go command rejects anyway — keeps its last occurrence.
54 Args:
55 text: Full contents of a ``go.mod`` file.
57 Returns:
58 Mapping of directive name to its normalized value, containing only the
59 directives actually present.
61 The ``go`` prefix is stripped, so both directives compare as plain versions:
63 >>> parse_go_mod("module example.com/m\n\ngo 1.22\ntoolchain go1.22.5\n")
64 {'go': '1.22', 'toolchain': '1.22.5'}
66 A directive-shaped line inside a parenthesised block belongs to that block,
67 never to the file:
69 >>> parse_go_mod("module m\n\nrequire (\n\tgo 9.99\n)\n\ngo 1.21\n")
70 {'go': '1.21'}
72 Absent directives are absent from the result rather than defaulted:
74 >>> parse_go_mod("module m\n")
75 {}
76 """
77 directives: dict[str, str] = {}
78 in_block = False
80 for raw_line in text.splitlines():
81 line = raw_line.split("//", 1)[0].strip()
83 if in_block:
84 in_block = line != ")"
85 continue
86 if line.endswith("("):
87 in_block = True
88 continue
90 match = _DIRECTIVE.match(line)
91 if match is not None:
92 directives[match.group(1)] = _normalize(match.group(2))
94 return directives
97def get_go_mod_directives(repo_root: Path) -> dict[str, str]:
98 """Read the ``go`` and ``toolchain`` directives from the repository's ``go.mod``.
100 Args:
101 repo_root: Root directory of the repository.
103 Returns:
104 Mapping of directive name to value; empty when ``go.mod`` is missing or
105 unreadable.
106 """
107 path = repo_root / GO_MOD_FILE
108 if not path.exists():
109 return {}
110 try:
111 text = path.read_text(encoding="utf-8")
112 except (OSError, UnicodeDecodeError):
113 # A directory at the path, permission denied, a race between exists() and
114 # read, or bytes that are not UTF-8: treat as "unspecified" rather than
115 # crashing the hook.
116 return {}
117 return parse_go_mod(text)
120def get_go_version_file(repo_root: Path) -> str | None:
121 """Read the toolchain pin from ``.go-version``.
123 Args:
124 repo_root: Root directory of the repository.
126 Returns:
127 The normalized version string, or None if the file is missing,
128 unreadable, or empty.
129 """
130 path = repo_root / GO_VERSION_FILE
131 if not path.exists():
132 return None
133 try:
134 text = path.read_text(encoding="utf-8")
135 except (OSError, UnicodeDecodeError):
136 return None
137 return _normalize(text) or None
140def _is_below(source_value: str | None, minimum_value: str | None) -> bool:
141 """Whether *source_value* names a version strictly below *minimum_value*.
143 False whenever there is nothing to compare: either side absent (the project
144 does not declare it) or not dotted-numeric (``toolchain default``).
146 Args:
147 source_value: Raw version text being checked, or None if undeclared.
148 minimum_value: Raw version text of the lower bound, or None if undeclared.
150 Returns:
151 True only when both sides carry a version number and *source_value* is
152 the lower of the two.
153 """
154 if source_value is None or minimum_value is None:
155 return False
156 source = parse_version(source_value)
157 minimum = parse_version(minimum_value)
158 if source is None or minimum is None:
159 return False
160 return not version_at_least(source, minimum)
163def _check_at_least(
164 source_label: str,
165 source_value: str | None,
166 minimum_label: str,
167 minimum_value: str | None,
168) -> list[str]:
169 """Report an error when *source_value* names a version below *minimum_value*.
171 Accepts None on either side so callers need no presence guard of their own —
172 an undeclared version simply yields no error.
173 """
174 if not _is_below(source_value, minimum_value):
175 return []
176 return [f"Go version mismatch: {source_label} is {source_value}, which is below {minimum_label} {minimum_value}"]
179def _check_pin_matches_toolchain(pinned: str | None, toolchain: str | None) -> list[str]:
180 """Report a disagreement between ``.go-version`` and the ``toolchain`` directive."""
181 if pinned is None or toolchain is None or same_version(pinned, toolchain):
182 return []
183 return [f"Go version mismatch: .go-version pins {pinned}, but the go.mod toolchain directive pins {toolchain}"]
186def check_version_consistency(repo_root: Path) -> list[str]:
187 """Check Go version consistency across project files.
189 Args:
190 repo_root: Root directory of the repository.
192 Returns:
193 List of error messages (empty if consistent, or if the repository
194 declares no Go versions at all).
195 """
196 directives = get_go_mod_directives(repo_root)
197 go_directive = directives.get("go")
198 toolchain = directives.get("toolchain")
199 pinned = get_go_version_file(repo_root)
201 # Each helper tolerates an undeclared (None) side, so the three relationships
202 # read as a flat list rather than a nest of presence guards.
203 return [
204 *_check_at_least("go.mod toolchain", toolchain, "the go.mod go directive", go_directive),
205 *_check_at_least(".go-version", pinned, "the go.mod go directive", go_directive),
206 *_check_pin_matches_toolchain(pinned, toolchain),
207 ]
210def main(argv: list[str] | None = None) -> int:
211 """Main entry point for the hook."""
212 parser = argparse.ArgumentParser(description="Check Go version consistency")
213 parser.add_argument(
214 "filenames",
215 nargs="*",
216 help="Filenames (ignored, checks repo root)",
217 )
218 parser.parse_args(argv) # validate/consume pre-commit's filename args; result unused
220 repo_root = find_repo_root()
221 errors = check_version_consistency(repo_root)
223 if errors:
224 for error in errors:
225 print(f"ERROR: {error}", file=sys.stderr)
226 return 1
228 return 0
231if __name__ == "__main__": # pragma: no mutate
232 sys.exit(main())