Skip to content

fix(project): resolve Go pins past unrelated tool manifests - #44

Open
johnny4young wants to merge 3 commits into
mainfrom
codex/project-manifest-fallback
Open

johnny4young wants to merge 3 commits into
mainfrom
codex/project-manifest-fallback

Conversation

@johnny4young

@johnny4young johnny4young commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

In a multi-language repository, .tool-versions often lists only Ruby, Node or Python, next to a valid go.mod. The resolver used to stop at that file and report no project version, and a child directory's unrelated .tool-versions hid a parent's Go pin. Fixing only the resolver would have left a second bug: Bash/Zsh auto-switching cached the first manifest it found, so edits to the real fallback go.mod or the parent pin never refreshed PATH.

What changed

  • Resolver (_gos_read_tool_versions_file / _gos_resolve_project_version) uses a three-way status:
    • 0: a Go pin was found;
    • 1: the file is readable but has no go/golang entry, so resolution continues to go.mod and then parent directories;
    • 2: the file is unreadable, or an explicit go/golang entry has no version, so resolution fails closed.
  • Auto-switch hook (__gos_auto_switch): the pure-shell prompt snapshot includes every manifest consulted until the effective Go pin is found. Edits to the unrelated file, the go.mod or the parent pin all invalidate the cache, and the subprocess-free fast path for an unchanged prompt is kept.
  • Field splitting of .tool-versions lines now uses parameter expansion (no globbing; see follow-ups).
  • README precedence docs, a CHANGELOG entry, and regressions in tests/project-env.bash covering:
    • same-directory and parent fallback;
    • explicit precedence;
    • a missing version and an unreadable file;
    • a glob entry;
    • in-place edits in Bash and Zsh;
    • preservation of the user's regex match.

Behavior change (documented)

  • A golang (or go) line without a version used to be skipped, so a later golang X line in the same file could still win. Now the first go/golang entry decides, and a bare one fails closed instead of silently picking another toolchain. This matches asdf's first-match semantics.
  • A .tool-versions file with no Go entry no longer makes gos use / gos run -- fail. Resolution continues to go.mod and then parent directories.

Why

Repositories that pin other runtimes with asdf/mise would otherwise get "no version found" despite a valid go.mod, and their prompt would keep a stale PATH.

How it was tested

  • tests/project-env.bash: the fallback regression fails against the original gos.sh. Both new follow-up regressions also fail without their fixes.
  • Full suite on head 4362759, as a non-root user with zsh 5.9 installed (so the Zsh hook path runs): 31/31 passed.
  • ShellCheck 0.11.0, shfmt 3.13.1, bash -n, scripts/sync-command-surfaces.bash --check and git diff --check are clean.
  • Manual checks of the new splitting:
    • golang 1.22.1 → 1.22.1;
    • golang<TAB>1.21.0 extra → 1.21.0;
    • go go1.20.3 → 1.20.3;
    • nodejs 20 → no pin;
    • bare golang → fails;
    • golang * → rejected as '*' instead of being expanded.
  • Hosted CI on 4362759: all 10 jobs and all 11 checks green, including native macOS Bash 3.2, Linux Fish and Windows Bash/PowerShell. Previous qualification: run 37359410297 on d46d0e2.

