Skip to content

check_go_version

rhiza_hooks.check_go_version

Check that the Go version is consistent across project files.

A Go project states its version in up to three places:

  • go.mod — the go directive, the minimum language version the module requires;
  • go.mod — the optional toolchain directive, the toolchain the go command switches to for this module;
  • .go-version — the toolchain pin honoured by goenv and actions/setup-go.

The hook enforces the three relationships between them: the toolchain directive may not be below the go directive (the go command itself rejects that), .go-version may not be below the go directive (the pinned toolchain could not build the module), and .go-version must name the same version as the toolchain directive when both are present.

Values that are not dotted-numeric (toolchain default, toolchain local) carry no version number, so they are accepted without comparison. A leading go prefix (go1.22.5) is stripped before parsing.

check_version_consistency(repo_root)

Check Go version consistency across project files.

Parameters:

Name Type Description Default
repo_root Path

Root directory of the repository.

required

Returns:

Type Description
list[str]

List of error messages (empty if consistent, or if the repository

list[str]

declares no Go versions at all).

Source code in rhiza_hooks/check_go_version.py
def check_version_consistency(repo_root: Path) -> list[str]:
    """Check Go version consistency across project files.

    Args:
        repo_root: Root directory of the repository.

    Returns:
        List of error messages (empty if consistent, or if the repository
        declares no Go versions at all).
    """
    directives = get_go_mod_directives(repo_root)
    go_directive = directives.get("go")
    toolchain = directives.get("toolchain")
    pinned = get_go_version_file(repo_root)

    # Each helper tolerates an undeclared (None) side, so the three relationships
    # read as a flat list rather than a nest of presence guards.
    return [
        *_check_at_least("go.mod toolchain", toolchain, "the go.mod go directive", go_directive),
        *_check_at_least(".go-version", pinned, "the go.mod go directive", go_directive),
        *_check_pin_matches_toolchain(pinned, toolchain),
    ]

get_go_mod_directives(repo_root)

Read the go and toolchain directives from the repository's go.mod.

Parameters:

Name Type Description Default
repo_root Path

Root directory of the repository.

required

Returns:

Type Description
dict[str, str]

Mapping of directive name to value; empty when go.mod is missing or

dict[str, str]

unreadable.

Source code in rhiza_hooks/check_go_version.py
def get_go_mod_directives(repo_root: Path) -> dict[str, str]:
    """Read the ``go`` and ``toolchain`` directives from the repository's ``go.mod``.

    Args:
        repo_root: Root directory of the repository.

    Returns:
        Mapping of directive name to value; empty when ``go.mod`` is missing or
        unreadable.
    """
    path = repo_root / GO_MOD_FILE
    if not path.exists():
        return {}
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        # A directory at the path, permission denied, a race between exists() and
        # read, or bytes that are not UTF-8: treat as "unspecified" rather than
        # crashing the hook.
        return {}
    return parse_go_mod(text)

get_go_version_file(repo_root)

Read the toolchain pin from .go-version.

Parameters:

Name Type Description Default
repo_root Path

Root directory of the repository.

required

Returns:

Type Description
str | None

The normalized version string, or None if the file is missing,

str | None

unreadable, or empty.

Source code in rhiza_hooks/check_go_version.py
def get_go_version_file(repo_root: Path) -> str | None:
    """Read the toolchain pin from ``.go-version``.

    Args:
        repo_root: Root directory of the repository.

    Returns:
        The normalized version string, or None if the file is missing,
        unreadable, or empty.
    """
    path = repo_root / GO_VERSION_FILE
    if not path.exists():
        return None
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return None
    return _normalize(text) or None

main(argv=None)

Main entry point for the hook.

Source code in rhiza_hooks/check_go_version.py
def main(argv: list[str] | None = None) -> int:
    """Main entry point for the hook."""
    parser = argparse.ArgumentParser(description="Check Go version consistency")
    parser.add_argument(
        "filenames",
        nargs="*",
        help="Filenames (ignored, checks repo root)",
    )
    parser.parse_args(argv)  # validate/consume pre-commit's filename args; result unused

    repo_root = find_repo_root()
    errors = check_version_consistency(repo_root)

    if errors:
        for error in errors:
            print(f"ERROR: {error}")
        return 1

    return 0

parse_go_mod(text)

Extract the go and toolchain directives from go.mod text.

Parenthesised blocks (require ()) are skipped so their contents can never be mistaken for a top-level directive. A directive repeated at top level — which the go command rejects anyway — keeps its last occurrence.

Parameters:

Name Type Description Default
text str

Full contents of a go.mod file.

required

Returns:

Type Description
dict[str, str]

Mapping of directive name to its normalized value, containing only the

dict[str, str]

directives actually present.

Source code in rhiza_hooks/check_go_version.py
def parse_go_mod(text: str) -> dict[str, str]:
    """Extract the ``go`` and ``toolchain`` directives from ``go.mod`` text.

    Parenthesised blocks (``require (`` … ``)``) are skipped so their contents
    can never be mistaken for a top-level directive. A directive repeated at top
    level — which the go command rejects anyway — keeps its last occurrence.

    Args:
        text: Full contents of a ``go.mod`` file.

    Returns:
        Mapping of directive name to its normalized value, containing only the
        directives actually present.
    """
    directives: dict[str, str] = {}
    in_block = False

    for raw_line in text.splitlines():
        line = raw_line.split("//", 1)[0].strip()

        if in_block:
            in_block = line != ")"
            continue
        if line.endswith("("):
            in_block = True
            continue

        match = _DIRECTIVE.match(line)
        if match is not None:
            directives[match.group(1)] = _normalize(match.group(2))

    return directives