From f380d4dac53e0d618b21e2f7f9c787658f4ff694 Mon Sep 17 00:00:00 2001 From: Brian Thompson Date: Thu, 23 Jul 2026 14:24:40 -0500 Subject: [PATCH 1/3] feat(gvm): add auto-detect Go version from `.go-version` / `go.mod` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement `gvm use` and `gvm install` without an explicit version argument by resolving the required Go version from the nearest project files, closing #10 / moovweb/gvm#523. Key changes: - Add `scripts/function/resolve_project_version` with helpers: - `__gvm_find_file_upwards`: cwd-safe upward directory walker (no longer mutates caller's working directory) - `__gvm_parse_go_mod_version`: reads `toolchain` / `go` directive from `go.mod` - `__gvm_read_dot_go_version_file`: reads `.go-version` pin - `__gvm_map_version_hint_to_tag`: maps language versions like `1.22` to the latest installed or available patch tag - `__gvm_resolve_project_go_version`: orchestrates precedence (`.go-version` → `go.mod`) - Update `scripts/env/use` to call `__gvm_resolve_project_go_version` when no version argument is given - Refactor `scripts/env/applymod` to reuse the same shared helpers - Fix `scripts/function/find_path_upwards` to avoid `cd` side-effects and external `dirname` calls - Document empty-string contract for `_bash_pseudo_hash` (no distinct nil values) - Add CI smoke test for `go.mod` auto-detection from a subdirectory - Bump version to 1.3.0 and update `ChangeLog` / `README.md` feat(install): add auto-detection of Go version from project files When no version is specified, `gvm install` now resolves the required Go version from the nearest `.go-version` or `go.mod` file using `__gvm_resolve_project_go_version`. Language-level versions (e.g. `go 1.22`) are mapped to the latest available patch release for that minor version. This removes the hard failure on missing version argument and replaces it with a smarter fallback, making it easier to install the correct Go version when working inside a project directory without having to look up or type the version manually. --- .DS_Store | Bin 0 -> 8196 bytes ChangeLog | 15 + README.md | 16 +- VERSION | 2 +- scripts/ci-smoke.sh | 13 + scripts/env/applymod | 36 +-- scripts/env/use | 19 +- scripts/function/_bash_pseudo_hash | 6 +- scripts/function/find_path_upwards | 44 +-- scripts/function/resolve_project_version | 352 +++++++++++++++++++++++ scripts/install | 20 +- tests/gvm_use_gomod_comment_test.sh | 13 + 12 files changed, 488 insertions(+), 48 deletions(-) create mode 100644 .DS_Store create mode 100644 scripts/function/resolve_project_version create mode 100644 tests/gvm_use_gomod_comment_test.sh diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d84a30c0b0d783bb1f7b90435991e9ca8442f277 GIT binary patch literal 8196 zcmeHM&1w`u5Ux%(W;G&9yhIS3qlkzBA7BjYNkJhXXgrwR=tkJtS&|K?rvVXfK7ye5 z7!o`MPk8`Q$aBPtU;pfE)%MJuL>5vF-8FQ5-SzF)KSRwDk=Z!dStnW}q9&@ftravC z#rkcH%0lS54r?%;`n~ndZk`Pzq;{wRs(>n>3aA3Az`Q7cceX9fn)kleQOo(hkhGJZF%%30=&4Z1}=dPO6VzqbZoxi8Dx?M^@Mps)Gm z)1T49^FQuN|ISSRaF=}>xO#%2v^tmdkKp0)Z|preKJ@WR?<8*QmGN%d-+#f!qkZbq9wHBEMEf%O>YX|q z=W_J;bi5vwb8!THo-BoBA1 zH|YhvrftfgR3Fb$tJR;DY=q%_ZWbk<3fyG%X5j>{%LPKxlH)bP8g5H<%&GKpE)O0( z9q%7Do-0$05b@n>8Rw{v_31*HSQRq%6i*>IkNaEY-ic5XMc%w)r%gnYeB3%M&)CuC z_|WkYd!F<8^`7yWLQVM8qXeIZJ7-HcSJ*A_>ALvAtxxIYJO&?29&9{cpK64NPu~$h ziz+ae3M_h7Yc~JCSO5KgF1afyRX`Q^Hwu_Wd$YZPxk7Fo&1!S)1ob(pEf!aJ>xQ7Q n5Zmw4aoF+?Lp&$Jmj+vS3lGYF{}90bq15-kTLNv)paOpZR$*f+ literal 0 HcmV?d00001 diff --git a/ChangeLog b/ChangeLog index 997bd53..2f769ed 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/). See [RELEASING.md](RELEASING.md) for the release process. +## [1.3.0] - 2026-07-23 + +### Added + +- `gvm use` / `gvm install` without a version resolve from the nearest `.go-version` or `go.mod` (`toolchain` / `go` directive), mapping language versions like `1.22` to the latest patch (#10 / moovweb/gvm#523). +- Shared project version helpers; `gvm applymod` uses the same go.mod parsing and patch mapping. + +### Fixed + +- Directory walk for `.go-version` / `.go-pkgset` / `go.mod` no longer stops at the current directory only, and no longer mutates the caller's working directory. + +### Changed + +- Document empty-string contract for `_bash_pseudo_hash` (no distinct nil values). + ## [1.2.1] - 2026-07-19 ### Fixed diff --git a/README.md b/README.md index 319abe1..a325f29 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # gvm2 -> **Status: stable for daily use (v1.2.0)** +> **Status: stable for daily use (v1.3.0)** > > gvm2 is a community reboot of the Go Version Manager. Please file bugs and > contributions against this repository. Production use is supported for the @@ -21,6 +21,15 @@ gvm use go1.22.12 --default go version ``` +In a module directory you can omit the version — gvm2 reads `.go-version` or +`go.mod` (`toolchain` / `go` line) and maps language versions like `go 1.22` to +the latest matching patch: + +```bash +gvm install -B # install version from go.mod / .go-version +gvm use # select matching installed version +``` + Common commands (nvm-style workflow): | Goal | Command | @@ -28,7 +37,9 @@ Common commands (nvm-style workflow): | List available versions | `gvm listall` | | Latest stable name only | `gvm listall --latest` | | Install a version | `gvm install go1.22.12` | +| Install from project files | `gvm install` (or `gvm install -B`) | | Switch version | `gvm use go1.22.12` | +| Switch from project files | `gvm use` | | List installed versions | `gvm list` | | Per-directory auto-switch | commit a `.go-version` (and optional `.go-pkgset`) | @@ -63,7 +74,7 @@ triage groups the backlog into these priorities: | P0 | Shell integration (`cd`, PATH, zsh) | [#527](https://github.com/moovweb/gvm/issues/527), [#528](https://github.com/moovweb/gvm/issues/528), [#515](https://github.com/moovweb/gvm/issues/515) | Fixed in 1.1.0 | | P1 | Install / bootstrap | [#530](https://github.com/moovweb/gvm/issues/530), [#480](https://github.com/moovweb/gvm/issues/480) | Fixed in 1.1.0 | | P2 | UX / docs / ergonomics | [#517](https://github.com/moovweb/gvm/issues/517), [#516](https://github.com/moovweb/gvm/issues/516) | Fixed in 1.2.0 ([#4](https://github.com/brianrobt/gvm2/issues/4)) | -| P3 | Features (progress bars, worktrees, auto-detect `go.mod`) | [#514](https://github.com/moovweb/gvm/issues/514), [#523](https://github.com/moovweb/gvm/issues/523) | Backlog | +| P3 | Features (progress bars, worktrees, auto-detect `go.mod`) | [#514](https://github.com/moovweb/gvm/issues/514), [#523](https://github.com/moovweb/gvm/issues/523) | `go.mod` auto-detect in 1.3.0 ([#10](https://github.com/brianrobt/gvm2/issues/10)); progress / worktrees still backlog | Tracked here: [brianrobt/gvm2/issues](https://github.com/brianrobt/gvm2/issues). @@ -72,6 +83,7 @@ Pull requests and other contributions are very much appreciated. ## Features * Install/Uninstall Go versions with `gvm install [tag]` where tag is "go1.22.12", "go1", "weekly.2011-11-08", or "tip" +* Omit the version on `gvm install` / `gvm use` to resolve from `.go-version` or `go.mod` * List available versions with `gvm listall` (annotations + `--latest` / `--porcelain`) * List added/removed files in GOROOT with `gvm diff` * Manage GOPATHs with `gvm pkgset [create/use/delete] [name]`. Use `--local` as `name` to manage repository under local path (`/path/to/repo/.gvm_local`). diff --git a/VERSION b/VERSION index 6085e94..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.1 +1.3.0 diff --git a/scripts/ci-smoke.sh b/scripts/ci-smoke.sh index b3938cb..f8841f6 100755 --- a/scripts/ci-smoke.sh +++ b/scripts/ci-smoke.sh @@ -24,6 +24,19 @@ if grep -q 'global:$GOPATH' "$GVM_DEST/gvm/environments/go1.22.12@global"; then echo "FAIL: go1.22.12@global has duplicated global GOPATH prefix" >&2 exit 1 fi +# Auto-detect from go.mod (#10) +moddir="$GVM_DEST/mod-detect" +mkdir -p "$moddir/subdir" +cat > "$moddir/go.mod" <<'EOF' +module example.com/detect + +go 1.22 +EOF +( + cd "$moddir/subdir" + gvm use + go version | grep -E 'go1\.22\.' +) spaced="$GVM_DEST/dir with spaces" mkdir -p "$spaced" cd "$spaced" diff --git a/scripts/env/applymod b/scripts/env/applymod index 1d892c5..b8f4bbe 100755 --- a/scripts/env/applymod +++ b/scripts/env/applymod @@ -2,29 +2,33 @@ . "$GVM_ROOT/scripts/functions" function gvm_applymod(){ + local mod_path="" + local hint="" + local version="" - mod_file=go.mod - if [ ! -f $mod_file ]; then + if ! mod_path="$(__gvm_find_file_upwards "go.mod")"; then return $(display_error "can not find go.mod") fi - mod_ver=$(cat go.mod | grep -E 'go [[:digit:]]+.[[:digit:]]+' | tr -d ' ' | tr -d '\n') - mod_name=$(cat go.mod | grep 'module ' | tr -d '\n') - display_message "$mod_name use go version: $mod_ver" + hint="$(__gvm_parse_go_mod_version "${mod_path}")" || hint="" + [[ -n "${hint}" ]] || return $(display_error "can not parse go version from go.mod") - if [ $(gvm list | grep $mod_ver | wc -l | tr -d '\n') -lt 1 ]; then - if [ $(gvm listall -a| grep $mod_ver | wc -l | tr -d '\n') -lt 1 ]; then - return $(display_error "can not find a go version match $mod_ver") - fi + # Prefer an already-installed match; otherwise resolve the latest available patch. + version="$(__gvm_map_version_hint_to_tag "${hint}" installed)" || version="" + if [[ -z "${version}" ]]; then + version="$(__gvm_map_version_hint_to_tag "${hint}" available)" || version="" + fi + [[ -n "${version}" ]] || return $(display_error "can not find a go version match ${hint}") - display_message "$mod_ver does not exist, try to install..." - $GVM_ROOT/scripts/install $mod_ver + display_message "go.mod requests ${hint}; using ${version}" - if [ $? -ne 0 ]; then - return $(display_error "gvm install $mod_ver error") + if [[ ! -d "${GVM_ROOT}/gos/${version}" ]]; then + display_message "${version} does not exist, try to install..." + "$GVM_ROOT/scripts/install" "${version}" + if [[ $? -ne 0 ]]; then + return $(display_error "gvm install ${version} error") fi fi - gvm_use "$mod_ver" - -} \ No newline at end of file + gvm_use "${version}" +} diff --git a/scripts/env/use b/scripts/env/use index 7470872..b1cc7ce 100644 --- a/scripts/env/use +++ b/scripts/env/use @@ -14,6 +14,7 @@ #
@textblock
# Usage: gvm use [--version] [--pkgset ] [--default] # gvm use @ [--default] +# gvm use [--pkgset ] [--default] # auto-detect from .go-version / go.mod # # Options: # --version value version name @@ -22,7 +23,8 @@ # --quiet suppress 'now using' acknowledgements # -h, --help show this message # -# When using --pkgset, you must also specify a go version name. +# With no version, resolve from the nearest .go-version (preferred) or go.mod. +# When using --pkgset, you must also specify a go version name (or auto-detect). # @/textblock # @param args Variable list of options and values # @return Returns success (status 0) if a pkgset was selected successfully or @@ -138,6 +140,7 @@ gvm_use() { then echo "Usage: gvm use [--version] [--pkgset ] [--default]" echo " gvm use @ [--default]" + echo " gvm use [--pkgset ] [--default]" echo echo "Options:" echo " --version value go version name or alias" @@ -146,7 +149,9 @@ gvm_use() { echo " --quiet suppress 'now using' acknowledgements" echo " -h, --help show this message" echo - echo "When using --pkgset, you must also specify a go version name." + echo "With no version, resolve from the nearest .go-version or go.mod" + echo "(toolchain / go directive; language versions map to the latest" + echo "installed patch for that minor)." echo return 0 else @@ -194,8 +199,14 @@ gvm_use() { if [[ -z "${version}" ]] then - display_error "Please specify the version. Execute 'gvm use --help' for command help." - return 1 + # Bare `gvm use`: resolve from .go-version / go.mod (#10 / moovweb/gvm#523). + version="$(__gvm_resolve_project_go_version installed)" || version="" + if [[ -z "${version}" ]] + then + display_error "Please specify the version (or add .go-version / go.mod). Execute 'gvm use --help' for command help." + return 1 + fi + [[ "${GVM_DEBUG}" -eq 1 ]] && echo "Auto-detected version: ${version}" fi # Exact match first, then prefix match — avoid fragile multi-grep pipelines under `set -e`. diff --git a/scripts/function/_bash_pseudo_hash b/scripts/function/_bash_pseudo_hash index 166be7e..bb9b146 100644 --- a/scripts/function/_bash_pseudo_hash +++ b/scripts/function/_bash_pseudo_hash @@ -42,7 +42,8 @@ setValueForKeyFakeAssocArray() { fi # parameter list supports empty arguments! local target_key="$1"; shift - local new_value="$1"; shift # @todo: need to support setting nil values! + # Empty values are stored as empty strings (no distinct nil). Callers use -n checks. + local new_value="$1"; shift local target_ary; target_ary=() local defaultIFS="$IFS" local IFS="$defaultIFS" @@ -137,7 +138,8 @@ valueForKeyFakeAssocArray() { local _item for _item in "${target_ary[@]}"; do if [[ "${_item%%:*}" == "${target_key}" ]]; then - value="$(_decode "${_item#*:}")" # @todo: need to support returning nil values! + # Missing keys and empty values both surface as "" (no distinct nil). + value="$(_decode "${_item#*:}")" break fi done diff --git a/scripts/function/find_path_upwards b/scripts/function/find_path_upwards index 4191605..e73770e 100644 --- a/scripts/function/find_path_upwards +++ b/scripts/function/find_path_upwards @@ -1,5 +1,7 @@ #!/usr/bin/env bash +. "$GVM_ROOT/scripts/function/_shell_compat" || return 1 + # __gvm_find_path_upwards() # /*! # @abstract Search upwards through the directory tree for a target file or @@ -30,20 +32,22 @@ __gvm_find_path_upwards() { echo "" && return 1 fi - local found_file="$(__gvmp_find_current_path_for_target "${target}")" - echo "${found_file}" - - if [[ -z "${found_file}" ]]; then - return 1 + # Prefer the cwd-safe walker from resolve_project_version when loaded. + if __gvm_is_function __gvm_find_file_upwards; then + __gvm_find_file_upwards "${target}" "${start_dir}" "${final_dir}" + return $? fi - return 0 + __gvmp_find_path_for_target "${target}" "${start_dir}" "${final_dir}" } __gvmp_find_path_for_target() { local target="${1}" local start_dir="${2}" local final_dir="${3}" + local current_dir="" + local stop_dir="" + local parent="" if [[ ${#target} -eq 0 ]]; then echo ""; return 1 @@ -51,27 +55,35 @@ __gvmp_find_path_for_target() { # resolve tilde for HOME start_dir="${start_dir/#\~/$HOME}" + final_dir="${final_dir/#\~/$HOME}" # resolve dot for PWD if [[ "${start_dir}" == "." ]]; then start_dir="$PWD" fi - local current_dir="${start_dir}" - local highest_dir="${final_dir}" + current_dir="$(builtin cd "${start_dir}" 2>/dev/null && pwd)" || { + echo "" + return 1 + } + stop_dir="$(builtin cd "${final_dir}" 2>/dev/null && pwd)" || stop_dir="/" - builtin cd "${current_dir}" - while [[ "${current_dir}" != "${highest_dir}" && "${current_dir}" != "/" ]]; do - if [[ -f "${current_dir}/${target}" || -d "${current_dir}/${target}" ]]; then - echo "${current_dir}/${target}"; return 0 + while true; do + if [[ -f "${current_dir}/${target}" || -d "${current_dir}/${target}" ]]; then + echo "${current_dir}/${target}" + return 0 + fi + if [[ "${current_dir}" == "${stop_dir}" || "${current_dir}" == "/" ]]; then break fi - - builtin cd .. - current_dir="$(pwd)" + # Avoid external `dirname` — PATH may be gvm-munged without /usr/bin. + parent="${current_dir%/*}" + [[ -z "${parent}" ]] && parent="/" + [[ "${parent}" == "${current_dir}" ]] && break + current_dir="${parent}" done - echo ""; + echo "" return 1 } diff --git a/scripts/function/resolve_project_version b/scripts/function/resolve_project_version new file mode 100644 index 0000000..8e14c93 --- /dev/null +++ b/scripts/function/resolve_project_version @@ -0,0 +1,352 @@ +#!/usr/bin/env bash + +. "$GVM_ROOT/scripts/function/_shell_compat" || return 1 + +# __gvm_find_file_upwards() +# /*! +# @abstract Walk from start_dir toward final_dir looking for a basename. +# @discussion +# Does not change the caller's working directory. Stops at final_dir or /. +# @param target Basename to find +# @param start_dir Optional start (default: PWD) +# @param final_dir Optional stop directory (default: HOME) +# @return Path to found file (status 0) or empty string (status 1) +# */ +__gvm_find_file_upwards() { + local target="${1}" + local start_dir="${2:-$PWD}" + local final_dir="${3:-$HOME}" + local current_dir="" + local stop_dir="" + local parent="" + + if [[ -z "${target}" || -z "${start_dir}" ]]; then + echo "" + return 1 + fi + + start_dir="${start_dir/#\~/$HOME}" + final_dir="${final_dir/#\~/$HOME}" + [[ "${start_dir}" == "." ]] && start_dir="$PWD" + + current_dir="$(builtin cd "${start_dir}" 2>/dev/null && pwd)" || { + echo "" + return 1 + } + stop_dir="$(builtin cd "${final_dir}" 2>/dev/null && pwd)" || stop_dir="/" + + while true; do + if [[ -e "${current_dir}/${target}" ]]; then + echo "${current_dir}/${target}" + return 0 + fi + if [[ "${current_dir}" == "${stop_dir}" || "${current_dir}" == "/" ]]; then + break + fi + # Avoid external `dirname` — PATH may be gvm-munged without /usr/bin. + parent="${current_dir%/*}" + [[ -z "${parent}" ]] && parent="/" + [[ "${parent}" == "${current_dir}" ]] && break + current_dir="${parent}" + done + + echo "" + return 1 +} + +# __gvm_parse_go_mod_version() +# /*! +# @abstract Read language / toolchain version from a go.mod file. +# @discussion +# Prefer an explicit \`toolchain go1.x.y\` line when present (exact gvm tag). +# Otherwise parse the \`go X.Y[.Z]\` directive and return a language version +# string like \`1.22\` or \`1.22.5\` (without the \`go\` prefix). +# @param go_mod_path Path to go.mod +# @return Version string (status 0) or empty (status 1) +# */ +__gvm_parse_go_mod_version() { + local go_mod_path="${1}" + local line="" + local rest="" + local toolchain="" + local lang_ver="" + + if [[ -z "${go_mod_path}" || ! -f "${go_mod_path}" ]]; then + echo "" + return 1 + fi + + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "${line}" || "${line}" == //* ]] && continue + + # Rematch convention: GVM_REMATCH[1] must equal the full match string + # (zsh drops 0-based index after __gvm_rematch). Strip prefixes first. + if [[ "${line}" == toolchain[[:space:]]* ]]; then + rest="${line#toolchain}" + rest="${rest#"${rest%%[![:space:]]*}"}" + if __gvm_rematch "${rest}" '^(go[0-9]+(\.[0-9]+)*([a-z0-9]+)?)$'; then + toolchain="${GVM_REMATCH[1]}" + fi + elif [[ "${line}" == go[[:space:]]* ]]; then + rest="${line#go}" + rest="${rest#"${rest%%[![:space:]]*}"}" + if __gvm_rematch "${rest}" '^([0-9]+(\.[0-9]+)*)$'; then + lang_ver="${GVM_REMATCH[1]}" + fi + fi + done < "${go_mod_path}" + + + if [[ -n "${toolchain}" ]]; then + echo "${toolchain}" + return 0 + fi + if [[ -n "${lang_ver}" ]]; then + echo "${lang_ver}" + return 0 + fi + + echo "" + return 1 +} + +# __gvm_read_dot_go_version_file() +# /*! +# @abstract Read first non-comment go version name from a .go-version file. +# @param filepath Path to .go-version +# @return Tag like go1.22.12 (status 0) or empty (status 1) +# */ +__gvm_read_dot_go_version_file() { + local filepath="${1}" + local version="" + local _line="" + + [[ -f "${filepath}" ]] || { + echo "" + return 1 + } + + while IFS= read -r _line || [[ -n "${_line}" ]]; do + _line="${_line%$'\r'}" + [[ "${_line}" =~ ^[[:space:]]*# ]] && continue + _line="${_line#"${_line%%[![:space:]]*}"}" + _line="${_line%"${_line##*[![:space:]]}"}" + [[ -z "${_line}" ]] && continue + if __gvm_rematch "${_line}" '^(go([0-9]+(\.[0-9]+)*([a-z0-9]+)?))$'; then + version="${GVM_REMATCH[1]}" + break + fi + done < "${filepath}" + + echo "${version}" + [[ -n "${version}" ]] +} + +# __gvm_normalize_go_version_hint() +# /*! +# @abstract Normalize hints like \`1.22\`, \`go1.22\`, \`go1.22.5\` to a tag prefix. +# @param hint Version hint from go.mod or user input +# @return Tag or prefix like go1.22 / go1.22.5 (status 0) or empty (status 1) +# */ +__gvm_normalize_go_version_hint() { + local hint="${1}" + + [[ -n "${hint}" ]] || { + echo "" + return 1 + } + + if __gvm_rematch "${hint}" '^go[0-9]'; then + echo "${hint}" + return 0 + fi + if __gvm_rematch "${hint}" '^[0-9]+(\.[0-9]+)*([a-z0-9]+)?$'; then + echo "go${hint}" + return 0 + fi + + echo "" + return 1 +} + +# __gvm_is_stable_go_tag() +# True for stable go1.x / go1.x.y tags (excludes beta/rc). +__gvm_is_stable_go_tag() { + __gvm_rematch "${1}" '^go[0-9]+(\.[0-9]+)+$' +} + +# __gvm_latest_matching_go_tag() +# /*! +# @abstract Pick the highest stable tag matching a prefix from a newline list. +# @param prefix Tag or prefix (e.g. go1.22 or go1.22.5) +# @param versions Newline-separated candidate tags +# @return Best matching tag (status 0) or empty (status 1) +# */ +__gvm_latest_matching_go_tag() { + local prefix="${1}" + local versions="${2}" + local best="" + local candidate="" + local exact=false + local sort_cmd="${SORT_PATH:-sort}" + + [[ -n "${prefix}" ]] || { + echo "" + return 1 + } + + # Exact patch/prerelease request: prefer exact match when present. + if __gvm_rematch "${prefix}" '^go[0-9]+\.[0-9]+\.[0-9]+([a-z0-9]+)?$'; then + exact=true + fi + + while IFS= read -r candidate; do + [[ -z "${candidate}" ]] && continue + if [[ "${exact}" == true ]]; then + if [[ "${candidate}" == "${prefix}" ]]; then + echo "${candidate}" + return 0 + fi + continue + fi + if [[ "${candidate}" == "${prefix}" || "${candidate}" == "${prefix}".* ]]; then + __gvm_is_stable_go_tag "${candidate}" || continue + best="${candidate}" + fi + done <<< "$(printf '%s\n' "${versions}" | "${sort_cmd}" -V)" + + echo "${best}" + [[ -n "${best}" ]] +} + +# __gvm_list_available_go_tags() +# /*! +# @abstract List remote go tags (go1* / release*), one per line. +# */ +__gvm_list_available_go_tags() { + local versions="" + local version="" + + if ! versions="$(git ls-remote -t https://github.com/golang/go 2>/dev/null | awk -F/ '{ print $NF }')"; then + echo "" + return 1 + fi + + for version in ${versions}; do + if [[ "${version:0:7}" == "release" || "${version:0:3}" == "go1" ]]; then + printf '%s\n' "${version}" + fi + done + return 0 +} + +# __gvm_list_installed_go_tags() +# /*! +# @abstract List installed go version directory names. +# */ +__gvm_list_installed_go_tags() { + local dir="" + [[ -d "${GVM_ROOT}/gos" ]] || { + echo "" + return 1 + } + for dir in "${GVM_ROOT}/gos"/*; do + [[ -d "${dir}" ]] || continue + printf '%s\n' "${dir##*/}" + done + return 0 +} + +# __gvm_map_version_hint_to_tag() +# /*! +# @abstract Map a language/tag hint to an installable gvm tag. +# @param hint Normalized or raw hint (1.22 / go1.22 / go1.22.12) +# @param scope \`installed\` (default) or \`available\` +# @return Concrete tag (status 0) or empty (status 1) +# */ +__gvm_map_version_hint_to_tag() { + local hint="${1}" + local scope="${2:-installed}" + local prefix="" + local candidates="" + local matched="" + + prefix="$(__gvm_normalize_go_version_hint "${hint}")" || { + echo "" + return 1 + } + + if [[ "${scope}" == "available" ]]; then + candidates="$(__gvm_list_available_go_tags)" || candidates="" + else + candidates="$(__gvm_list_installed_go_tags)" || candidates="" + fi + + matched="$(__gvm_latest_matching_go_tag "${prefix}" "${candidates}")" || matched="" + if [[ -n "${matched}" ]]; then + echo "${matched}" + return 0 + fi + + # For available scope with an exact tag request, return the tag itself so + # install can attempt it even if listing failed transiently. + if [[ "${scope}" == "available" ]] && __gvm_rematch "${prefix}" '^go[0-9]+\.[0-9]+\.[0-9]+([a-z0-9]+)?$'; then + echo "${prefix}" + return 0 + fi + + echo "" + return 1 +} + +# __gvm_resolve_project_go_version() +# /*! +# @abstract Resolve Go version from the nearest project files. +# @discussion +# Precedence: +# 1. \`.go-version\` (explicit gvm pin) +# 2. \`go.mod\` \`toolchain\` line, else \`go\` directive (mapped to latest patch) +# +# @param scope Optional \`installed\` (default, for \`gvm use\`) or \`available\` +# (for \`gvm install\`). +# @return Concrete gvm tag like go1.22.12 (status 0) or empty (status 1) +# */ +__gvm_resolve_project_go_version() { + local scope="${1:-installed}" + local path="" + local hint="" + local mapped="" + + if path="$(__gvm_find_file_upwards ".go-version")"; then + hint="$(__gvm_read_dot_go_version_file "${path}")" || hint="" + if [[ -n "${hint}" ]]; then + mapped="$(__gvm_map_version_hint_to_tag "${hint}" "${scope}")" || mapped="" + if [[ -n "${mapped}" ]]; then + echo "${mapped}" + return 0 + fi + # Exact pin from .go-version: return as-is for install attempts. + if [[ "${scope}" == "available" ]]; then + echo "${hint}" + return 0 + fi + fi + fi + + if path="$(__gvm_find_file_upwards "go.mod")"; then + hint="$(__gvm_parse_go_mod_version "${path}")" || hint="" + if [[ -n "${hint}" ]]; then + mapped="$(__gvm_map_version_hint_to_tag "${hint}" "${scope}")" || mapped="" + if [[ -n "${mapped}" ]]; then + echo "${mapped}" + return 0 + fi + fi + fi + + echo "" + return 1 +} diff --git a/scripts/install b/scripts/install index 06465eb..f666b8a 100755 --- a/scripts/install +++ b/scripts/install @@ -3,6 +3,7 @@ function show_usage() { echo "Usage: gvm install [version] [options]" + echo " gvm install [options] # auto-detect from .go-version / go.mod" echo " -s, --source=SOURCE Install Go from specified source." echo " -n, --name=NAME Override the default name for this version." echo " -pb, --with-protobuf Install Go protocol buffers." @@ -10,16 +11,17 @@ function show_usage() { echo " -B, --binary Only install from binary." echo " --prefer-binary Attempt a binary install, falling back to source." echo " -h, --help Display this message." + echo + echo "With no version, resolve from the nearest .go-version or go.mod" + echo "(toolchain / go directive; language versions map to the latest" + echo "available patch for that minor)." } read_command_line() { - VERSION=$1 - if [[ "${VERSION:0:1}" != "-" ]]; then + VERSION="" + if [[ $# -gt 0 && "${1:0:1}" != "-" ]]; then + VERSION=$1 shift - else - display_warning "Invalid version: $1" - show_usage - exit 1 fi GO_SOURCE_URL=https://github.com/golang/go for i in "$@"; do @@ -348,7 +350,11 @@ install_from_source() { main() { trap 'display_fatal "Canceled!"' INT read_command_line "$@" - [[ "$VERSION" == "" ]] && display_fatal "No version specified" + if [[ "$VERSION" == "" ]]; then + VERSION="$(__gvm_resolve_project_go_version available)" || VERSION="" + [[ -n "$VERSION" ]] || display_fatal "No version specified (and no .go-version / go.mod found)" + display_message "Auto-detected version $VERSION" + fi if [[ "$GO_NAME" == "" ]]; then GO_NAME=$VERSION diff --git a/tests/gvm_use_gomod_comment_test.sh b/tests/gvm_use_gomod_comment_test.sh new file mode 100644 index 0000000..63b9805 --- /dev/null +++ b/tests/gvm_use_gomod_comment_test.sh @@ -0,0 +1,13 @@ +source $GVM_ROOT/scripts/gvm + +## Requires go1.22.12 from 00gvm_install_comment_test.sh +mkdir -p /tmp/gvm2-mod-detect-$$/subdir +echo 'module example.com/t + +go 1.22 +' > /tmp/gvm2-mod-detect-$$/go.mod +cd /tmp/gvm2-mod-detect-$$/subdir +gvm use # status=0; match=/Now using version go1\.22\./ +go version # status=0; match=/go1\.22\./ +cd / +rm -rf /tmp/gvm2-mod-detect-$$ From 86de1bccd37183f7d635eacb8b7c48a38e4369e3 Mon Sep 17 00:00:00 2001 From: Brian Thompson Date: Thu, 23 Jul 2026 14:34:38 -0500 Subject: [PATCH 2/3] fix(test): stop tf hang on multi-line go.mod fixture tf executes each line as its own command, so a multi-line echo left an unclosed quote and wedged the suite after the alias cleanup noise. --- ChangeLog | 1 + tests/gvm_alias_comment_test.sh | 6 +++--- tests/gvm_use_gomod_comment_test.sh | 6 ++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/ChangeLog b/ChangeLog index 2f769ed..7cd166a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -17,6 +17,7 @@ See [RELEASING.md](RELEASING.md) for the release process. ### Fixed - Directory walk for `.go-version` / `.go-pkgset` / `go.mod` no longer stops at the current directory only, and no longer mutates the caller's working directory. +- Comment tests: write `go.mod` with single-line `printf` so `tf` does not hang on multi-line quotes; silence expected alias cleanup errors. ### Changed diff --git a/tests/gvm_alias_comment_test.sh b/tests/gvm_alias_comment_test.sh index ded056a..1c87244 100644 --- a/tests/gvm_alias_comment_test.sh +++ b/tests/gvm_alias_comment_test.sh @@ -1,8 +1,8 @@ source $GVM_ROOT/scripts/gvm -## Cleanup test objects -gvm alias delete foo -gvm alias delete bar +## Cleanup test objects (may not exist yet — ignore errors) +gvm alias delete foo > /dev/null 2>&1 +gvm alias delete bar > /dev/null 2>&1 ####################### gvm alias # status=0 diff --git a/tests/gvm_use_gomod_comment_test.sh b/tests/gvm_use_gomod_comment_test.sh index 63b9805..688bb45 100644 --- a/tests/gvm_use_gomod_comment_test.sh +++ b/tests/gvm_use_gomod_comment_test.sh @@ -1,11 +1,9 @@ source $GVM_ROOT/scripts/gvm ## Requires go1.22.12 from 00gvm_install_comment_test.sh +## Note: tf runs each line as its own command — never use multi-line quotes/heredocs. mkdir -p /tmp/gvm2-mod-detect-$$/subdir -echo 'module example.com/t - -go 1.22 -' > /tmp/gvm2-mod-detect-$$/go.mod +printf '%s\n' 'module example.com/t' '' 'go 1.22' > /tmp/gvm2-mod-detect-$$/go.mod cd /tmp/gvm2-mod-detect-$$/subdir gvm use # status=0; match=/Now using version go1\.22\./ go version # status=0; match=/go1\.22\./ From 0c91bd6dfad8caa0e02e0dde981d313363125cea Mon Sep 17 00:00:00 2001 From: Brian Thompson Date: Thu, 23 Jul 2026 14:43:34 -0500 Subject: [PATCH 3/3] fix(gvm): address CodeRabbit findings for project version resolve Bound remote tag listing with git HTTP low-speed abort, keep .go-version authoritative, accept plain 1.x.y pins, dedupe path walking, and harden the go.mod comment test tempdir handling. --- ChangeLog | 1 + scripts/function/find_path_upwards | 58 +++--------------------- scripts/function/resolve_project_version | 20 +++++++- tests/gvm_use_gomod_comment_test.sh | 11 +++-- 4 files changed, 31 insertions(+), 59 deletions(-) diff --git a/ChangeLog b/ChangeLog index 7cd166a..4e0c980 100644 --- a/ChangeLog +++ b/ChangeLog @@ -18,6 +18,7 @@ See [RELEASING.md](RELEASING.md) for the release process. - Directory walk for `.go-version` / `.go-pkgset` / `go.mod` no longer stops at the current directory only, and no longer mutates the caller's working directory. - Comment tests: write `go.mod` with single-line `printf` so `tf` does not hang on multi-line quotes; silence expected alias cleanup errors. +- `.go-version` stays authoritative (no go.mod fallback after a parsed pin); accept plain `1.x.y` pins; bound remote tag listing via git HTTP low-speed abort. ### Changed diff --git a/scripts/function/find_path_upwards b/scripts/function/find_path_upwards index e73770e..b2f0fdc 100644 --- a/scripts/function/find_path_upwards +++ b/scripts/function/find_path_upwards @@ -1,6 +1,8 @@ #!/usr/bin/env bash . "$GVM_ROOT/scripts/function/_shell_compat" || return 1 +# Canonical walker lives here; load eagerly so cd / use share one implementation. +. "$GVM_ROOT/scripts/function/resolve_project_version" || return 1 # __gvm_find_path_upwards() # /*! @@ -32,62 +34,14 @@ __gvm_find_path_upwards() { echo "" && return 1 fi - # Prefer the cwd-safe walker from resolve_project_version when loaded. - if __gvm_is_function __gvm_find_file_upwards; then - __gvm_find_file_upwards "${target}" "${start_dir}" "${final_dir}" - return $? - fi - - __gvmp_find_path_for_target "${target}" "${start_dir}" "${final_dir}" + __gvm_find_file_upwards "${target}" "${start_dir}" "${final_dir}" } +# Compatibility alias for older call sites / tests. __gvmp_find_path_for_target() { - local target="${1}" - local start_dir="${2}" - local final_dir="${3}" - local current_dir="" - local stop_dir="" - local parent="" - - if [[ ${#target} -eq 0 ]]; then - echo ""; return 1 - fi - - # resolve tilde for HOME - start_dir="${start_dir/#\~/$HOME}" - final_dir="${final_dir/#\~/$HOME}" - - # resolve dot for PWD - if [[ "${start_dir}" == "." ]]; then - start_dir="$PWD" - fi - - current_dir="$(builtin cd "${start_dir}" 2>/dev/null && pwd)" || { - echo "" - return 1 - } - stop_dir="$(builtin cd "${final_dir}" 2>/dev/null && pwd)" || stop_dir="/" - - while true; do - if [[ -f "${current_dir}/${target}" || -d "${current_dir}/${target}" ]]; then - echo "${current_dir}/${target}" - return 0 - fi - if [[ "${current_dir}" == "${stop_dir}" || "${current_dir}" == "/" ]]; then - break - fi - # Avoid external `dirname` — PATH may be gvm-munged without /usr/bin. - parent="${current_dir%/*}" - [[ -z "${parent}" ]] && parent="/" - [[ "${parent}" == "${current_dir}" ]] && break - current_dir="${parent}" - done - - echo "" - return 1 + __gvm_find_file_upwards "$@" } - __gvmp_find_current_path_for_target() { local target="${1}" local start_dir="$PWD" @@ -106,4 +60,4 @@ __gvmp_find_current_path_for_target() { echo ""; return 1 -} \ No newline at end of file +} diff --git a/scripts/function/resolve_project_version b/scripts/function/resolve_project_version index 8e14c93..5a37946 100644 --- a/scripts/function/resolve_project_version +++ b/scripts/function/resolve_project_version @@ -1,5 +1,8 @@ #!/usr/bin/env bash +# source once and only once! +[[ ${GVM_RESOLVE_PROJECT_VERSION:-} -eq 1 ]] && return || readonly GVM_RESOLVE_PROJECT_VERSION=1 + . "$GVM_ROOT/scripts/function/_shell_compat" || return 1 # __gvm_find_file_upwards() @@ -135,9 +138,13 @@ __gvm_read_dot_go_version_file() { _line="${_line#"${_line%%[![:space:]]*}"}" _line="${_line%"${_line##*[![:space:]]}"}" [[ -z "${_line}" ]] && continue + # Accept go1.22.12 and ecosystem-style plain 1.22.12. if __gvm_rematch "${_line}" '^(go([0-9]+(\.[0-9]+)*([a-z0-9]+)?))$'; then version="${GVM_REMATCH[1]}" break + elif __gvm_rematch "${_line}" '^([0-9]+(\.[0-9]+)*([a-z0-9]+)?)$'; then + version="go${GVM_REMATCH[1]}" + break fi done < "${filepath}" @@ -230,7 +237,14 @@ __gvm_list_available_go_tags() { local versions="" local version="" - if ! versions="$(git ls-remote -t https://github.com/golang/go 2>/dev/null | awk -F/ '{ print $NF }')"; then + # No repo-wide timeout helper; use git HTTP low-speed abort (portable vs `timeout(1)`). + if ! versions="$( + GIT_TERMINAL_PROMPT=0 git \ + -c http.lowSpeedLimit=1024 \ + -c http.lowSpeedTime=30 \ + ls-remote -t https://github.com/golang/go 2>/dev/null | + awk -F/ '{ print $NF }' + )"; then echo "" return 1 fi @@ -323,16 +337,18 @@ __gvm_resolve_project_go_version() { if path="$(__gvm_find_file_upwards ".go-version")"; then hint="$(__gvm_read_dot_go_version_file "${path}")" || hint="" if [[ -n "${hint}" ]]; then + # .go-version is authoritative once parsed — never fall through to go.mod. mapped="$(__gvm_map_version_hint_to_tag "${hint}" "${scope}")" || mapped="" if [[ -n "${mapped}" ]]; then echo "${mapped}" return 0 fi - # Exact pin from .go-version: return as-is for install attempts. if [[ "${scope}" == "available" ]]; then echo "${hint}" return 0 fi + echo "" + return 1 fi fi diff --git a/tests/gvm_use_gomod_comment_test.sh b/tests/gvm_use_gomod_comment_test.sh index 688bb45..dc675a0 100644 --- a/tests/gvm_use_gomod_comment_test.sh +++ b/tests/gvm_use_gomod_comment_test.sh @@ -1,11 +1,12 @@ -source $GVM_ROOT/scripts/gvm +source "$GVM_ROOT/scripts/gvm" ## Requires go1.22.12 from 00gvm_install_comment_test.sh ## Note: tf runs each line as its own command — never use multi-line quotes/heredocs. -mkdir -p /tmp/gvm2-mod-detect-$$/subdir -printf '%s\n' 'module example.com/t' '' 'go 1.22' > /tmp/gvm2-mod-detect-$$/go.mod -cd /tmp/gvm2-mod-detect-$$/subdir +MODDIR=$(mktemp -d "${TMPDIR:-/tmp}/gvm2-mod-detect.XXXXXX") +mkdir -p "$MODDIR/subdir" +printf '%s\n' 'module example.com/t' '' 'go 1.22' > "$MODDIR/go.mod" +cd "$MODDIR/subdir" # status=0 gvm use # status=0; match=/Now using version go1\.22\./ go version # status=0; match=/go1\.22\./ cd / -rm -rf /tmp/gvm2-mod-detect-$$ +[[ -n "$MODDIR" && "$MODDIR" == "${TMPDIR:-/tmp}"* ]] && rm -rf "$MODDIR"