Review follow-ups

  • bd13b8d fix(env): keep the auto-switch hook from clobbering regex matches. The hook runs in the user's interactive shell on every prompt. Detecting a Go line with [[ =~ ]] overwrote the user's BASH_REMATCH (and Zsh's MATCH/match, and needed zsh/pcre under RE_MATCH_PCRE). It now uses an equivalent case on the left-trimmed line. The test asserts the user's match survives in Bash and Zsh.
  • 4362759 fix(project): split .tool-versions fields without pathname expansion. The unquoted set -- $line also globbed, so with golang * and a stray file named 1.21.6 in the caller's working directory, gos selected go1.21.6. Fields are now split with parameter expansion, which is subprocess-free and safe on Bash 3.2 (this resolver always runs under Bash). The regression uses that exact decoy file.

Decisions taken

  • Fail closed on an unreadable file or an incomplete explicit Go entry, rather than falling through to another manifest.
  • The prompt snapshot errs toward over-invalidation: treating a line as a Go pin when the resolver wouldn't can only cause an extra re-resolve, never a stale PATH.

Known limitations & follow-ups

Merge order

Independent of the other open PRs. It merges cleanly with #45, #46, #47, #48 and #49. Builds on the already merged #42 and #43.

- Preserve explicit and unreadable Go pin failures
- Track fallback manifest changes in the Bash and Zsh prompt cache
- Cover local and parent manifests with offline regression tests
__gos_auto_switch runs in the user's interactive shell on every prompt.
Detecting a Go entry with [[ =~ ]] overwrote the user's BASH_REMATCH (and
zsh's MATCH/match, or required zsh/pcre under RE_MATCH_PCRE). Use an
equivalent case pattern instead and cover the user's match surviving in
both Bash and Zsh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExmnfUngrDYjAheGvYwMnn

@johnny4young johnny4young left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: resolve Go pins past unrelated tool manifests

What it does: _gos_read_tool_versions_file now returns 0 for a Go pin, 1 for "readable, no Go entry", and 2 for an unreadable file or a go/golang entry without a version. _gos_resolve_project_version continues to go.mod and parent directories only on status 1. The Bash/Zsh prompt hook (__gos_auto_switch) applies the same rule when it builds its cache-invalidation snapshot, and now records every manifest it walked past, not just the last one.

Verdict: ready after the fix I pushed.

Fixed (pushed bd13b8d)

  • 🟠 gos.sh __gos_auto_switch: the hook detected a Go line with [[ $line =~ $gos_tool_pattern ]]. The hook runs in the user's interactive shell on every prompt, so this overwrote the user's BASH_REMATCH and, in Zsh, MATCH/match/MBEGIN/…. Zsh users with setopt RE_MATCH_PCRE would also need zsh/pcre on every prompt. Repro: [[ "user value" =~ (user) ]], then a prompt in a dir whose .tool-versions has golang 1.2.3, gives BASH_REMATCH[0] = golang. I replaced it with an equivalent subprocess-free case on the left-trimmed line: go | golang | go[[:space:]]* | golang[[:space:]]*. It gives the same results as the regex for golang 1.2, leading spaces/tabs, a bare go/golang, golang\r, go-task, gopls and # golang in both bash 5.2 and zsh 5.9. tests/project-env.bash now asserts that the user's match survives in both shells, and that test fails without the fix.
    • Validation: full suite as a non-root user gave 31/31 passed (with zsh installed, so the Zsh hook path ran). ShellCheck 0.11.0, shfmt 3.13.1, bash -n and sync-command-surfaces --check were clean. CI will re-run for macOS Bash 3.2. The constructs (${v%%[[:space:]]*}, case bracket classes) are Bash 3.2-safe.

Checked and fine

  • if version=$(…); then …; else [ "$?" -eq 1 ] || return 1; fi: $? in the else branch is the condition's status, so 2 correctly fails closed. The while loop's normal status is 0 (its last body command is a case), so || return 2 triggers only on a redirect failure. Unreadable files and an explicit golang with no version are both covered by tests.
  • Cache key: a non-Go .tool-versions file is included in the snapshot and the walk continues, so edits to it or to the go.mod/parent pin invalidate the cache. Treating a line as a Go pin when the resolver wouldn't can only over-invalidate, never serve a stale result.
  • go.mod parsing is untouched (toolchain wins over go, first go directive only).

Notes / nits

  • 🟡 Behavior change worth a changelog word: previously a bare golang line was skipped and a later golang X line in the same file could still win. Now the first go/golang entry decides and a bare one fails. That matches asdf's first-match semantics, and the README already says it fails.
  • 🟡 Pre-existing: _gos_read_tool_versions_file field-splits with set -- $line without set -f, so a line such as golang * is pathname-expanded against $PWD. Harmless in practice, but read -r tool version _ <<<"$line" (or a local set -f) would avoid the glob.

Generated by Claude Code

The unquoted `set -- $line` split also globbed, so an entry such as
`golang *` expanded against the caller's working directory: a stray
file named 1.21.6 there selected go1.21.6. Split the fields with
parameter expansion instead, which is subprocess-free and Bash
3.2-safe, and cover the decoy-file case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExmnfUngrDYjAheGvYwMnn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants