Skip to content

fix: refresh the env cache on legacy file, install, link and config changes; keep project versions off the global link and version - #748

Merged
linghengqian merged 6 commits into
version-fox:mainfrom
thelastbackspace:fix/745-legacy-file-env-cache
Nov 6, 2026
Merged

linghengqian merged 6 commits into
version-fox:mainfrom
thelastbackspace:fix/745-legacy-file-env-cache

Conversation

@thelastbackspace

@thelastbackspace thelastbackspace commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

vfox env reuses its cached output when the project config path, PATH and the mtimes of the .vfox.toml files are unchanged. A lot of what the output depends on isn't in that list: legacy version files like .nvmrc (read into the project scope by loadToolChain), the directory whose .vfox/sdks the project tools are linked into, what is installed, and whether the links still exist. So a cd into or out of a .nvmrc directory kept the old output, and so did vfox install --all, vfox add, or an rm -rf .vfox, until you cd'd out and back.

The cached output is now stored under a resolution key that covers those inputs, compared at every prompt like CachedPathPolicy. No plugin is loaded for it; a cache hit costs the same as before plus a few stats per tool.

Changes:

  • ConfigState gets a CachedResolutionKey field, with ResolutionKeyChanged / SetResolutionKey. The key has three parts:
    • projectToolsKey: the project SDK directory followed by one name = MarshalInline() line per project tool in SortedKeys() order, or empty when the project has no tools (so a cd between plain directories still hits the cache).
    • installsStamp: for each tool of chain.GetAllTools() (lower-cased), whether <plugins>/<name> exists and the mtime of <installs>/<name> (or <installs>/cache/<name>, the sdk.NewSdk fallback). The plugin only counts by presence, since a plugin can write into its own directory.
    • linksStamp: which of <link dir>/<name> exist in the project, session and global link directories. This one is taken again after the slow path, since the slow path makes the links.
  • HasChanged compares config mtimes with != and stores UnixNano(), so a config replaced by an older file (mv of a backup, cp -p, rsync -t, an unpacked archive) or edited within the stored second counts as changed. A state file written in seconds just gets rewritten once.
  • GetLinkDirPathByScope maps the project scope to the session link directory when the project link directory is the global one (working directory = home). Before, env or use -p there repointed the global links every other shell uses. The check is sameDir: equal cleaned paths, or else os.SameFile on both, so a working directory or $HOME that reaches the home directory through a symlink (or another case on a case-insensitive volume) is caught too.
  • vfox uninstall only unsets and switches the global version when the global config asks for the version being removed (in sdk.Uninstall and uninstallCmd). Before, it went by Current(), so removing a version only the project asked for rewrote the global config.
  • AGENTS.md: the note on the cached env output mentions the project key, the installs and links stamps and the nanosecond mtime compare, and the link directory line says project links in the home directory go to the session directory. The scope tables in docs/guides/quick-start.md and docs/zh-hans/guides/quick-start.md get a line saying the same.

