Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

### Fixed

- Project resolution skips `.tool-versions` files that only pin other languages, preserving `go.mod` and parent Go pins. Bash/Zsh auto-switching tracks both the unrelated manifest and the selected Go manifest so edits in the same directory invalidate the prompt cache. Incomplete explicit Go entries still fail closed, and the first `go`/`golang` entry decides (a bare entry no longer lets a later line win). `.tool-versions` fields are no longer pathname-expanded, so `golang *` cannot select a version from file names in the working directory.
- Windows uninstall validates ownership, removes only known installed files, and preserves unrelated contents instead of recursively deleting the target directory. Existing installations without a receipt remain supported, with their ambiguous `LICENSE` left in place.
- Windows installs stage replacements on the destination filesystem and restore previous files on publication failure. Failed fresh installs remove partial payloads; failed restoration keeps recovery files and reports their location.
- Windows PATH edits refresh a stale process even when the registry already contains gos, remove process entries on uninstall, and preserve registry value types and unrelated entries. Tests use registry substitutes and a disposable native Windows key.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,10 @@ gos updated: v1.9.0 -> v1.10.0

`gos use` searches from the current directory upward. At each directory level it
prefers `.go-version`, then `.tool-versions` entries named `golang` or `go`,
then a `toolchain goX.Y.Z` directive in `go.mod`, then the `go X.Y` directive.
then a `toolchain goX.Y.Z` directive in `go.mod`, then the `go X.Y` directive. A
`.tool-versions` file that only lists other languages is skipped, so `go.mod`
and parent Go pins still apply. An explicit Go entry with a missing version
fails instead of silently selecting a different toolchain.

```bash
gos pin 1.24.1 # writes .go-version
Expand Down
67 changes: 52 additions & 15 deletions gos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2019,22 +2019,31 @@ _gos_read_tool_versions_file() {
local file="$1" line tool version
while IFS= read -r line || [ -n "$line" ]; do
line=${line%%#*}
line=$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
# Trim with parameter expansion, not a sed subprocess per line: the
# resolver can now read a .tool-versions at every directory level.
line="${line#"${line%%[![:space:]]*}"}"
[ -z "$line" ] && continue

# shellcheck disable=SC2086 # Intentional field split: .tool-versions is whitespace-delimited.
set -- $line
tool="${1:-}"
version="${2:-}"
# Split the whitespace-delimited fields with parameter expansion, not an
# unquoted `set -- $line`: field splitting would also glob, so an entry
# such as `golang *` must never expand against the current directory.
tool="${line%%[[:space:]]*}"
version="${line#"$tool"}"
version="${version#"${version%%[![:space:]]*}"}"
version="${version%%[[:space:]]*}"
case "$tool" in
go | golang)
[ -n "$version" ] || continue
version="${version#go}"
if [ -z "$version" ]; then
_gos_error "${file}: the ${tool} entry has no version."
return 2
fi
printf '%s\n' "$version"
return 0
;;
esac
done <"$file"
done <"$file" || return 2
# A readable multi-tool manifest without Go is not a Go version pin.
return 1
}

Expand Down Expand Up @@ -2078,9 +2087,14 @@ _gos_resolve_project_version() {

candidate="${dir%/}/.tool-versions"
if [ -f "$candidate" ]; then
version=$(_gos_read_tool_versions_file "$candidate") || return 1
printf '%s|%s\n' "$version" "$candidate"
return 0
if version=$(_gos_read_tool_versions_file "$candidate"); then
printf '%s|%s\n' "$version" "$candidate"
return 0
else
# Status 1 means no Go entry: keep looking in go.mod and parents.
# An unreadable file or explicit incomplete Go entry must still fail.
[ "$?" -eq 1 ] || return 1
fi
fi

candidate="${dir%/}/go.mod"
Expand Down Expand Up @@ -4008,14 +4022,37 @@ __gos_auto_switch() {
# directory still invalidates the cached PATH decision.
local gos_version gos_bin gos_key gos_manifest="" gos_manifest_line
local gos_dir="$PWD" gos_candidate gos_installed="$GOS_VERSIONS_DIR"
local gos_has_pin
while :; do
for gos_candidate in "${gos_dir%/}/.go-version" "${gos_dir%/}/.tool-versions" "${gos_dir%/}/go.mod"; do
if [ -f "$gos_candidate" ]; then
gos_manifest="${gos_candidate}"$'\n'
while IFS= read -r gos_manifest_line || [ -n "$gos_manifest_line" ]; do
gos_manifest="${gos_manifest}${gos_manifest_line}"$'\n'
done <"$gos_candidate" 2>/dev/null || gos_manifest="unreadable:${gos_candidate}"
break 2
gos_manifest="${gos_manifest}${gos_candidate}"$'\n'
gos_has_pin=true
[ "${gos_candidate##*/}" != ".tool-versions" ] || gos_has_pin=false
# The group owns the 2>/dev/null: a redirection listed after the
# failing <"$gos_candidate" would not silence its error, which would
# then print on every prompt for an unreadable manifest.
if {
while IFS= read -r gos_manifest_line || [ -n "$gos_manifest_line" ]; do
gos_manifest="${gos_manifest}${gos_manifest_line}"$'\n'
# Only an unrelated-so-far .tool-versions needs the Go-entry scan.
# A case pattern, not [[ =~ ]]: this runs in the user's interactive
# shell on every prompt and must not clobber BASH_REMATCH or zsh's
# MATCH/match (or depend on zsh's RE_MATCH_PCRE module).
if [ "$gos_has_pin" = false ]; then
case "${gos_manifest_line#"${gos_manifest_line%%[![:space:]]*}"}" in
go | golang | go[[:space:]]* | golang[[:space:]]*) gos_has_pin=true ;;
esac
fi
done <"$gos_candidate"
} 2>/dev/null; then
# Include unrelated .tool-versions files in the snapshot, but keep
# following the resolver to the actual Go pin. Either can change.
[ "$gos_has_pin" = false ] || break 2
else
gos_manifest="${gos_manifest}unreadable:${gos_candidate}"
break 2
fi
fi
done
[ "$gos_dir" = "/" ] && break
Expand Down
91 changes: 91 additions & 0 deletions tests/project-env.bash
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,60 @@ GOS_TEST_VERSIONS_DIR="${case_dir}/versions" run_gos "$case_dir" bash "$script"
[ "$output" = "1.21" ] || fail "__project-version should keep an ambiguous bare minor, got: ${output}"
pass "__project-version resolves a bare go.mod minor against installed versions"

# Multi-language repositories commonly pin Ruby/Node/Python with asdf while
# Go comes from go.mod or a parent pin. A manifest without Go is not a pin.
case_dir="${test_root}/project-tool-versions-fallback"
mkdir -p "${case_dir}/project/child"
printf 'nodejs 24.0.0\npython 3.13.0\n' >"${case_dir}/project/.tool-versions"
printf 'module example.com/multitool\n\ngo 1.20\ntoolchain go1.21.6\n' >"${case_dir}/project/go.mod"
run_gos "$case_dir" bash "$script" __project-version "${case_dir}/project"
assert_status 0 "$status" 'non-Go tool manifest fallback' "$output"
[ "$output" = "1.21.6" ] || fail "non-Go tool manifest masked go.mod: ${output}"
printf 'ruby 3.3.0\n' >"${case_dir}/project/child/.tool-versions"
run_gos "$case_dir" bash "$script" __project-version "${case_dir}/project/child"
[ "$output" = "1.21.6" ] || fail "non-Go tool manifest masked a parent Go pin: ${output}"
printf 'golang 1.20.0\n' >"${case_dir}/project/.tool-versions"
run_gos "$case_dir" bash "$script" __project-version "${case_dir}/project"
[ "$output" = "1.20.0" ] || fail "explicit Go tool version lost precedence: ${output}"
printf 'golang\n' >"${case_dir}/project/.tool-versions"
run_gos "$case_dir" bash "$script" use --print "${case_dir}/project"
assert_nonzero_status "$status" 'incomplete Go entry must not fall back' "$output"
assert_contains "$output" 'golang entry has no version' 'incomplete Go entry names the cause'
[ ! -s "${case_dir}/urls.log" ] || fail 'incomplete Go entry must stay offline'
# The first Go entry decides: a bare entry must not let a later line win, and
# a `go` prefix that leaves nothing behind is just as incomplete.
printf 'golang\ngo 1.20.0\n' >"${case_dir}/project/.tool-versions"
run_gos "$case_dir" bash "$script" use --print "${case_dir}/project"
assert_nonzero_status "$status" 'a bare first Go entry must not defer to a later line' "$output"
assert_not_contains "$output" '1.20.0' 'a later Go entry must not win over a bare first one'
printf 'go go\n' >"${case_dir}/project/.tool-versions"
run_gos "$case_dir" bash "$script" use --print "${case_dir}/project"
assert_nonzero_status "$status" 'a go-prefix-only Go entry is incomplete' "$output"
assert_contains "$output" 'go entry has no version' 'go-prefix-only entry names the cause'
# Fields are split without pathname expansion: a stray file named like a
# version in the caller's directory must not turn `golang *` into a pin.
mkdir -p "${case_dir}/decoy"
: >"${case_dir}/decoy/1.21.6"
printf 'golang *\n' >"${case_dir}/project/.tool-versions"
cd "${case_dir}/decoy"
run_gos "$case_dir" bash "$script" use --print "${case_dir}/project"
cd "$OLDPWD"
assert_nonzero_status "$status" 'a glob version must not expand against the working directory' "$output"
assert_not_contains "$output" '1.21.6' 'tool versions fields are not pathname-expanded'
# run_gos truncates urls.log on every call, so check after each use --print.
[ ! -s "${case_dir}/urls.log" ] || fail 'a glob Go entry must stay offline'
printf 'ruby 3.3.0\n' >"${case_dir}/project/.tool-versions"
chmod 000 "${case_dir}/project/.tool-versions"
if [ ! -r "${case_dir}/project/.tool-versions" ]; then
run_gos "$case_dir" bash "$script" use --print "${case_dir}/project"
assert_nonzero_status "$status" 'unreadable tool manifest must not fall back' "$output"
else
pass 'unreadable tool manifest check skipped: this host can read mode 000 files'
fi
chmod 600 "${case_dir}/project/.tool-versions"
[ ! -s "${case_dir}/urls.log" ] || fail 'manifest fallback must remain offline'
pass 'project resolution ignores unrelated tool versions but preserves explicit Go pins'

case_dir="${test_root}/env-auto"
mkdir -p "${case_dir}/project" "${case_dir}/missing" "${case_dir}/versions/go1.21.6/bin" "${case_dir}/versions/go1.20.0/bin" "${case_dir}/bin"
printf '1.21.6\n' >"${case_dir}/project/.go-version"
Expand Down Expand Up @@ -190,6 +244,28 @@ rm .go-version
printf 'module example.com/edit\n\ngo 1.21\n' >go.mod
__gos_auto_switch
case "$GOS_AUTO_BIN" in */go1.21.6/bin) ;; *) exit 12 ;; esac
# An unrelated asdf file must not hide the go.mod used by the resolver or
# keep its contents out of the prompt cache's invalidation snapshot.
printf 'ruby 3.3.0\n' >.tool-versions
__gos_auto_switch
case "$GOS_AUTO_BIN" in */go1.21.6/bin) ;; *) exit 16 ;; esac
printf 'module example.com/edit\n\ngo 1.20\n' >go.mod
__gos_auto_switch
case "$GOS_AUTO_BIN" in */go1.20.0/bin) ;; *) exit 17 ;; esac
# The hook runs in the user's interactive shell on every prompt, so scanning
# a Go entry must not clobber the user's last regex match.
[[ user-match =~ user-match ]]
printf 'golang 1.21.6\n' >.tool-versions
__gos_auto_switch
case "$GOS_AUTO_BIN" in */go1.21.6/bin) ;; *) exit 18 ;; esac
if [ -n "${ZSH_VERSION:-}" ]; then
[ "${MATCH:-}" = user-match ] || exit 21
else
[ "${BASH_REMATCH[0]:-}" = user-match ] || exit 21
fi
rm .tool-versions
printf 'module example.com/edit\n\ngo 1.21\n' >go.mod
__gos_auto_switch
# Installing a second matching patch makes this bare minor ambiguous.
mkdir -p "$GOS_VERSIONS_DIR/go1.21.7/bin"
cat "$GOS_VERSIONS_DIR/go1.21.6/bin/go" >"$GOS_VERSIONS_DIR/go1.21.7/bin/go"
Expand All @@ -205,6 +281,21 @@ case "$GOS_AUTO_BIN" in */go1.20.0/bin) ;; *) exit 13 ;; esac
rm go.mod
__gos_auto_switch
[ -z "${GOS_AUTO_BIN:-}" ]
# A child manifest with no Go must also observe edits to a parent Go pin.
mkdir -p nested
printf 'ruby 3.3.0\n' >nested/.tool-versions
printf '1.21.6\n' >.go-version
cd nested
__gos_auto_switch
case "$GOS_AUTO_BIN" in */go1.21.6/bin) ;; *) exit 19 ;; esac
printf '1.20.0\n' >../.go-version
__gos_auto_switch
case "$GOS_AUTO_BIN" in */go1.20.0/bin) ;; *) exit 20 ;; esac
cd ..
rm .go-version nested/.tool-versions
rmdir nested
__gos_auto_switch
[ -z "${GOS_AUTO_BIN:-}" ]
if [ -n "${ZSH_VERSION:-}" ]; then
case " ${precmd_functions[*]} " in *" __gos_auto_switch "*) ;; *) exit 14 ;; esac
fi
Expand Down
Loading