Repository: pre-commit/pre-commit

Issue:
Make Go a first class language
Hey, as many tools are now built using golang, it would be good to have it as a first class language.

I've hacked a little [POC](https://github.com/pre-commit/pre-commit/compare/main...taoufik07:pre-commit:golang_playground?expand=1) heavily inspired by the ruby lang and using https://github.com/stefanmaric/g as a go version manager.

Please let me know if that's what you had in mind before I go any further. 


Discussion:
we can't depend on `g` -- but go is simple enough to provision by downloading the appropriate prebuilt bundle from the go website
Even better, I've updated the [POC](https://github.com/pre-commit/pre-commit/compare/main...taoufik07:pre-commit:golang_playground?expand=1), please let me know if I should open a draft PR.

Downloading golang and installing packages (e.g. tfsec) takes ~50s on a relatively speedy connection and a performant computer, I'm not sure if we can do something about that. Having the download language would help, but that's another discussion.



Resolving patch:
diff --git a/pre_commit/languages/golang.py b/pre_commit/languages/golang.py
--- a/pre_commit/languages/golang.py
+++ b/pre_commit/languages/golang.py
@@ -1,9 +1,21 @@
 from __future__ import annotations
 
 import contextlib
+import functools
+import json
 import os.path
+import platform
+import shutil
 import sys
+import tarfile
+import tempfile
+import urllib.error
+import urllib.request
+import zipfile
+from typing import ContextManager
 from typing import Generator
+from typing import IO
+from typing import Protocol
 from typing import Sequence
 
 import pre_commit.constants as C
@@ -17,20 +29,100 @@
 from pre_commit.util import rmtree
 
 ENVIRONMENT_DIR = 'golangenv'
-get_default_version = helpers.basic_get_default_version
 health_check = helpers.basic_health_check
 
+_ARCH_ALIASES = {
+    'x86_64': 'amd64',
+    'i386': '386',
+    'aarch64': 'arm64',
+    'armv8': 'arm64',
+    'armv7l': 'armv6l',
+}
+_ARCH = platform.machine().lower()
+_ARCH = _ARCH_ALIASES.get(_ARCH, _ARCH)
+
+
+class ExtractAll(Protocol):
+    def extractall(self, path: str) -> None: ...
+
+
+if sys.platform == 'win32':  # pragma: win32 cover
+    _EXT = 'zip'
+
+    def _open_archive(bio: IO[bytes]) -> ContextManager[ExtractAll]:
+        return zipfile.ZipFile(bio)
+else:  # pragma: win32 no cover
+    _EXT = 'tar.gz'
+
+    def _open_archive(bio: IO[bytes]) -> ContextManager[ExtractAll]:
+        return tarfile.open(fileobj=bio)
+
+
+@functools.lru_cache(maxsize=1)
+def get_default_version() -> str:
+    if helpers.exe_exists('go'):
+        return 'system'
+    else:
+        return C.DEFAULT
+
+
+def get_env_patch(venv: str, version: str) -> PatchesT:
+    if version == 'system':
+        return (
+            ('PATH', (os.path.join(venv, 'bin'), os.pathsep, Var('PATH'))),
+        )
 
-def get_env_patch(venv: str) -> PatchesT:
     return (
-        ('PATH', (os.path.join(venv, 'bin'), os.pathsep, Var('PATH'))),
+        ('GOROOT', os.path.join(venv, '.go')),
+        (
+            'PATH', (
+                os.path.join(venv, 'bin'), os.pathsep,
+                os.path.join(venv, '.go', 'bin'), os.pathsep, Var('PATH'),
+            ),
+        ),
     )
 
 
+@functools.lru_cache
+def _infer_go_version(version: str) -> str:
+    if version != C.DEFAULT:
+        return version
+    resp = urllib.request.urlopen('https://go.dev/dl/?mode=json')
+    # TODO: 3.9+ .removeprefix('go')
+    return json.load(resp)[0]['version'][2:]
+
+
+def _get_url(version: str) -> str:
+    os_name = platform.system().lower()
+    version = _infer_go_version(version)
+    return f'https://dl.google.com/go/go{version}.{os_name}-{_ARCH}.{_EXT}'
+
+
+def _install_go(version: str, dest: str) -> None:
+    try:
+        resp = urllib.request.urlopen(_get_url(version))
+    except urllib.error.HTTPError as e:  # pragma: no cover
+        if e.code == 404:
+            raise ValueError(
+                f'Could not find a version matching your system requirements '
+                f'(os={platform.system().lower()}; arch={_ARCH})',
+            ) from e
+        else:
+            raise
+    else:
+        with tempfile.TemporaryFile() as f:
+            shutil.copyfileobj(resp, f)
+            f.seek(0)
+
+            with _open_archive(f) as archive:
+                archive.extractall(dest)
+        shutil.move(os.path.join(dest, 'go'), os.path.join(dest, '.go'))
+
+
 @contextlib.contextmanager
-def in_env(prefix: Prefix) -> Generator[None, None, None]:
-    envdir = helpers.environment_dir(prefix, ENVIRONMENT_DIR, C.DEFAULT)
-    with envcontext(get_env_patch(envdir)):
+def in_env(prefix: Prefix, version: str) -> Generator[None, None, None]:
+    envdir = helpers.environment_dir(prefix, ENVIRONMENT_DIR, version)
+    with envcontext(get_env_patch(envdir, version)):
         yield
 
 
@@ -39,15 +131,23 @@ def install_environment(
         version: str,
         additional_dependencies: Sequence[str],
 ) -> None:
-    helpers.assert_version_default('golang', version)
     env_dir = helpers.environment_dir(prefix, ENVIRONMENT_DIR, version)
 
+    if version != 'system':
+        _install_go(version, env_dir)
+
     if sys.platform == 'cygwin':  # pragma: no cover
         gopath = cmd_output('cygpath', '-w', env_dir)[1].strip()
     else:
         gopath = env_dir
+
     env = dict(os.environ, GOPATH=gopath)
     env.pop('GOBIN', None)
+    if version != 'system':
+        env['GOROOT'] = os.path.join(env_dir, '.go')
+        env['PATH'] = os.pathsep.join((
+            os.path.join(env_dir, '.go', 'bin'), os.environ['PATH'],
+        ))
 
     helpers.run_setup_cmd(prefix, ('go', 'install', './...'), env=env)
     for dependency in additional_dependencies:
@@ -64,5 +164,5 @@ def run_hook(
         file_args: Sequence[str],
         color: bool,
 ) -> tuple[int, bytes]:
-    with in_env(hook.prefix):
+    with in_env(hook.prefix, hook.language_version):
         return helpers.run_xargs(hook, hook.cmd, file_args, color=color)


Test patch:
diff --git a/testing/resources/golang_hooks_repo/golang-hello-world/main.go b/testing/resources/golang_hooks_repo/golang-hello-world/main.go
--- a/testing/resources/golang_hooks_repo/golang-hello-world/main.go
+++ b/testing/resources/golang_hooks_repo/golang-hello-world/main.go
@@ -3,7 +3,9 @@ package main
 
 import (
 	"fmt"
+	"runtime"
 	"github.com/BurntSushi/toml"
+	"os"
 )
 
 type Config struct {
@@ -11,7 +13,11 @@ type Config struct {
 }
 
 func main() {
+	message := runtime.Version()
+	if len(os.Args) > 1 {
+		message = os.Args[1]
+	}
 	var conf Config
 	toml.Decode("What = 'world'\n", &conf)
-	fmt.Printf("hello %v\n", conf.What)
+	fmt.Printf("hello %v from %s\n", conf.What, message)
 }
diff --git a/tests/languages/golang_test.py b/tests/languages/golang_test.py
new file mode 100644
--- /dev/null
+++ b/tests/languages/golang_test.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import re
+from unittest import mock
+
+import pytest
+
+import pre_commit.constants as C
+from pre_commit.languages import golang
+from pre_commit.languages import helpers
+
+
+ACTUAL_GET_DEFAULT_VERSION = golang.get_default_version.__wrapped__
+
+
+@pytest.fixture
+def exe_exists_mck():
+    with mock.patch.object(helpers, 'exe_exists') as mck:
+        yield mck
+
+
+def test_golang_default_version_system_available(exe_exists_mck):
+    exe_exists_mck.return_value = True
+    assert ACTUAL_GET_DEFAULT_VERSION() == 'system'
+
+
+def test_golang_default_version_system_not_available(exe_exists_mck):
+    exe_exists_mck.return_value = False
+    assert ACTUAL_GET_DEFAULT_VERSION() == C.DEFAULT
+
+
+ACTUAL_INFER_GO_VERSION = golang._infer_go_version.__wrapped__
+
+
+def test_golang_infer_go_version_not_default():
+    assert ACTUAL_INFER_GO_VERSION('1.19.4') == '1.19.4'
+
+
+def test_golang_infer_go_version_default():
+    version = ACTUAL_INFER_GO_VERSION(C.DEFAULT)
+
+    assert version != C.DEFAULT
+    assert re.match(r'^\d+\.\d+\.\d+$', version)
diff --git a/tests/repository_test.py b/tests/repository_test.py
--- a/tests/repository_test.py
+++ b/tests/repository_test.py
@@ -380,17 +380,36 @@ def test_swift_hook(tempdir_factory, store):
     )
 
 
-def test_golang_hook(tempdir_factory, store):
+def test_golang_system_hook(tempdir_factory, store):
     _test_hook_repo(
         tempdir_factory, store, 'golang_hooks_repo',
-        'golang-hook', [], b'hello world\n',
+        'golang-hook', ['system'], b'hello world from system\n',
+        config_kwargs={
+            'hooks': [{
+                'id': 'golang-hook',
+                'language_version': 'system',
+            }],
+        },
+    )
+
+
+def test_golang_versioned_hook(tempdir_factory, store):
+    _test_hook_repo(
+        tempdir_factory, store, 'golang_hooks_repo',
+        'golang-hook', [], b'hello world from go1.18.4\n',
+        config_kwargs={
+            'hooks': [{
+                'id': 'golang-hook',
+                'language_version': '1.18.4',
+            }],
+        },
     )
 
 
 def test_golang_hook_still_works_when_gobin_is_set(tempdir_factory, store):
     gobin_dir = tempdir_factory.get()
     with envcontext((('GOBIN', gobin_dir),)):
-        test_golang_hook(tempdir_factory, store)
+        test_golang_system_hook(tempdir_factory, store)
     assert os.listdir(gobin_dir) == []
 
 
@@ -677,7 +696,7 @@ def test_additional_golang_dependencies_installed(
     envdir = helpers.environment_dir(
         hook.prefix,
         golang.ENVIRONMENT_DIR,
-        C.DEFAULT,
+        golang.get_default_version(),
     )
     binaries = os.listdir(os.path.join(envdir, 'bin'))
     # normalize for windows