Testing (on 8d2b46d, the head of this branch):

  • cmd/commands/env_test.go:
    • TestEnvCacheFollowsLegacyVersionFiles: plain dir -> .legacy-version dir -> plain -> back, an in-place edit of the file, then two directories with the same legacy file, each exporting through its own .vfox/sdks.
    • TestEnvCacheFollowsInstalls: the project asks for alpha 2.0.0, which isn't installed. Installing it is seen at the next run, and so is removing it again. On 5c2e6a4 this fails with after installing 2.0.0: ALPHA_VERSION = 1.0.0, want 2.0.0.
    • TestEnvCacheFollowsInstallsInTheFallbackLayout: the same with alpha under cache/cache/alpha. With the fallback path dropped from installsStamp it fails with after installing 2.0.0 into the fallback layout: ALPHA_VERSION = 1.0.0, want 2.0.0.
    • TestEnvCacheFollowsAddedPlugins: adding the plugin of a project tool puts it on the next PATH.
    • TestEnvCacheRepairsRemovedLinks: removing <work>/.vfox, then the global sdks directory, and the next run makes the links again. On 5c2e6a4: the link .../project/.vfox/sdks/alpha was not made again.
    • TestEnvProjectInHomeKeepsGlobalLinks and TestEnvProjectInHomeThroughSymlinkKeepsGlobalLinks: a .vfox.toml in the home directory, reached by its own path or through a symlink, leaves the global alpha link alone. On 5c2e6a4 the first fails, on d862284 the symlink one fails, both with the global link of alpha was repointed from .../v-1.0.0/alpha-1.0.0 to .../v-2.0.0/alpha-2.0.0.
    • TestProjectToolsKey: unit test of the project part of the key.
    • The alpha plugin of the env fixture also exports ALPHA_VERSION, so TestEnvCacheRespectsTargetShell now checks the prefix of that export.
  • cmd/commands/uninstall_cli_test.go: TestUninstallLeavesTheGlobalVersionOfOtherScopes. With global 1.0.0 and project 3.0.0, uninstall alpha@3.0.0 leaves the global config alone (on 5c2e6a4 it becomes alpha = "2.0.0"). With global 2.0.0, uninstall alpha@2.0.0 still prints Auto switch and ends on 1.0.0.
  • internal/sdk/link_test.go: TestUninstallFollowsTheGlobalVersion, global on 1.0.0 and project on 2.0.0. Uninstall("1.0.0") leaves the global scope without the tool and the project on 2.0.0, Uninstall("2.0.0") leaves the global scope on 1.0.0. On 5c2e6a4 both fail (the global scope still asks for 1.0.0 after it was uninstalled, the global scope asks for "" after the project's version was uninstalled, want 1.0.0); with globalVersion() returning "" the first fails.
  • internal/env/link_dir_test.go: TestGetLinkDirPathByScopeProjectInHome, the home directory, a symlink to it, and another directory. On d862284 the symlink case fails with the home directory through a symlink: project link directory = .../home-link/.vfox/sdks, want .../session.
  • internal/env/state_test.go: TestConfigState_ResolutionKeyChanged (round trip through the state file) and TestConfigState_HasChanged_AnyOtherMtime, where the config mtime moves two days back and then one millisecond forward. On 5c2e6a4 that fails with older: changed = false, err = <nil>, want a change. TestConfigState_HasChanged_FileModified uses os.Chtimes instead of a 1.1 s sleep.
  • go test ./... passes on every package except internal/shared/printer, which waits for a key press without a TTY (same on main). go test -race ./cmd/commands ./internal/env ./internal/sdk passes, go vet ./... is clean and gofmt -l is clean on the changed files.

Fixes #745

…differently

The env cache only tracked the mtimes of .vfox.toml files and the project
path, so a directory with only a .nvmrc (or another legacy version file)
looked the same as one without it. cd'ing in or out, or editing the file in
place, kept serving the previous output.

Record the tools the project scope resolved to, legacy files included, and
treat a different set as a change, the same way the PATH policy is handled.

Fixes version-fox#745
@linghengqian linghengqian added this to the 1.0.14 milestone Nov 5, 2026
@codecov

codecov Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.04762% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.58%. Comparing base (4ddd721) to head (8d2b46d).

Files with missing lines Patch % Lines
internal/sdk/sdk.go 66.66% 2 Missing and 1 partial ⚠️
internal/env/context.go 83.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #748      +/-   ##
==========================================
+ Coverage   53.93%   55.58%   +1.65%     
==========================================
  Files         112      112              
  Lines       10387     8448    -1939     
==========================================
- Hits         5602     4696     -906     
+ Misses       4332     3293    -1039     
- Partials      453      459       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@linghengqian linghengqian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the PR. On 836749d the three cases of #745 come out right in a bash session with the nodejs plugin: .nvmrc and .node-version on the way in, the global version on the way out, and an edit or a removal of the file in place. (These runs have forget_env_state of plugin 0.5.4 switched off, as in 0.5.3. It deletes env-state.json from ParseLegacyFile, which hides the cache on the way in and cannot help on the way out.) The tests added in 836749d cover the helpers and the key: of five mutations (a no-op SetProjectTools, a renamed JSON tag, a ProjectToolsChanged that is always false, a key of all scopes, a key without versions) each is caught. One case is still wrong and two things around the change are missing.

  1. The key does not contain the directory. ProjectSdkDir is <cwd>/.vfox/sdks (internal/pathmeta/path_meta.go:135), and a project-scope tool is exported through it: the plugin's EnvKeys gets that link path and PATH gets <cwd>/.vfox/sdks/<sdk>/bin. A legacy tool has no unlink attribute, so it takes the project route too (cmd/commands/env.go:246). For a .vfox.toml the directory is part of configPaths[Project]. For a legacy file nothing carries it, so two directories whose legacy files resolve to the same tools have the same key, and the second one is served the output of the first.

    With .nvmrc = 18.20.8 in two directories a and b, after cd a, cd ../b and a few prompts, command -v node in b prints .../a/.vfox/sdks/nodejs/bin/node, and b/.vfox is never created. Two directories with a .vfox.toml print .../b/.vfox/sdks/.... Seen from b:

    • node: command not found once a/.vfox is gone (git clean -fdx, or the project is deleted)
    • node -v prints v20.20.2 after a/.nvmrc was changed to 20.20.2 and vfox ran in a from another terminal, while b/.nvmrc still says 18.20.8

    This is the "directory it is read from" input that the issue lists next to the version file, so Fixes #745 only holds once it is in.

    A test that fails on 836749d: let the plugin of TestEnvCacheFollowsLegacyVersionFiles also return {key="LEGACY_PATH", value=ctx.path}, add a second directory with the same .legacy-version, and go dir, other, dir, other with three runs each. The first run in the second directory fails with

    LEGACY_PATH in .../with-legacy-too = .../with-legacy/.vfox/sdks/legacy, want .../with-legacy-too/.vfox/sdks/legacy
    

    Putting runtimeEnvContext.PathMeta.Working.ProjectSdkDir in front of the tools in the key fixes it (only when the project scope has tools, so a cd between plain directories still hits the cache). With that change this test, the existing ones and TestProjectToolsKey (which then takes the directory as a second argument) pass, the two shell cases above come out right, plain -> plain2 stays a cache hit, and a .nvmrc directory settles to cache hits after two runs. The comment in envFlag and the one on CachedProjectTools then need to name the directory as well.

  2. AGENTS.md still describes the old key. Line 113 says the cached env output is also keyed by the PATH policy, because config.yaml is not among the files whose modification time is tracked. The project scope is a second input of that kind, since a legacy file is not tracked either. #736 changed that sentence in the commit that added CachedPathPolicy.

  3. projectToolsKey returns "" when MarshalTOML fails, which is the key of "no tools" and would let a stale output stand. Tools.MarshalTOML never returns an error (internal/pathmeta/vfox_toml.go:108-121), so the branch is unreachable, and it is the one line of projectToolsKey that no test covers, TestProjectToolsKey included. Building the key from SortedKeys() and MarshalInline() removes the branch.

The rest checks out on 836749d. The first test fails on main at the first cd, and each of four mutations of the change (no SetProjectTools call, no ProjectToolsChanged check, a key of tool names without versions, an empty key) is caught by it. go vet and gofmt are clean on the changed files. go test passes on every package except internal/shared/printer, which waits for a key press without a TTY (also on main), and -race passes on cmd/commands and internal/env.

Two directories whose legacy version files resolve to the same tools got
the same key, so the second one was served the first one's .vfox/sdks
paths. Put ProjectSdkDir in front of the project tools, build the key from
SortedKeys and MarshalInline, and note the project scope in AGENTS.md.
@thelastbackspace

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, and for the shell repro with two .nvmrc dirs, I hadn't thought about the link path at all.

  1. The key now starts with ProjectSdkDir when the project scope has tools, so plain -> plain2 still hits the cache. TestProjectToolsKey takes the dir and checks that another dir gives another key, and TestEnvCacheFollowsLegacyVersionFiles exports LEGACY_PATH and goes dir, other, dir, other with three runs each. It fails on 836749d with LEGACY_PATH in .../with-legacy-too = .../with-legacy/.vfox/sdks/legacy and passes now. Updated both comments.
  2. AGENTS.md now says the cached output is also keyed by the project scope, since legacy files aren't tracked either.
  3. Dropped the MarshalTOML error branch, the key is built from SortedKeys() and MarshalInline() now.

@linghengqian linghengqian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The three points of the last review are all in and check out on 5c2e6a4. Seven things are left: three inputs of the cached output that the key still does not cover (1 to 3), two defects found on the way, both on main as well (4 and 5), and the description and a comment (6 and 7).

  1. What is installed is not in the key. resolveInstalledToolConfig (cmd/commands/tool_resolution.go:75) takes the first configured version, project first, that is installed and falls back to the next scope otherwise, and a tool whose plugin is not added is skipped (internal/manager.go:93). vfox install and vfox add change both without touching a tracked file or PATH, so the cache serves output for a state that no longer holds, as it did for the version file and the directory.

    With a .vfox.toml that asks for nodejs = "19.9.0" (not installed) and 26.13.0 as the global version, a bash that cd's into the directory runs v26.13.0, silently. After vfox install --all --yes in that shell (exit 0, nodejs@19.9.0 reported as installed) every following prompt is a cache hit and node -v stays at v26.13.0 until a cd out and back in. An install by another process is not seen either, and main behaves the same. vfox use and vfox install <sdk>@<version> --use refresh because they write a config file; vfox install --all cannot take --use (cmd/commands/install.go:74) and prints no hint.

    The key needs a stamp of the installs: for each tool of chain.GetAllTools() (lower-cased), whether <plugins>/<name> exists and the mtime of <installs>/<name>, which moves when a version is installed or removed (<installs>/cache/<name> when that one is missing, the fallback of sdk.NewSdk, internal/sdk/sdk.go:1208-1219). It is compared at every prompt the way ProjectToolsChanged is, and no plugin is loaded for it. The plugin counts by presence only, since a plugin may write into its own directory and its mtime would invalidate the cache at every prompt.

    Tests that fail on 5c2e6a4: let the alpha plugin of the env fixture also export ALPHA_VERSION from ctx.main.version, put alpha = "2.0.0" (not installed) into the .vfox.toml of f.work, run three times feeding the PATH back, create cache/alpha/v-2.0.0/alpha-2.0.0/bin and move the mtime of cache/alpha a minute ahead (coarse clocks). The next run has to export 2.0.0, and 1.0.0 again after the directory is removed; on 5c2e6a4 it fails with after installing 2.0.0: ALPHA_VERSION = 1.0.0, want 2.0.0. A second test adds the plugin directory of a project tool after the first runs; there the PATH of the next run still lacks the tool.

  2. Whether the links exist is not in the key. The output points at <cwd>/.vfox/sdks/<sdk>, ~/.vfox/sdks/<sdk> or the session directory; a recompute makes them again, a cache hit does not look. After rm -rf .vfox in the project directory (what git clean -fdx does) the shell stays on the old output, node: command not found at every following prompt, until a cd out and back in. On main this happens in a .vfox.toml directory, on 5c2e6a4 in a .nvmrc directory as well.

    Whether <link dir>/<name> exists, for the three link directories (GetLinkDirPathByScope, internal/env/context.go:95), belongs to the stamp. This part has to be taken again after the slow path, which makes the links: taken before it, the second run in the same shell recomputes and TestEnvCacheRespectsTargetShell fails. Test: converge, remove <work>/.vfox, run once, and <work>/.vfox/sdks/alpha has to be there again; likewise for the global sdks directory. On 5c2e6a4: the link .../project/.vfox/sdks/alpha was not made again: lstat ...: no such file or directory. AGENTS.md line 113 needs a clause for 1 and 2: the cached output is also keyed by what is installed and by the links it points at.

  3. HasChanged compares mtime > storedMtime (internal/env/state.go:164) in whole seconds (getFileModTime, :257-262), so a config file replaced by one with an older time is not seen, and neither is an edit that leaves the mtime in the second that is stored. With the global config replaced by a file that says nodejs = "20.20.2" and has an mtime from 2020 (mv of a backup, cp -p, rsync -t and an unpacked archive do the same), every following prompt on 5c2e6a4 is a cache hit and node -v stays at v26.13.0. A project .vfox.toml replaced that way is caught on 5c2e6a4 only because the project tools are in the key now; on main it keeps the old version. Comparing with != and storing UnixNano() fixes it (a state file written with seconds differs once and is rewritten); internal/env/state_test.go:135 sleeps 1.1 s to get a different second. Test: Update, then os.Chtimes the file two days back, and again one millisecond forward; HasChanged has to be true both times, and on 5c2e6a4 it is not (older: changed = false, err = <nil>, want a change).

  4. A project config in the home directory shares its link directory with the global scope. ProjectSdkDir is <cwd>/.vfox/sdks (internal/pathmeta/path_meta.go:135) and GlobalSdkDir is <vfox home>/sdks (:137), ~/.vfox/sdks by default, the same directory when the working directory is the home directory, so what env or use -p links for the project there repoints the global link that every other shell uses. With 26.13.0 as the global version and ~/.nvmrc at 20.20.2, one terminal running vfox env in the home directory is enough: a second terminal in another directory, on a cache hit, then runs v20.20.2. vfox use -p nodejs@20.20.2 typed in the home directory does the same on main while the global config still says 26.13.0, and with this change a cd ~ reaches it for a .nvmrc as well. Mapping the project scope to the session link directory when the two are the same, in GetLinkDirPathByScope (internal/env/context.go:95), covers env and use (on Windows the comparison has to ignore case). Test: after a first run from the project directory, which links alpha to 1.0.0, set f.work to the home directory with a .vfox.toml asking for alpha = "2.0.0" and run again; on 5c2e6a4 the global link of alpha was repointed from .../v-1.0.0/alpha-1.0.0 to .../v-2.0.0/alpha-2.0.0.

  5. vfox uninstall of a version that only the project asks for rewrites the global version. Current() answers with the version of the highest-priority scope (internal/sdk/sdk.go:823), and Uninstall (:504-506) and uninstallCmd (cmd/commands/uninstall.go:64, 79-82) then act on the global scope: they unset it and set it to the first remaining version. With nodejs = "20.20.2" in the global config, nodejs = "19.9.0" in the project's .vfox.toml and 18.20.8, 20.20.2 and 26.13.0 also installed, vfox uninstall nodejs@19.9.0 in the project directory prints Unset nodejs successfully., Auto switch to the other version. and Now using nodejs@26.13.0., and the global config reads nodejs = "26.13.0" afterwards while 20.20.2 is still installed. Looking at the version the global scope asks for, in both places, fixes it, and uninstalling the global version keeps switching away from it. Tests with the CLI fixture and 1.0.0, 2.0.0, 3.0.0 on disk: global alpha = "1.0.0", project alpha = "3.0.0", and uninstall alpha@3.0.0 has to leave the global config as it was (on 5c2e6a4 it becomes alpha = "2.0.0"); with global alpha = "2.0.0", uninstall alpha@2.0.0 still prints Auto switch and leaves 1.0.0.

  6. The description still describes 1be3b7a. It says envFlag builds the key from the project toml's tools, "same serialization as Tools.MarshalTOML"; the key is now the project SDK directory followed by one name = MarshalInline() line per tool from SortedKeys(), without the [tools] header. The testing paragraph stops at the in-place edit of the first test and does not mention the second directory, TestProjectToolsKey or the state tests, and the Changes list has neither the directory nor the AGENTS.md line. It needs to be rewritten for the head that gets merged, listing what was run on that head.

  7. The comments of ProjectToolsChanged and SetProjectTools (internal/env/state.go:189-201) speak of "a different set of project tools" and "the project tools of the output", and the parameter is called tools, while the string is the project SDK directory followed by the tools (and, with 1 and 2, the stamp). Name CachedProjectTools and the two methods after the key, or say what the string holds.

With the changes sketched in 1 to 5 the tests above and the existing ones pass. In a bash session an install --all is seen at the next prompt, also when another process did the install; rm -rf .vfox is repaired at the next prompt; the replaced global config is seen; a project config in the home directory leaves the global link alone; uninstalling the project's version leaves the global version alone; and plain -> plain2 and a .nvmrc directory still settle to cache hits. A cache hit of vfox env takes about 9 to 10 ms with and without them, and both stamps together take about 0.11 ms for eight tools.

The rest checks out on 5c2e6a4. The test added for the directory fails on 836749d with the LEGACY_PATH line quoted in the reply, and each of five mutations of the new code (the call site passing an empty directory, projectToolsKey ignoring its directory, the directory kept when the project has no tools, tool names without versions, the ProjectToolsChanged check removed) is caught. In a bash session with the nodejs plugin (forget_env_state of 0.5.4 switched off, as in 0.5.3), node in the second .nvmrc directory comes from its own .vfox/sdks, also after the .vfox of the first directory is removed and after the first .nvmrc was edited from another terminal, and plain -> plain2 stays a cache hit. projectToolsKey and the state helpers are at 100% in go tool cover -func, go vet and gofmt are clean on the changed files, go test passes on every package except internal/shared/printer (it waits for a key press without a TTY, also on main), and -race passes on cmd/commands and internal/env.

…bugs

- Stamp the plugin presence and install directory mtime of every
  configured tool, and which links exist in the project, session and
  global link directories, into the resolution key.
- Compare config mtimes with != in nanoseconds so an older replacement
  or a same-second edit is seen.
- Use the session link directory for the project scope when it is the
  global one (working directory is home).
- Uninstall only switches the global version away when the global
  config asks for the removed version.
- Rename CachedProjectTools to CachedResolutionKey.
@thelastbackspace

Copy link
Copy Markdown
Contributor Author

Thanks again for such a careful review, I appreciate the repro steps for each one. All seven are in the new commit:

  1. Installs are in the key now (installsStamp): plugin presence plus the mtime of <installs>/<name>, falling back to cache/<name>. I added the two tests you described (install/remove 2.0.0, and adding a plugin).
  2. Link existence for the three link dirs is in the key too (linksStamp), taken again after the slow path. There's a test for <work>/.vfox and for the global sdks, and AGENTS.md mentions both stamps.
  3. HasChanged now compares with != and stores UnixNano(). Added the test with the two-days-back / +1ms Chtimes.
  4. I fixed this one here: GetLinkDirPathByScope uses the session dir for the project scope when it's the same as the global one (case-insensitive on Windows), with the home-directory test.
  5. Also fixed here: Uninstall and uninstallCmd look at the global version instead of Current(), and both uninstall cases have tests.
  6. I rewrote the description for the current head and listed what I ran on it.
  7. Renamed it to CachedResolutionKey / ResolutionKeyChanged / SetResolutionKey, and the comment now says what the string holds.

Each new test fails on 5c2e6a4 and passes now. go test (except printer), -race on cmd/commands and internal/env, vet and gofmt are all clean.

@linghengqian linghengqian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm. You might need to fix the CI.

@thelastbackspace

Copy link
Copy Markdown
Contributor Author

Sorry about that. TestEnvCacheRespectsTargetShell assumed the ALPHA_VERSION export comes first, but the exports aren't ordered, so on CI PATH came first. It now checks that the line is in the output instead of at the start. Ran it 20 times locally and the rest of the suite passes.

@linghengqian linghengqian left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The seven points of the last review are all in and check out on d862284. Four things are left: the guard for a project config in the home directory misses a case (1), the title (2), leftovers of the old behavior in a test and in the description (3), and two behaviors of the new code that no test pins (4).

  1. The guard for a project config in the home directory compares strings. samePath (internal/env/context.go:118) compares the cleaned paths and ignores case on Windows, so a working directory that reaches the home directory through a symlink, or a $HOME that is a symlink, is not recognized. <link>/.vfox/sdks and ~/.vfox/sdks are the same directory but not the same string, GetLinkDirPathByScope (:108) answers the project directory, and what env or use -p links for the project goes into the global link directory again.

    With 26.13.0 as the global version and ~/.nvmrc at 20.20.2, a second terminal that runs vfox env in a symlink to the home directory repoints the global nodejs link to 20.20.2, and the first terminal, on a cache hit, then runs v20.20.2. The same happens with $HOME set to the symlink and the working directory the real path. vfox use -p nodejs@20.20.2 typed in the symlink does it as well (Now using nodejs@20.20.2, while the global config still says 26.13.0).

    Comparing the directories themselves fixes it:

    func sameDir(a, b string) bool {
    	if filepath.Clean(a) == filepath.Clean(b) {
    		return true
    	}
    	ai, err := os.Stat(a)
    	if err != nil {
    		return false
    	}
    	bi, err := os.Stat(b)
    	if err != nil {
    		return false
    	}
    	return os.SameFile(ai, bi)
    }

    The text comparison first keeps paths that do not exist yet working as before. For the rest the spelling stops mattering: a symlink, another case on a case-insensitive volume, a drive letter in another case. It replaces samePath and its runtime.GOOS branch, whose case handling no test exercises (the home directory test passes the same string twice). AGENTS.md:122 says project links use <project>/.vfox/sdks; in the home directory they go to the session directory now, and the line needs to say so. The scope table in docs/guides/quick-start.md:287 and in docs/zh-hans/guides/quick-start.md:281 gives $PWD/.vfox/sdks for the project scope, so a line under each table has to say the same.

    Tests that fail on d862284: in internal/env, a context whose working directory is a symlink to the directory that holds GlobalSdkDir has to answer SessionSdkDir for GetLinkDirPathByScope(Project), and a working directory elsewhere its own .vfox/sdks; the symlink case fails with the home directory through a symlink: project link directory = .../home-link/.vfox/sdks, want .../session. In cmd/commands, TestEnvProjectInHomeKeepsGlobalLinks (env_test.go:547) once more with f.work a symlink to the home directory of the fixture; it fails with the global link of alpha was repointed from .../v-1.0.0/alpha-1.0.0 to .../v-2.0.0/alpha-2.0.0. Both skip when os.Symlink is not allowed. The first one also puts the guard under a test of its own package: Codecov lists 7 patch lines at 0% in internal/env/context.go, because CI runs go test ./... without -coverpkg and the cmd/commands tests do not count for that package.

  2. The title. The repo squashes a PR with several commits under the PR title, and the release notes are built from commit titles (changelog.use: github in .goreleaser.yaml, fix: goes under Bug Fixes). The title is still fix(env): invalidate cached output when legacy version files resolve differently, which is what the first commit did. The PR now also makes the cache follow installs, removed links and replaced config files, keeps a project config in the home directory off the global link, and makes vfox uninstall go by the version the global config asks for. Those are what users will notice, and #744 and #747 name their changes in the title. For example: fix: refresh the env cache on legacy file, install, link and config changes; keep project versions off the global link and version.

  3. Leftovers of the old behavior. internal/env/state_test.go:135 still sleeps 1.1 s under a comment that says the Unix timestamp is second precision, and the compare is in nanoseconds now. os.Chtimes on the file, a minute ahead, makes the change without the wait and without depending on the clock of the file system, as TestConfigState_HasChanged_AnyOtherMtime does; TestConfigState_HasChanged_FileModified then takes 0.00 s instead of 1.10 s. Line 53 stores .Unix() in GlobalMtime, which holds nanoseconds now. TestConfigState_ResolutionKeyChanged (:374-393) still speaks of project tools in its comment and in four messages, while the key also holds the installs and the links.

    In the description, the first bullet of Changes says ConfigState.CachedProjectTools is now CachedResolutionKey; neither exists on main, so the bullet describes a step of the branch and not the diff. The bullet on GetLinkDirPathByScope ("The comparison ignores case on Windows") changes with 1, and the Testing section has to list what was run on the head that gets merged.

  4. Two behaviors of the new code that no test pins.

    The first is the fallback of installsStamp to <installs>/cache/<name> (cmd/commands/env.go:398), the layout sdk.NewSdk falls back to for installs made by v1.0.2. With the second path removed from the loop every test passes. Test: take cache/alpha out of the env fixture, put 1.0.0 under cache/cache/alpha, ask for alpha = "2.0.0" in the project and converge (ALPHA_VERSION is 1.0.0), then create cache/cache/alpha/v-2.0.0/alpha-2.0.0/bin and move the mtime of cache/cache/alpha a minute ahead; the next run has to export 2.0.0. It passes on d862284 and fails without the fallback with after installing 2.0.0 into the fallback layout: ALPHA_VERSION = 1.0.0, want 2.0.0.

    The second is globalVersion() with the condition in Uninstall that uses it (internal/sdk/sdk.go:506). With globalVersion() returning "" every test passes: when another version is left, uninstallCmd rewrites the global config through Use(first, Global) on its own, and nothing looks at Uninstall itself. Test, in internal/sdk next to TestUninstallStillCleansInstalledVersions (link_test.go:346): 1.0.0 and 2.0.0 installed, the global scope on 1.0.0 and the project on 2.0.0. Uninstall("1.0.0") has to leave the global scope without the tool and the project at 2.0.0, and Uninstall("2.0.0") has to leave the global scope at 1.0.0. On main and on 5c2e6a4 both fail (the global scope still asks for 1.0.0 after it was uninstalled, the global scope asks for "" after the project's version was uninstalled, want 1.0.0), on d862284 both pass, and the first fails when globalVersion() returns "". Codecov lists 6 patch lines of internal/sdk/sdk.go without coverage (3 missing, 3 partial) for the same reason as in 1; this test is in the package that holds them.

With the changes sketched in 1, 3 and 4 the tests above and the existing ones pass, and go vet and gofmt -l are clean on the touched files. In a bash session a symlinked home directory leaves the global link alone for vfox env (the symlink as the working directory or as $HOME) and for vfox use -p (as the working directory). The key and the two stamps take about 23 us per prompt with one configured tool, measured inside the process (13 us for the installs, 8 us for the links), and os.SameFile adds about 1 us to that. A cache hit of vfox env takes about 6 ms with one configured tool, and switching the two stamps off in the same binary does not move it beyond the noise of about 0.1 ms.

The rest checks out on d862284. In a bash session with the nodejs plugin (forget_env_state of 0.5.4 switched off, as in 0.5.3), vfox install --all --yes in the shell and an install by another process are seen at the next prompt. rm -rf .vfox is repaired at the next prompt, in a .vfox.toml directory and in a .nvmrc directory. A global config replaced by a file with an mtime from 2020 is seen. A ~/.nvmrc and vfox use -p in the home directory, reached by its own path, leave the global link alone. Uninstalling a version only the project asks for leaves the global config alone, and uninstalling the global version still unsets it and switches to another. plain -> plain2 stays a cache hit, and a .nvmrc directory settles to cache hits after two runs.

The tests added since 5c2e6a4 all fail on it, with the messages the description quotes, and pass on d862284. Mutations of the install stamp (left out, plugin presence ignored, mtime ignored), of the link stamp (left out, taken only before the slow path, project or global directory dropped), of the key (never changed, never stored, directory dropped, versions dropped, directory kept without tools), of the mtime compare (back to >, back to seconds), of the alias guard and of both uninstall conditions are each caught by the existing tests. go test passes on every package except internal/shared/printer (it waits for a key press without a TTY, also on main), go test -count=40 on the env and uninstall tests is stable, and -race passes on cmd/commands and internal/env.

@thelastbackspace thelastbackspace changed the title fix(env): invalidate cached output when legacy version files resolve differently fix: refresh the env cache on legacy file, install, link and config changes; keep project versions off the global link and version Nov 5, 2026
@thelastbackspace

Copy link
Copy Markdown
Contributor Author

Thanks, all four points are addressed on 8d2b46d.

  1. samePath is replaced by sameDir (text compare first, then os.SameFile). Added TestGetLinkDirPathByScopeProjectInHome in internal/env (new file link_dir_test.go) and TestEnvProjectInHomeThroughSymlinkKeepsGlobalLinks in cmd/commands; both fail on d862284 with the messages you quoted and skip when symlinks aren't allowed. AGENTS.md and both quick-start pages now say project links in the home directory go to the session directory.
  2. Retitled to your suggestion.
  3. TestConfigState_HasChanged_FileModified uses os.Chtimes a minute ahead instead of the sleep (0.00s now), line 53 stores UnixNano(), and the comment and messages in TestConfigState_ResolutionKeyChanged talk about the key. Description bullets and the Testing section are updated to the final diff.
  4. Added TestEnvCacheFollowsInstallsInTheFallbackLayout (fails with the fallback path dropped) and TestUninstallFollowsTheGlobalVersion in internal/sdk (fails on 5c2e6a4 on both checks, and on the first with globalVersion() returning "").

go test ./... (minus internal/shared/printer), -race on cmd/commands, internal/env and internal/sdk, go vet ./... and gofmt -l are clean.

@linghengqian linghengqian added bug Something isn't working and removed need-more-info labels Nov 6, 2026

@linghengqian linghengqian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@linghengqian
linghengqian merged commit 37cff8f into version-fox:main Nov 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: vfox env serves the cached output after a cd: a .nvmrc / .node-version is ignored on the way in and kept on the way out

2 participants