From b41b24fbaa841b48fdb9c8daef28162408203b73 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:15:27 +0000 Subject: [PATCH 01/19] fix(install): switch backends into a new installation, and resolve CLI-named versions without guessing - `mise backends switch` under the identity layout installs the new backend's installation of each switched version. It is a different installation from the old backend's, so the version used to count as "not installed" and nothing was reinstalled; now the version path (the old install or its version link) is what says it was installed. - `mise where tool@version` applies the install options the configuration sets for the tool, as `mise x` and the other commands already did. - A version named on the command line without options, with no installation of its own, uses the one installation of that version made with other options in every command (`which`, `bin-paths`, `x`, `install`), as it did before the layout. With several, `mise where` lists them and stops instead of picking the one the version link names. Such a stand-in never becomes the bare request's selection. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M44YQP9FJWWAS70N7R85BRBQ --- docs/dev-tools/install-layout.md | 21 ++++++--- e2e/cli/test_install_layout_cli_variants | 55 ++++++++++++++++++++++++ e2e/cli/test_install_layout_switch | 38 ++++++++++++++++ src/cli/backends/switch.rs | 13 ++++-- src/cli/where.rs | 43 ++++++++++++------ src/install_layout/resolver.rs | 43 +++++++++++++++++- src/toolset/mod.rs | 3 +- src/toolset/tool_request_set.rs | 5 ++- 8 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 e2e/cli/test_install_layout_cli_variants create mode 100644 e2e/cli/test_install_layout_switch diff --git a/docs/dev-tools/install-layout.md b/docs/dev-tools/install-layout.md index 87ebddfe841..88d41844197 100644 --- a/docs/dev-tools/install-layout.md +++ b/docs/dev-tools/install-layout.md @@ -159,7 +159,10 @@ mise still writes the tool directory's `.mise.backend.toml` sidecar and [Finding an installation](#finding-an-installation-mise-where-and-mise-which) for when mise uses the installation directory instead. - **Tool options, backends, and the registry work as before.** Only where files - land and how installations are found changes. + land and how installations are found changes. `mise backends switch` installs + the new backend's installation of each switched version (it is a different + installation from the old backend's, which stays until it is pruned) and + points the version link at it. ## How identity is decided @@ -450,12 +453,16 @@ tools that have more than one variant of a version. package-manager installs record the real installation path, which now contains the hash. They stay valid for that installation but are not portable to another one. Installing the same identity again lands in the same directory. -- **A version named on the command line carries no options.** An installation - made with install options a configuration file sets (`symlink_bins`, a - `matching` pattern) is a different installation from the plain version, so - `mise where tool@1.0` finds it only because it is the lone variant, and other - commands treat the unadorned request as not installed. Run them where the - configuration applies, or let `mise install` make the plain variant. +- **A version named on the command line carries only the options a + configuration gives it.** In a project, `mise where tool@1.0`, `mise x +tool@1.0` and the other commands use the install options the configuration + sets for that tool (`symlink_bins`, a `matching` pattern). Outside one, a + version named without options uses the installation of that version when + there is only one, whatever options it was made with, as before this layout. + With several, `mise where` lists them and other commands treat the version as + not installed; name the options to pick one, as in + `mise where 'tool[matching=server]@1.0'`, or install the plain version with + `mise install --force tool@1.0`. - **A version link is not authority.** Hardcoded `installs//` paths see only the most recently installed variant. - **Directory names are less descriptive.** A label such as `berry` for yarn or diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants new file mode 100644 index 00000000000..7af59eba7c4 --- /dev/null +++ b/e2e/cli/test_install_layout_cli_variants @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# A version named on the command line carries no install options, so under the +# identity install layout it can mean an installation a configuration made with +# options. `mise where` uses one such installation when it is the only one, and +# lists them instead of guessing when there are several. +export MISE_EXPERIMENTAL=1 +export MISE_INSTALL_LAYOUT=identity +work="$PWD" + +mkdir env opt +cat >env/mise.toml <<'EOF2' +[tools] +dummy = { version = "2.0.0", install_env = { DUMMY_VARIANT = "musl" } } +EOF2 +cat >opt/mise.toml <<'EOF2' +[tools] +dummy = { version = "2.0.0", flavor = "full" } +EOF2 + +echo "=== the only installation of the version is found ===" +assert "mise -C $work/env install" +env_dir="$(mise -C "$work/env" where dummy)" +assert "mise where dummy@2.0.0" "$env_dir" + +echo "=== several are listed, not guessed between ===" +assert "mise -C $work/opt install" +opt_dir="$(mise -C "$work/opt" where dummy)" +assert_fail "mise where dummy@2.0.0" "installed with several sets of options" +assert_fail "mise where dummy@2.0.0" "$(basename "$opt_dir")" +assert_fail "mise where dummy@2.0.0" "$(basename "$env_dir")" + +echo "=== naming the options finds the variant ===" +assert "mise where 'dummy[flavor=full]@2.0.0'" "$opt_dir" +assert "mise -C $work/opt where dummy@2.0.0" "$opt_dir" + +echo "=== a lone variant stands for the bare version in every command ===" +mkdir solo +cat >solo/mise.toml <<'EOF2' +[tools] +dummy = { version = "1.0.0", flavor = "full" } +EOF2 +assert "mise -C $work/solo install" +solo_dir="$(mise -C "$work/solo" where dummy)" +assert "mise where dummy@1.0.0" "$solo_dir" +assert "mise bin-paths dummy@1.0.0 | xargs realpath" "$(realpath "$solo_dir/bin")" +assert "mise which dummy --tool dummy@1.0.0 | xargs realpath" "$(realpath "$solo_dir/bin/dummy")" +assert_contains "mise x dummy@1.0.0 -- dummy" "This is Dummy 1.0.0!" +assert_contains "mise install dummy@1.0.0 2>&1" "already installed" +assert "test \"\$(mise installs ls dummy --json | jq '[.[] | select(.version == \"1.0.0\")] | length')\" -eq 1" +# a forced install of the bare version makes its own installation, which it then uses +assert "mise install --force dummy@1.0.0" +assert "test \"\$(mise installs ls dummy --json | jq '[.[] | select(.version == \"1.0.0\")] | length')\" -eq 2" +plain_dir="$(mise where dummy@1.0.0)" +[[ $plain_dir != "$solo_dir" ]] || fail "the bare version still resolves to the variant" +assert "mise -C $work/solo where dummy" "$solo_dir" diff --git a/e2e/cli/test_install_layout_switch b/e2e/cli/test_install_layout_switch new file mode 100644 index 00000000000..f2a10bd3272 --- /dev/null +++ b/e2e/cli/test_install_layout_switch @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# `mise backends switch` under the identity install layout: the new backend's +# installation is a different one from the old backend's, so the switch installs +# it (instead of finding the version "already installed"), and the version link +# moves to it. hk moved from Aqua to Packslip at 1.58.1. +export MISE_EXPERIMENTAL=1 +export MISE_INSTALL_LAYOUT=identity +export MISE_LOCKFILE=1 +export MISE_MINIMUM_RELEASE_AGE=0 +detect_platform +installs="$MISE_DATA_DIR/installs" + +cat <<'EOF2' >mise.toml +[tools] +hk = "1" +EOF2 +cat <<'EOF2' >mise.lock +lockfile_version = 2 + +[[tools.hk]] +version = "1.58.1" +backend = "aqua:jdx/hk" +specifiers = ["1"] +EOF2 + +assert_contains "mise install 2>&1" "mise backends switch hk" +old_dir="$(mise where hk)" +assert_contains "cat '$old_dir/.mise-install.toml'" 'backend = "aqua:jdx/hk"' + +mise backends switch hk +assert_contains "cat mise.lock" 'backend = "packslip:github.com/jdx/hk"' +new_dir="$(mise where hk)" +[[ $new_dir != "$old_dir" ]] || fail "the switch kept the aqua installation $old_dir" +assert_contains "cat '$new_dir/.mise-install.toml'" 'backend = "packslip:github.com/jdx/hk"' +assert "readlink '$installs/hk/1.58.1'" "../$(basename "$new_dir")" +assert_contains "mise exec -- hk --version" "hk 1.58.1" +# the old backend's installation is left for prune, not deleted +assert "test -f '$old_dir/.mise-install.toml'" diff --git a/src/cli/backends/switch.rs b/src/cli/backends/switch.rs index 31f667bc12c..6635679def0 100644 --- a/src/cli/backends/switch.rs +++ b/src/cli/backends/switch.rs @@ -455,9 +455,11 @@ impl BackendsSwitch { } /// Reinstall the switched versions that are installed, from the new - /// backend. Installs are keyed by tool and version, not backend, so an - /// install from the old backend would otherwise keep satisfying the new - /// lock entry. + /// backend. In the legacy layout installs are keyed by tool and version, not + /// backend, so an install from the old backend would otherwise keep + /// satisfying the new lock entry. In the identity layout the new backend's + /// installation is a different one, so a version is reinstalled when its + /// version path (the old backend's install, or its version link) is there. async fn reinstall( &self, switched: &BTreeSet<(String, String)>, @@ -466,9 +468,12 @@ impl BackendsSwitch { let mut config = Config::reset().await?; let mut requests = vec![]; for (lockfile, tv) in self.scoped_versions(&config).await? { + let installed = tv.backend()?.is_version_installed(&config, &tv, false) + || (crate::install_layout::resolver::enabled() + && tv.ba().installs_path().join(tv.tv_pathname()).exists()); if (switched.contains(&(tv.short().to_string(), tv.version.clone())) || relocked_tools.contains(&(lockfile, tv.short().to_string()))) - && tv.backend()?.is_version_installed(&config, &tv, false) + && installed { requests.push(tv.request); } diff --git a/src/cli/where.rs b/src/cli/where.rs index f9d4be2fc89..ac16e08a2b8 100644 --- a/src/cli/where.rs +++ b/src/cli/where.rs @@ -43,7 +43,12 @@ impl Where { // A version named on the command line carries no install options. let named_here = self.tool.tvr.is_some() || self.asdf_version.is_some(); let tvr = match self.tool.tvr { - Some(tvr) => tvr, + // The options a configuration sets for the tool apply to a version named + // here too, as they do for `mise x tool@version`. + Some(tvr) => crate::toolset::apply_config_options_to_runtime_arg( + config.get_tool_request_set().await?, + tvr, + ), None => match self.asdf_version { Some(version) => self.tool.with_version(&version).tvr.unwrap(), None => { @@ -65,10 +70,10 @@ impl Where { if tv.backend()?.is_version_installed(&config, &tv, true) { miseprintln!("{}", tv.install_path().to_string_lossy()); Ok(()) - } else if let Some(dir) = named_here + } else if let Some(dir) = (named_here && !tv.resolved_from_lockfile()) .then(|| installed_variant(&tv)) + .transpose()? .flatten() - .filter(|_| !tv.resolved_from_lockfile()) { // The version was installed with options a configuration sets; the one // named here carries none. @@ -83,13 +88,27 @@ impl Where { } } -/// An installation of `tv`'s tool and version made with install options, when the -/// version as named installs nothing of its own. With several, the one the version -/// link names, else the first. -fn installed_variant(tv: &crate::toolset::ToolVersion) -> Option { - let variants = crate::install_layout::resolver::variants_of(tv); - let linked = crate::install_layout::resolver::physical_dir(tv.ba(), &tv.tv_pathname()); - linked - .filter(|dir| variants.contains(dir)) - .or_else(|| variants.into_iter().next()) +/// The installation of `tv`'s tool and version made with install options, when the +/// version as named installs nothing of its own and there is exactly one. With +/// several, which one is meant depends on options the command line did not give, +/// so it is an error that lists them rather than a guess (the version link names +/// only the most recent one). +fn installed_variant(tv: &crate::toolset::ToolVersion) -> Result> { + let mut variants = crate::install_layout::resolver::variants_of(tv); + if variants.len() > 1 { + let list = variants + .iter() + .map(|dir| format!(" {}", crate::file::display_path(dir))) + .collect::>() + .join("\n"); + eyre::bail!( + "{} is installed with several sets of options:\n{list}\n\ + Run this where the configuration that sets them applies, or name them, \ + for example `{}[option=value]@{}`", + tv.style(), + tv.ba().short, + tv.version + ); + } + Ok(variants.pop()) } diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index b842b107000..849ec3aedf2 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -386,6 +386,19 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { return Some(located); } Unlocked::Ambiguous(_) => ambiguous = true, + // A version named on the command line without options means whatever + // is installed of it, as it did before this layout: a lone installation + // made with options a configuration set (`filter_bins`, `matching`) + // stands for it. With several, which is meant is not known. + Unlocked::Nothing + if identity.options.is_empty() + && matches!(tv.request.source(), crate::toolset::ToolSource::Argument) => + { + if let [only] = variant_installations(&identity).as_slice() { + locate_cache().insert(cache_key, only.clone()); + return Some(only.clone()); + } + } Unlocked::Nothing => {} } } else { @@ -523,6 +536,32 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { } } +/// The complete installations, in every root, of `identity`'s backend, version and +/// platform made for another request (other install options). +fn variant_installations(identity: &InstallIdentity) -> Vec { + let own = request_of(identity); + let mut out = vec![]; + for root in roots() { + let catalog = Catalog::new(&root); + for record in catalog.records_for_backend(&identity.backend) { + let dir = catalog.install_dir(&record); + if record.identity.version == identity.version + && record.identity.platform == identity.platform + && request_of(&record.identity) != own + && is_complete(&dir) + { + out.push(Located { + dir, + root: root.clone(), + record: Some(record), + installed: true, + }); + } + } + } + out +} + /// The catalog records that could satisfy a request pinning an artifact, best /// first: the installation allocated for exactly that pin, one the pin already /// adopted, or an unlocked installation whose acquired artifact is the pinned @@ -1324,7 +1363,9 @@ pub(crate) fn note_reuse(tv: &ToolVersion) -> Result<()> { // a lockfile, another spelling or a shared root made) remembers it, so the // choice stays put when more installations of the request appear. let key = identity.request_key(); - if pin_of(&identity).is_none() { + // A variant standing in for a bare request named on the command line is not + // its selection. + if pin_of(&identity).is_none() && same_request(&record.identity, &key) { let shared = (!is_primary_root(&located.root)).then_some(located.root.as_path()); if catalog.select_if_unset(&key, &record, shared)? { reset_cache(); diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index 08115852fc8..d3abdfa9027 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -36,7 +36,8 @@ pub use install_options::InstallOptions; pub use tool_deps::ensure_compatible_install_requests; pub use tool_request::ToolRequest; pub use tool_request_set::{ - ToolRequestSet, ToolRequestSetBuilder, tool_env_var_name, tool_env_vars, tool_from_env_var_name, + ToolRequestSet, ToolRequestSetBuilder, apply_config_options_to_runtime_arg, tool_env_var_name, + tool_env_vars, tool_from_env_var_name, }; pub use tool_source::ToolSource; pub use tool_version::resolve_sub_base; diff --git a/src/toolset/tool_request_set.rs b/src/toolset/tool_request_set.rs index 4815791503c..19e129b735d 100644 --- a/src/toolset/tool_request_set.rs +++ b/src/toolset/tool_request_set.rs @@ -304,7 +304,10 @@ impl ToolRequestSetBuilder { /// defaults, so option emptiness cannot indicate whether config should apply. /// The canonical backend resolver preserves the normal precedence and reapplies /// explicit inline backend options last. -fn apply_config_options_to_runtime_arg(trs: &ToolRequestSet, mut tvr: ToolRequest) -> ToolRequest { +pub fn apply_config_options_to_runtime_arg( + trs: &ToolRequestSet, + mut tvr: ToolRequest, +) -> ToolRequest { if let Some(config_options) = trs .tools .get(tvr.ba()) From 73a8e733c40a7f21c945477967450279a772c384 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:01:30 +0000 Subject: [PATCH 02/19] fix(install): check every installs root on backend switch, apply options to a positional where version, keep variants for bare versions - `mise backends switch` counts a version as installed when the old backend's install is in a shared or system root too. - `mise where tool version` applies the configuration's options like `mise where tool@version` does. - A version carrying options (named, or from a configuration) never falls back to another variant; only a bare version does, and only after a legacy installation of it. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4504RJ7TBC7T8E0NSWWNPNN --- e2e/cli/test_install_layout_cli_variants | 15 ++++++++++ src/cli/backends/switch.rs | 10 ++++++- src/cli/where.rs | 33 +++++++++++----------- src/install_layout/resolver.rs | 35 +++++++++++++++--------- 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index 7af59eba7c4..fab1403c449 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -53,3 +53,18 @@ assert "test \"\$(mise installs ls dummy --json | jq '[.[] | select(.version == plain_dir="$(mise where dummy@1.0.0)" [[ $plain_dir != "$solo_dir" ]] || fail "the bare version still resolves to the variant" assert "mise -C $work/solo where dummy" "$solo_dir" + +echo "=== a version named with options a configuration sets never stands in for another variant ===" +mkdir minimal +cat >minimal/mise.toml <<'EOF2' +[tools] +dummy = { version = "1.1.0", flavor = "minimal" } +EOF2 +assert "mise -C $work/minimal install" +mkdir full +cat >full/mise.toml <<'EOF2' +[tools] +dummy = { version = "1.1.0", flavor = "full" } +EOF2 +assert_fail "mise -C $work/full where dummy@1.1.0" +assert_fail "mise -C $work/full where dummy 1.1.0" diff --git a/src/cli/backends/switch.rs b/src/cli/backends/switch.rs index 6635679def0..ec74bad4686 100644 --- a/src/cli/backends/switch.rs +++ b/src/cli/backends/switch.rs @@ -468,9 +468,17 @@ impl BackendsSwitch { let mut config = Config::reset().await?; let mut requests = vec![]; for (lockfile, tv) in self.scoped_versions(&config).await? { + // The old backend's install may be in the user's root or in a shared + // or system one. let installed = tv.backend()?.is_version_installed(&config, &tv, false) || (crate::install_layout::resolver::enabled() - && tv.ba().installs_path().join(tv.tv_pathname()).exists()); + && std::iter::once(crate::dirs::INSTALLS.to_path_buf()) + .chain(crate::env::shared_install_dirs()) + .any(|root| { + tv.ba().installs_path().file_name().is_some_and(|tool_dir| { + root.join(tool_dir).join(tv.tv_pathname()).exists() + }) + })); if (switched.contains(&(tv.short().to_string(), tv.version.clone())) || relocked_tools.contains(&(lockfile, tv.short().to_string()))) && installed diff --git a/src/cli/where.rs b/src/cli/where.rs index ac16e08a2b8..dfb930f4ba1 100644 --- a/src/cli/where.rs +++ b/src/cli/where.rs @@ -42,27 +42,28 @@ impl Where { let config = Config::get().await?; // A version named on the command line carries no install options. let named_here = self.tool.tvr.is_some() || self.asdf_version.is_some(); - let tvr = match self.tool.tvr { - // The options a configuration sets for the tool apply to a version named - // here too, as they do for `mise x tool@version`. + // The options a configuration sets for the tool apply to a version named + // here too (as `tool@version` or `tool version`), as they do for + // `mise x tool@version`. + let named = match (self.tool.tvr.clone(), &self.asdf_version) { + (Some(tvr), _) => Some(tvr), + (None, Some(version)) => self.tool.clone().with_version(version).tvr, + (None, None) => None, + }; + let tvr = match named { Some(tvr) => crate::toolset::apply_config_options_to_runtime_arg( config.get_tool_request_set().await?, tvr, ), - None => match self.asdf_version { - Some(version) => self.tool.with_version(&version).tvr.unwrap(), - None => { - let ts = ToolsetBuilder::new().build(&config).await?; - match ts.versions.get(self.tool.ba.as_ref()) { - Some(tvl) => { - tvl.os_supported_requests().next().cloned().ok_or_else(|| { - eyre::eyre!("{} does not have an active version", self.tool.ba) - })? - } - None => self.tool.with_version("latest").tvr.unwrap(), - } + None => { + let ts = ToolsetBuilder::new().build(&config).await?; + match ts.versions.get(self.tool.ba.as_ref()) { + Some(tvl) => tvl.os_supported_requests().next().cloned().ok_or_else(|| { + eyre::eyre!("{} does not have an active version", self.tool.ba) + })?, + None => self.tool.with_version("latest").tvr.unwrap(), } - }, + } }; let tv = tvr.resolve(&config, &Default::default()).await?; diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index 849ec3aedf2..5a9f239efce 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -353,6 +353,7 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { let key = identity.request_key(); let unlocked = pin_of(&identity).is_none(); let mut ambiguous = false; + let mut try_variant = false; if unlocked { let cache_key = (dirs::INSTALLS.to_path_buf(), digest.clone()); if let Some(hit) = locate_cache().get(&cache_key) { @@ -386,20 +387,12 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { return Some(located); } Unlocked::Ambiguous(_) => ambiguous = true, - // A version named on the command line without options means whatever - // is installed of it, as it did before this layout: a lone installation - // made with options a configuration set (`filter_bins`, `matching`) - // stands for it. With several, which is meant is not known. - Unlocked::Nothing - if identity.options.is_empty() - && matches!(tv.request.source(), crate::toolset::ToolSource::Argument) => - { - if let [only] = variant_installations(&identity).as_slice() { - locate_cache().insert(cache_key, only.clone()); - return Some(only.clone()); - } + // Nothing of the request's own: a bare version named on the command + // line may still stand for a variant, after a legacy install (below). + Unlocked::Nothing => { + try_variant = identity.options.is_empty() + && matches!(tv.request.source(), crate::toolset::ToolSource::Argument); } - Unlocked::Nothing => {} } } else { for root in roots() { @@ -427,6 +420,14 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { if !ambiguous && let Some(legacy) = legacy_dir(tv, &identity) { return Some(legacy_located(legacy)); } + // A version named on the command line without options means whatever is + // installed of it, as it did before this layout: a lone installation made + // with options a configuration set (`filter_bins`, `matching`) stands for + // it. With several, which is meant is not known. + if try_variant && let [only] = variant_installations(&identity).as_slice() { + locate_cache().insert((dirs::INSTALLS.to_path_buf(), digest.clone()), only.clone()); + return Some(only.clone()); + } let primary = Catalog::new(dirs::INSTALLS.to_path_buf()); // Several installations answer and none is chosen: report a path that is // never an installation, so nothing (`where`, `exec`, PATH) quietly runs one @@ -950,6 +951,14 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { let mut bare = tv.clone(); bare.install_path = None; let own_request = identity_of(&bare).map(|identity| request_of(&identity)); + // A request with options of its own (named, or set by a configuration) means + // exactly those; only a bare version can stand for a variant. + if own_request + .as_ref() + .is_some_and(|own| !own.options.is_empty()) + { + return vec![]; + } // The request has installations of its own, or a selection: which one it uses // is for the selection to say (or the user, when that is ambiguous), so a // variant made with other options is no stand-in for it. From 18dad59093cca5e23945eebc07c951db3471dc4d Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:42:02 +0000 Subject: [PATCH 03/19] fix(install): never install into or select a variant standing in for a bare version A bare version named on the command line uses a lone installation made with options. When that installation had to be installed again, the install wrote into it with the bare request's options and selected it for the bare request. It is now allocated like an installation in a read-only shared root: used as it is, and replaced by an installation of the request's own when anything has to be written. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M452EYVPYXAHZJKT6ZA7TDV6 --- e2e/cli/test_install_layout_cli_variants | 20 ++++++++++++++++++++ src/install_layout/resolver.rs | 19 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index fab1403c449..08432c36486 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -54,6 +54,26 @@ plain_dir="$(mise where dummy@1.0.0)" [[ $plain_dir != "$solo_dir" ]] || fail "the bare version still resolves to the variant" assert "mise -C $work/solo where dummy" "$solo_dir" +echo "=== a lone variant that has to be installed again is not installed over for the bare version ===" +mkdir solo2 +cat >solo2/mise.toml <<'EOF2' +[tools] +tiny = { version = "3.1.0", flavor = "full" } +EOF2 +assert "mise -C $work/solo2 install" +solo2_dir="$(mise -C "$work/solo2" where tiny)" +assert "mise where tiny@3.1.0" "$solo2_dir" +# as if a refresh of the variant had been interrupted +mkdir -p "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")" +touch "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")/incomplete" +assert "mise install tiny@3.1.0" +bare_dir="$(mise where tiny@3.1.0)" +[[ $bare_dir != "$solo2_dir" ]] || fail "the bare version was installed into the variant" +# the variant is still the project's, once its own refresh is done +rm "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")/incomplete" +assert "mise -C $work/solo2 where tiny" "$solo2_dir" +assert "mise installs ls tiny --json | jq -r '.[] | select(.selected and .version == \"3.1.0\") | .dir' | sort | tr '\n' ' '" "$(printf '%s\n' "$bare_dir" "$solo2_dir" | sort | tr '\n' ' ')" + echo "=== a version named with options a configuration sets never stands in for another variant ===" mkdir minimal cat >minimal/mise.toml <<'EOF2' diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index 5a9f239efce..54c3b48641e 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -1070,8 +1070,9 @@ pub(crate) struct Allocated { /// An existing installation that already satisfies the request: nothing /// needs to be installed, only recorded. pub(crate) reused: bool, - /// The installation is in a read-only shared root: it may be used, never - /// written to. Installing means allocating again, in the primary root. + /// The installation is in a read-only shared root, or is a variant standing + /// in for a bare version: it may be used, never written to. Installing means + /// allocating again, for the request itself in the primary root. pub(crate) read_only: bool, /// The unlocked request this allocation answers; set when its selection /// should be (re)recorded on success. `None` for locked requests, which @@ -1156,6 +1157,20 @@ pub(crate) fn allocate(tv: &ToolVersion, refresh: bool) -> Result return Ok(None), + // A variant standing in for a bare version (see `locate`) is used where + // it is, never installed into or selected for that version: installing + // makes the bare request an installation of its own. + Some(record) if request_of(&record.identity) != request_of(&identity) => { + if !refresh { + return Ok(Some(Allocated { + read_only: true, + dir: located.dir, + reused: true, + selects: None, + record, + })); + } + } // An installation that already satisfies the request, possibly in a // read-only shared root, is reused as it is. Some(record) if !refresh => { From efbbd67151c15c028bb2eddeb3e7666a7fb5c314 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:52:55 +0000 Subject: [PATCH 04/19] fix(backends): find the old backend's version path when only one backend adds a graph suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backends switch looked for the old backend's install at the new request's path name. An aube or uv install adds a private graph suffix (1.0.0~aube~…) that the other backend's request does not carry, so the old install was missed and not replaced. The plain version and any suffixed form are now checked too. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4532WTWYKHZ5MPHYGDNZJCK --- src/cli/backends/switch.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/cli/backends/switch.rs b/src/cli/backends/switch.rs index ec74bad4686..c898332abba 100644 --- a/src/cli/backends/switch.rs +++ b/src/cli/backends/switch.rs @@ -475,9 +475,10 @@ impl BackendsSwitch { && std::iter::once(crate::dirs::INSTALLS.to_path_buf()) .chain(crate::env::shared_install_dirs()) .any(|root| { - tv.ba().installs_path().file_name().is_some_and(|tool_dir| { - root.join(tool_dir).join(tv.tv_pathname()).exists() - }) + tv.ba() + .installs_path() + .file_name() + .is_some_and(|tool_dir| has_version_path(&root.join(tool_dir), &tv)) })); if (switched.contains(&(tv.short().to_string(), tv.version.clone())) || relocked_tools.contains(&(lockfile, tv.short().to_string()))) @@ -569,3 +570,23 @@ impl Snapshot { } } } + +/// Whether the tool directory `tool_dir` has a version path for `tv`'s version, +/// as the backend before the switch named it. That is `tv`'s own path name, or +/// the version with the private dependency-graph suffix an aube or uv install +/// adds (`1.0.0~aube~…`), which only one of the two backends may carry. +fn has_version_path(tool_dir: &Path, tv: &ToolVersion) -> bool { + if tool_dir.join(tv.tv_pathname()).exists() || tool_dir.join(&tv.version).exists() { + return true; + } + let suffixed = format!("{}~", tv.version); + std::fs::read_dir(tool_dir).is_ok_and(|entries| { + entries.flatten().any(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(&suffixed)) + && entry.path().exists() + }) + }) +} From 5ebb77133c9d7dee4f4aa9fe51763908361bc842 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:05:37 +0000 Subject: [PATCH 05/19] fix(install): do not cache a variant standing in for a bare command-line version The lookup cache is keyed by the request, and a configured request with the same identity does not stand for a variant. Caching the stand-in let a configuration's missing tool read as installed once a command-line argument had filled the cache. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M453T5KPDG7CTZ605BF46BQM --- src/install_layout/resolver.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index 54c3b48641e..e00984fc763 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -423,9 +423,10 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { // A version named on the command line without options means whatever is // installed of it, as it did before this layout: a lone installation made // with options a configuration set (`filter_bins`, `matching`) stands for - // it. With several, which is meant is not known. + // it. With several, which is meant is not known. It is not cached: the cache + // is keyed by the request, and the same request from a configuration file + // does not stand for a variant. if try_variant && let [only] = variant_installations(&identity).as_slice() { - locate_cache().insert((dirs::INSTALLS.to_path_buf(), digest.clone()), only.clone()); return Some(only.clone()); } let primary = Catalog::new(dirs::INSTALLS.to_path_buf()); From 846b888b430fb32b474e388191650c9224e1f0a4 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:26:23 +0000 Subject: [PATCH 06/19] fix(install): never take an installation built from another dependency graph for a bare version A bare command-line version could fall back to the only installation of the version even when that one was built from another aube or uv dependency graph than the request names, so `mise x npm:tool@ver` ran the old dependencies after the lockfile's graph changed. A graph is not an install option a configuration sets; installations from another graph are no longer variants. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M455063PMY65RE30T0FPF2QC --- src/install_layout/resolver.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index e00984fc763..e5f3e94f328 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -538,6 +538,15 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { } } +/// Whether `a` was built from the dependency graph (aube, uv) `b` names, or +/// neither has one. A graph is not an option a configuration sets: an +/// installation built from another one is not a variant of the request. +fn same_graph(a: &InstallIdentity, b: Option<&InstallIdentity>) -> bool { + ["aube", "uv"] + .iter() + .all(|k| a.inputs.get(*k) == b.and_then(|b| b.inputs.get(*k))) +} + /// The complete installations, in every root, of `identity`'s backend, version and /// platform made for another request (other install options). fn variant_installations(identity: &InstallIdentity) -> Vec { @@ -549,6 +558,7 @@ fn variant_installations(identity: &InstallIdentity) -> Vec { let dir = catalog.install_dir(&record); if record.identity.version == identity.version && record.identity.platform == identity.platform + && same_graph(&record.identity, Some(identity)) && request_of(&record.identity) != own && is_complete(&dir) { @@ -951,7 +961,8 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { }; let mut bare = tv.clone(); bare.install_path = None; - let own_request = identity_of(&bare).map(|identity| request_of(&identity)); + let own_identity = identity_of(&bare); + let own_request = own_identity.as_ref().map(request_of); // A request with options of its own (named, or set by a configuration) means // exactly those; only a bare version can stand for a variant. if own_request @@ -977,6 +988,7 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { let dir = catalog.install_dir(&record); if record.identity.version == version && record.identity.platform == platform + && same_graph(&record.identity, own_identity.as_ref()) && own_request .as_ref() .is_none_or(|own| request_of(&record.identity) != *own) From 4e6aa1434c43318474d588851421250f8b567d29 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:54:16 +0000 Subject: [PATCH 07/19] fix(backends): find the old backend's installation by its receipt when it has no version link On Windows without junctions an installation has no version link, so a backend switch found nothing to replace and left the tool without an installation from the new backend. Installations requested as the tool at that version are now found through their receipts as well. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M456K7M964JT6SCN1WM9SWW7 --- src/cli/backends/switch.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/cli/backends/switch.rs b/src/cli/backends/switch.rs index c898332abba..9a097058591 100644 --- a/src/cli/backends/switch.rs +++ b/src/cli/backends/switch.rs @@ -469,17 +469,24 @@ impl BackendsSwitch { let mut requests = vec![]; for (lockfile, tv) in self.scoped_versions(&config).await? { // The old backend's install may be in the user's root or in a shared - // or system one. + // or system one, and may have no version link (Windows without + // junctions), so its receipt is looked for too. let installed = tv.backend()?.is_version_installed(&config, &tv, false) || (crate::install_layout::resolver::enabled() - && std::iter::once(crate::dirs::INSTALLS.to_path_buf()) + && (std::iter::once(crate::dirs::INSTALLS.to_path_buf()) .chain(crate::env::shared_install_dirs()) .any(|root| { tv.ba() .installs_path() .file_name() .is_some_and(|tool_dir| has_version_path(&root.join(tool_dir), &tv)) - })); + }) + || crate::install_layout::resolver::installations() + .iter() + .any(|i| { + i.version == tv.version + && i.requested_as.as_deref() == Some(tv.short()) + }))); if (switched.contains(&(tv.short().to_string(), tv.version.clone())) || relocked_tools.contains(&(lockfile, tv.short().to_string()))) && installed From 6dcb2a7b114b99d33a6c790c5321a8d36d2ee2d9 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:45:40 +0000 Subject: [PATCH 08/19] fix(install): do not run an always-run postinstall hook in a variant standing in for a bare version A postinstall hook with when = "always" ran even when the install was satisfied by a read-only installation, a variant standing in for a bare version or one in a shared root, with MISE_TOOL_INSTALL_PATH pointing into it. Such a request now installs its own copy first, like any other install that would write. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M459HBXDFVT7YTD8FYQ2GNP0 --- e2e/cli/test_install_layout_cli_variants | 16 ++++++++++++++++ src/backend/mod.rs | 16 +++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index 08432c36486..c0c5158b4ff 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -74,6 +74,22 @@ rm "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")/incomplete" assert "mise -C $work/solo2 where tiny" "$solo2_dir" assert "mise installs ls tiny --json | jq -r '.[] | select(.selected and .version == \"3.1.0\") | .dir' | sort | tr '\n' ' '" "$(printf '%s\n' "$bare_dir" "$solo2_dir" | sort | tr '\n' ' ')" +echo "=== a postinstall hook that always runs never runs in a variant standing in ===" +mkdir variant hooked +cat >variant/mise.toml <<'EOF2' +[tools] +tiny = { version = "3.0.0", flavor = "full" } +EOF2 +assert "mise -C $work/variant install" +variant_dir="$(mise -C "$work/variant" where tiny)" +cat >hooked/mise.toml <<'EOF2' +[tools] +tiny = { version = "3.0.0", postinstall = { run = "touch \"$MISE_TOOL_INSTALL_PATH/hooked\"", when = "always" } } +EOF2 +assert "mise -C $work/hooked install tiny@3.0.0" +assert "test ! -e '$variant_dir/hooked'" +assert "test -e \"\$(mise -C '$work/hooked' where tiny@3.0.0)/hooked\"" + echo "=== a version named with options a configuration sets never stands in for another variant ===" mkdir minimal cat >minimal/mise.toml <<'EOF2' diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 87533448e4c..7d9f7a86779 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -3974,13 +3974,19 @@ pub trait Backend: Debug + Send + Sync { .await && !rolling_reinstall; - // An identity-layout installation found in a read-only shared root is used as - // it is; anything that would write (a forced or incompatible install) is - // allocated afresh in the primary root first, before it can touch the shared - // directory. + // An identity-layout installation found in a read-only shared root, or a + // variant standing in for a bare version, is used as it is; anything that + // would write (a forced or incompatible install, or a postinstall hook that + // runs even when nothing is installed) is allocated afresh in the primary + // root first, before it can touch that directory. let mut allocated = allocated; + let always_postinstall = tv + .request + .options() + .postinstall() + .is_some_and(|(_, always)| always); if allocated.as_ref().is_some_and(|a| a.read_only) - && (ctx.force || rolling_reinstall || !install_satisfied) + && (ctx.force || rolling_reinstall || !install_satisfied || always_postinstall) { let mut bare = tv.clone(); bare.install_path = None; From 913b787f64597addf23f2dede037df614b446a7b Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:13:17 +0000 Subject: [PATCH 09/19] fix(install): only take a variant of the same tool for a bare version The lone-variant fallback accepted any installation of the backend, version and platform, so an installation made for another tool aliasing the same backend (oxlint and oxfmt are both github:oxc-project/oxc) stood in for a bare version of this one. It now has to be one of this tool's own installations. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45B3XH9KH60QVHQXNEHBM1Z --- e2e/cli/test_install_layout_cli_variants | 14 ++++++++++++++ src/install_layout/resolver.rs | 9 ++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index c0c5158b4ff..5a4a70a67ee 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -90,6 +90,20 @@ assert "mise -C $work/hooked install tiny@3.0.0" assert "test ! -e '$variant_dir/hooked'" assert "test -e \"\$(mise -C '$work/hooked' where tiny@3.0.0)/hooked\"" +echo "=== another tool aliasing the same backend never stands in ===" +mkdir aliased +cat >aliased/mise.toml <minimal/mise.toml <<'EOF2' diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index e5f3e94f328..d7b42b40ff7 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -426,7 +426,7 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { // it. With several, which is meant is not known. It is not cached: the cache // is keyed by the request, and the same request from a configuration file // does not stand for a variant. - if try_variant && let [only] = variant_installations(&identity).as_slice() { + if try_variant && let [only] = variant_installations(tv.ba(), &identity).as_slice() { return Some(only.clone()); } let primary = Catalog::new(dirs::INSTALLS.to_path_buf()); @@ -548,8 +548,9 @@ fn same_graph(a: &InstallIdentity, b: Option<&InstallIdentity>) -> bool { } /// The complete installations, in every root, of `identity`'s backend, version and -/// platform made for another request (other install options). -fn variant_installations(identity: &InstallIdentity) -> Vec { +/// platform made for another request (other install options) of the tool `ba`; +/// another tool aliasing the same backend (`oxfmt` and `oxlint`) has its own. +fn variant_installations(ba: &crate::args::BackendArg, identity: &InstallIdentity) -> Vec { let own = request_of(identity); let mut out = vec![]; for root in roots() { @@ -561,6 +562,7 @@ fn variant_installations(identity: &InstallIdentity) -> Vec { && same_graph(&record.identity, Some(identity)) && request_of(&record.identity) != own && is_complete(&dir) + && belongs_to(ba, &record, &dir) { out.push(Located { dir, @@ -993,6 +995,7 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { .as_ref() .is_none_or(|own| request_of(&record.identity) != *own) && is_complete(&dir) + && belongs_to(tv.ba(), &record, &dir) { out.push(dir); } From 009387fbf5a436ceea05511dd3baef5285f568ae Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:13:19 +0000 Subject: [PATCH 10/19] perf(backends): list installations once, and only for switched versions, when reinstalling after a backend switch Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45B3ZV5DSBGQP87B8WQ48E3 --- src/cli/backends/switch.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/cli/backends/switch.rs b/src/cli/backends/switch.rs index 9a097058591..fd98bfa8ab2 100644 --- a/src/cli/backends/switch.rs +++ b/src/cli/backends/switch.rs @@ -467,7 +467,14 @@ impl BackendsSwitch { ) -> Result<()> { let mut config = Config::reset().await?; let mut requests = vec![]; + // Read once, and only if some switched version has no version path. + let mut installations = None; for (lockfile, tv) in self.scoped_versions(&config).await? { + if !switched.contains(&(tv.short().to_string(), tv.version.clone())) + && !relocked_tools.contains(&(lockfile, tv.short().to_string())) + { + continue; + } // The old backend's install may be in the user's root or in a shared // or system one, and may have no version link (Windows without // junctions), so its receipt is looked for too. @@ -481,16 +488,14 @@ impl BackendsSwitch { .file_name() .is_some_and(|tool_dir| has_version_path(&root.join(tool_dir), &tv)) }) - || crate::install_layout::resolver::installations() + || installations + .get_or_insert_with(crate::install_layout::resolver::installations) .iter() .any(|i| { i.version == tv.version && i.requested_as.as_deref() == Some(tv.short()) }))); - if (switched.contains(&(tv.short().to_string(), tv.version.clone())) - || relocked_tools.contains(&(lockfile, tv.short().to_string()))) - && installed - { + if installed { requests.push(tv.request); } } From 848e603a3e54e4f4bc0e2a77a66563da31e8566c Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:41:22 +0000 Subject: [PATCH 11/19] fix(backends): compare versions the way installations name them when finding a switched install Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45CQBAWMKKWAWMG40F8RP9F --- src/cli/backends/switch.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/cli/backends/switch.rs b/src/cli/backends/switch.rs index fd98bfa8ab2..db22fb2aa1c 100644 --- a/src/cli/backends/switch.rs +++ b/src/cli/backends/switch.rs @@ -492,7 +492,7 @@ impl BackendsSwitch { .get_or_insert_with(crate::install_layout::resolver::installations) .iter() .any(|i| { - i.version == tv.version + i.version == layout_version(&tv) && i.requested_as.as_deref() == Some(tv.short()) }))); if installed { @@ -583,15 +583,24 @@ impl Snapshot { } } +/// `tv`'s version as installations and version paths name it (`ref-main` for +/// `ref:main`). +fn layout_version(tv: &ToolVersion) -> String { + crate::install_layout::resolver::identity_scope(tv) + .map(|(_, version)| version) + .unwrap_or_else(|| tv.version.replace([':', '/'], "-")) +} + /// Whether the tool directory `tool_dir` has a version path for `tv`'s version, /// as the backend before the switch named it. That is `tv`'s own path name, or /// the version with the private dependency-graph suffix an aube or uv install /// adds (`1.0.0~aube~…`), which only one of the two backends may carry. fn has_version_path(tool_dir: &Path, tv: &ToolVersion) -> bool { - if tool_dir.join(tv.tv_pathname()).exists() || tool_dir.join(&tv.version).exists() { + let version = layout_version(tv); + if tool_dir.join(tv.tv_pathname()).exists() || tool_dir.join(&version).exists() { return true; } - let suffixed = format!("{}~", tv.version); + let suffixed = format!("{version}~"); std::fs::read_dir(tool_dir).is_ok_and(|entries| { entries.flatten().any(|entry| { entry From ec7d683a1a77de96e2590175fb1f894924d41b04 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:00:36 +0000 Subject: [PATCH 12/19] fix(install): tell a bare version's settings from request options when looking for a variant A bare version's identity also records settings that change what is installed (a cargo registry, a pip index). With one set, the bare version looked like a request with options of its own, so the only installation of the version, made with options a configuration sets, was not found. A version now stands for a variant when its request sets no install options, and only for a variant made with the same settings. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45DTJ79GHKK03XWMDFXJYDV --- src/install_layout/resolver.rs | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index d7b42b40ff7..b1c8d96125b 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -390,7 +390,7 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { // Nothing of the request's own: a bare version named on the command // line may still stand for a variant, after a legacy install (below). Unlocked::Nothing => { - try_variant = identity.options.is_empty() + try_variant = !sets_install_options(tv) && matches!(tv.request.source(), crate::toolset::ToolSource::Argument); } } @@ -538,6 +538,25 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { } } +/// Whether `tv`'s request sets options that change what is installed (named on +/// the command line, or applied from a configuration). The options of a bare +/// version's identity come from settings alone (a cargo registry, a pip index). +fn sets_install_options(tv: &ToolVersion) -> bool { + tv.request.options().opts.keys().any(|key| { + !crate::toolset::EPHEMERAL_OPT_KEYS.contains(&key.as_str()) + && !crate::backend::static_helpers::LISTING_ONLY_OPT_KEYS.contains(&key.as_str()) + }) +} + +/// Whether a variant `a` was made with the settings a bare version's identity `b` +/// records: every option of `b` (which a bare version gets only from settings) is +/// one `a` has too. +fn same_settings(a: &InstallIdentity, b: &InstallIdentity) -> bool { + b.options + .iter() + .all(|(key, value)| a.options.get(key) == Some(value)) +} + /// Whether `a` was built from the dependency graph (aube, uv) `b` names, or /// neither has one. A graph is not an option a configuration sets: an /// installation built from another one is not a variant of the request. @@ -560,6 +579,7 @@ fn variant_installations(ba: &crate::args::BackendArg, identity: &InstallIdentit if record.identity.version == identity.version && record.identity.platform == identity.platform && same_graph(&record.identity, Some(identity)) + && same_settings(&record.identity, identity) && request_of(&record.identity) != own && is_complete(&dir) && belongs_to(ba, &record, &dir) @@ -967,10 +987,7 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { let own_request = own_identity.as_ref().map(request_of); // A request with options of its own (named, or set by a configuration) means // exactly those; only a bare version can stand for a variant. - if own_request - .as_ref() - .is_some_and(|own| !own.options.is_empty()) - { + if sets_install_options(tv) { return vec![]; } // The request has installations of its own, or a selection: which one it uses @@ -991,6 +1008,9 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { if record.identity.version == version && record.identity.platform == platform && same_graph(&record.identity, own_identity.as_ref()) + && own_identity + .as_ref() + .is_none_or(|own| same_settings(&record.identity, own)) && own_request .as_ref() .is_none_or(|own| request_of(&record.identity) != *own) From 292cf2a857cbeb4a8f56783078c627c60c0755de Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:16:17 +0000 Subject: [PATCH 13/19] fix(install): let an installation stand for a command-line version when it has all of the version's options and more Defaults a tool's registry entry or backend alias gives its options counted as options the version set itself, so a bare version of such a tool never found the lone installation made with more options by a configuration. An installation now stands in when it has every install option the version has (its own, its registry's defaults, the settings that take part) and others besides, never one with different values for options the version has. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45EQ97H6RGEW8JPQNXCX2DB --- src/install_layout/resolver.rs | 55 +++++++++++++--------------------- 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index b1c8d96125b..6f279101572 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -390,8 +390,7 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { // Nothing of the request's own: a bare version named on the command // line may still stand for a variant, after a legacy install (below). Unlocked::Nothing => { - try_variant = !sets_install_options(tv) - && matches!(tv.request.source(), crate::toolset::ToolSource::Argument); + try_variant = matches!(tv.request.source(), crate::toolset::ToolSource::Argument); } } } else { @@ -420,12 +419,12 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { if !ambiguous && let Some(legacy) = legacy_dir(tv, &identity) { return Some(legacy_located(legacy)); } - // A version named on the command line without options means whatever is - // installed of it, as it did before this layout: a lone installation made - // with options a configuration set (`filter_bins`, `matching`) stands for - // it. With several, which is meant is not known. It is not cached: the cache - // is keyed by the request, and the same request from a configuration file - // does not stand for a variant. + // A version named on the command line means whatever is installed of it, as + // it did before this layout: a lone installation made with the request's + // options and more that a configuration set (`filter_bins`, `matching`) stands + // for it (see `extends`). With several, which is meant is not known. It is + // not cached: the cache is keyed by the request, and the same request from a + // configuration file does not stand for a variant. if try_variant && let [only] = variant_installations(tv.ba(), &identity).as_slice() { return Some(only.clone()); } @@ -538,20 +537,12 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { } } -/// Whether `tv`'s request sets options that change what is installed (named on -/// the command line, or applied from a configuration). The options of a bare -/// version's identity come from settings alone (a cargo registry, a pip index). -fn sets_install_options(tv: &ToolVersion) -> bool { - tv.request.options().opts.keys().any(|key| { - !crate::toolset::EPHEMERAL_OPT_KEYS.contains(&key.as_str()) - && !crate::backend::static_helpers::LISTING_ONLY_OPT_KEYS.contains(&key.as_str()) - }) -} - -/// Whether a variant `a` was made with the settings a bare version's identity `b` -/// records: every option of `b` (which a bare version gets only from settings) is -/// one `a` has too. -fn same_settings(a: &InstallIdentity, b: &InstallIdentity) -> bool { +/// Whether an installation made for `a` can stand for a request for `b`: it was +/// made with every install option `b` has (the request's own, its registry's +/// defaults, the settings that take part), and more besides. A version named +/// without options thus finds an installation a configuration made with some, +/// and never one made with different values for the options it has. +fn extends(a: &InstallIdentity, b: &InstallIdentity) -> bool { b.options .iter() .all(|(key, value)| a.options.get(key) == Some(value)) @@ -579,7 +570,7 @@ fn variant_installations(ba: &crate::args::BackendArg, identity: &InstallIdentit if record.identity.version == identity.version && record.identity.platform == identity.platform && same_graph(&record.identity, Some(identity)) - && same_settings(&record.identity, identity) + && extends(&record.identity, identity) && request_of(&record.identity) != own && is_complete(&dir) && belongs_to(ba, &record, &dir) @@ -969,11 +960,12 @@ pub(crate) fn installs_matching(backend: &str, version: &str) -> Vec<(String, Op out } -/// The installations of `tv`'s tool and version on this platform made with other -/// install options than `tv` carries. A version named on the command line carries -/// no options, so `mise where tool@1.0` has to find a copy made with the options a -/// configuration file sets. Installations of `tv`'s own request are not variants: -/// which of those it uses is its selection's business, never a guess here. +/// The installations of `tv`'s tool and version on this platform made with the +/// install options `tv` carries and more (see `extends`). A version named on the +/// command line carries none of its own, so `mise where tool@1.0` has to find a +/// copy made with the options a configuration file sets. Installations of `tv`'s +/// own request are not variants: which of those it uses is its selection's +/// business, never a guess here. pub fn variants_of(tv: &ToolVersion) -> Vec { if !enabled() { return vec![]; @@ -985,11 +977,6 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { bare.install_path = None; let own_identity = identity_of(&bare); let own_request = own_identity.as_ref().map(request_of); - // A request with options of its own (named, or set by a configuration) means - // exactly those; only a bare version can stand for a variant. - if sets_install_options(tv) { - return vec![]; - } // The request has installations of its own, or a selection: which one it uses // is for the selection to say (or the user, when that is ambiguous), so a // variant made with other options is no stand-in for it. @@ -1010,7 +997,7 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { && same_graph(&record.identity, own_identity.as_ref()) && own_identity .as_ref() - .is_none_or(|own| same_settings(&record.identity, own)) + .is_none_or(|own| extends(&record.identity, own)) && own_request .as_ref() .is_none_or(|own| request_of(&record.identity) != *own) From 58a3fc10f0f236643ec525d47332ac86ced4d3c1 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:24:43 +0000 Subject: [PATCH 14/19] fix(install): only a version with no options of its own stands for an installation made with more A version given options by a configuration (install_env, say) could take an installation made with those options and others, such as a build with extra features, and `mise x` would run it. A version stands for such an installation only when it sets no options of its own: its options are those the same version named alone gets from the tool's registry entry, backend alias and settings. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45F6QMR812GP8A35YN1G4GQ --- e2e/cli/test_install_layout_cli_variants | 15 ++++++++++++++ src/install_layout/resolver.rs | 26 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index 5a4a70a67ee..7279a790f8c 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -104,6 +104,21 @@ tinier_dir="$(mise -C "$work/aliased" where tinier)" [[ $tinier_dir == "$MISE_DATA_DIR/installs/"tiny-* ]] || fail "$tinier_dir is not an identity installation" assert_fail "mise where tiny@2.1.0" +echo "=== a version a configuration gives options never takes an installation with more ===" +mkdir env2 more +cat >env2/mise.toml <<'EOF2' +[tools] +tiny = { version = "1.0.0", install_env = { TINY_BUILD = "1" } } +EOF2 +cat >more/mise.toml <<'EOF2' +[tools] +tiny = { version = "1.0.0", install_env = { TINY_BUILD = "1" }, flavor = "full" } +EOF2 +assert "mise -C $work/more install" +assert_fail "mise -C $work/env2 where tiny@1.0.0" +# while the version named alone does find it +assert "mise where tiny@1.0.0" "$(mise -C "$work/more" where tiny)" + echo "=== a version named with options a configuration sets never stands in for another variant ===" mkdir minimal cat >minimal/mise.toml <<'EOF2' diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index 6f279101572..226f92f6e61 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -390,7 +390,8 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { // Nothing of the request's own: a bare version named on the command // line may still stand for a variant, after a legacy install (below). Unlocked::Nothing => { - try_variant = matches!(tv.request.source(), crate::toolset::ToolSource::Argument); + try_variant = matches!(tv.request.source(), crate::toolset::ToolSource::Argument) + && is_bare(tv, &identity); } } } else { @@ -537,6 +538,21 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { } } +/// Whether the request `identity` came from sets no install options of its own: +/// its options are those the same version named with nothing else gets (from the +/// tool's registry entry or backend alias, and from settings). +fn is_bare(tv: &ToolVersion, identity: &InstallIdentity) -> bool { + let Ok(plain) = ToolRequest::new( + std::sync::Arc::new(tv.ba().clone()), + &tv.version, + crate::toolset::ToolSource::Argument, + ) else { + return false; + }; + identity_of(&ToolVersion::new(plain, tv.version.clone())) + .is_some_and(|plain| plain.options == identity.options) +} + /// Whether an installation made for `a` can stand for a request for `b`: it was /// made with every install option `b` has (the request's own, its registry's /// defaults, the settings that take part), and more besides. A version named @@ -976,6 +992,14 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { let mut bare = tv.clone(); bare.install_path = None; let own_identity = identity_of(&bare); + // A request with options of its own (named, or set by a configuration) means + // exactly those; only a bare version can stand for a variant. + if own_identity + .as_ref() + .is_some_and(|identity| !is_bare(tv, identity)) + { + return vec![]; + } let own_request = own_identity.as_ref().map(request_of); // The request has installations of its own, or a selection: which one it uses // is for the selection to say (or the user, when that is ambiguous), so a From 9bb9392e5aa4149f93609696590a2ffefe6cf31f Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:33:14 +0000 Subject: [PATCH 15/19] fix(install): count options written inline on the command line as the version's own The plain request a version is compared with was built from its backend argument, which carries options written inline (`tool[opt=value]@1.0`), so such a version counted as having no options of its own and could take an installation made with more. It is now built from the tool's name alone. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45FPADWM2SBGQ1NDG1CTTFY --- e2e/cli/test_install_layout_cli_variants | 2 ++ src/install_layout/resolver.rs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index 7279a790f8c..710121f2906 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -118,6 +118,8 @@ assert "mise -C $work/more install" assert_fail "mise -C $work/env2 where tiny@1.0.0" # while the version named alone does find it assert "mise where tiny@1.0.0" "$(mise -C "$work/more" where tiny)" +# options written on the command line are the version's own too +assert_fail "mise where 'tiny[flavor=full]@1.0.0'" echo "=== a version named with options a configuration sets never stands in for another variant ===" mkdir minimal diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index 226f92f6e61..d9633236894 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -542,8 +542,10 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { /// its options are those the same version named with nothing else gets (from the /// tool's registry entry or backend alias, and from settings). fn is_bare(tv: &ToolVersion, identity: &InstallIdentity) -> bool { + // From the tool's name alone: options written inline (`tool[opt=value]`) are + // the request's own, and the plain request does not have them. let Ok(plain) = ToolRequest::new( - std::sync::Arc::new(tv.ba().clone()), + std::sync::Arc::new(crate::args::BackendArg::from(tv.ba().short.as_str())), &tv.version, crate::toolset::ToolSource::Argument, ) else { From bbd7741ed772d908b94fbdd4bf293cd8f0e025dc Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:48:53 +0000 Subject: [PATCH 16/19] fix(install): find a lone variant in a shared root whose catalog is gone Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45GJZK2SC1TZHA1QE3AMYYH --- e2e/cli/test_install_layout_cli_variants | 10 ++++++++++ src/install_layout/resolver.rs | 21 +++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index 710121f2906..855e58ec350 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -121,6 +121,16 @@ assert "mise where tiny@1.0.0" "$(mise -C "$work/more" where tiny)" # options written on the command line are the version's own too assert_fail "mise where 'tiny[flavor=full]@1.0.0'" +echo "=== a lone variant in a shared root whose catalog is gone still stands in ===" +mkdir sharedvar +cat >sharedvar/mise.toml <<'EOF2' +[tools] +tiny = { version = "1.0.1", flavor = "full" } +EOF2 +assert "MISE_INSTALLS_DIR='$HOME/shared-variants' mise -C $work/sharedvar install" +mv "$HOME/shared-variants/.mise" "$HOME/shared-variants/.mise.lost" +assert_matches "MISE_SHARED_INSTALL_DIRS='$HOME/shared-variants' mise where tiny@1.0.1" "^$HOME/shared-variants/tiny-" + echo "=== a version named with options a configuration sets never stands in for another variant ===" mkdir minimal cat >minimal/mise.toml <<'EOF2' diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index d9633236894..562732e8f49 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -514,12 +514,7 @@ fn unlocked_choice(key: &InstallIdentity) -> Unlocked { let catalog = Catalog::new(&root); // A shared root's catalog cannot be rebuilt here; if it is gone, the // installations it listed still count, by their receipts. - let records = if is_primary_root(&root) { - catalog.records_for_backend(&key.backend) - } else { - catalog.records_or_receipts_for_backend(&key.backend) - }; - for record in records { + for record in records_of(&catalog, &root, &key.backend) { let dir = catalog.install_dir(&record); if same_request(&record.identity, key) && is_complete(&dir) { complete.push(Located { @@ -575,6 +570,16 @@ fn same_graph(a: &InstallIdentity, b: Option<&InstallIdentity>) -> bool { .all(|k| a.inputs.get(*k) == b.and_then(|b| b.inputs.get(*k))) } +/// The records of `backend` in the catalog of `root`; for a shared root whose +/// catalog is gone, its installations' receipts. +fn records_of(catalog: &Catalog, root: &Path, backend: &str) -> Vec { + if is_primary_root(root) { + catalog.records_for_backend(backend) + } else { + catalog.records_or_receipts_for_backend(backend) + } +} + /// The complete installations, in every root, of `identity`'s backend, version and /// platform made for another request (other install options) of the tool `ba`; /// another tool aliasing the same backend (`oxfmt` and `oxlint`) has its own. @@ -583,7 +588,7 @@ fn variant_installations(ba: &crate::args::BackendArg, identity: &InstallIdentit let mut out = vec![]; for root in roots() { let catalog = Catalog::new(&root); - for record in catalog.records_for_backend(&identity.backend) { + for record in records_of(&catalog, &root, &identity.backend) { let dir = catalog.install_dir(&record); if record.identity.version == identity.version && record.identity.platform == identity.platform @@ -1016,7 +1021,7 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { let mut out = vec![]; for root in roots() { let catalog = Catalog::new(&root); - for record in catalog.records_for_backend(&backend) { + for record in records_of(&catalog, &root, &backend) { let dir = catalog.install_dir(&record); if record.identity.version == version && record.identity.platform == platform From e0b489d596ddabf93d4125011c267001ce41e28b Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:08:00 +0000 Subject: [PATCH 17/19] fix(install): count variants by the options they were made with, not by copies Two copies made with the same options (in another root, or an older refresh generation) counted as two variants, so a bare version found none to stand for and `mise where` listed both. Variants are now one per set of options, the set's own selection or only copy. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45HNZ0169QPQPC03QJXJ8BN --- e2e/cli/test_install_layout_cli_variants | 7 +++ src/install_layout/resolver.rs | 66 ++++++++++++++---------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index 855e58ec350..e84d4768f95 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -130,6 +130,13 @@ EOF2 assert "MISE_INSTALLS_DIR='$HOME/shared-variants' mise -C $work/sharedvar install" mv "$HOME/shared-variants/.mise" "$HOME/shared-variants/.mise.lost" assert_matches "MISE_SHARED_INSTALL_DIRS='$HOME/shared-variants' mise where tiny@1.0.1" "^$HOME/shared-variants/tiny-" +# a second copy made with the same options is the same variant: its selection says which +export MISE_SHARED_INSTALL_DIRS="$HOME/shared-variants" +assert "mise -C $work/sharedvar install --force" +own_copy="$(mise -C "$work/sharedvar" where tiny)" +[[ $own_copy == "$MISE_DATA_DIR/installs/"tiny-* ]] || fail "$own_copy is not in the user's root" +assert "mise where tiny@1.0.1" "$own_copy" +unset MISE_SHARED_INSTALL_DIRS echo "=== a version named with options a configuration sets never stands in for another variant ===" mkdir minimal diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index 562732e8f49..3a97131b388 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -426,7 +426,7 @@ pub(crate) fn locate(tv: &ToolVersion) -> Option { // for it (see `extends`). With several, which is meant is not known. It is // not cached: the cache is keyed by the request, and the same request from a // configuration file does not stand for a variant. - if try_variant && let [only] = variant_installations(tv.ba(), &identity).as_slice() { + if try_variant && let [only] = variant_choices(tv.ba(), &identity).as_slice() { return Some(only.clone()); } let primary = Catalog::new(dirs::INSTALLS.to_path_buf()); @@ -570,6 +570,36 @@ fn same_graph(a: &InstallIdentity, b: Option<&InstallIdentity>) -> bool { .all(|k| a.inputs.get(*k) == b.and_then(|b| b.inputs.get(*k))) } +/// The installations a bare version could stand for, one for each set of options +/// they were made with: copies of one option set (another root, an older refresh +/// generation) are that set's own choice, its selection or its only copy. A set +/// whose copies are not settled lists them all, for the user to select. +fn variant_choices(ba: &crate::args::BackendArg, identity: &InstallIdentity) -> Vec { + let mut sets: Vec<(InstallIdentity, Vec)> = vec![]; + for located in variant_installations(ba, identity) { + let Some(record) = &located.record else { + continue; + }; + let key = request_of(&record.identity); + match sets.iter_mut().find(|(k, _)| *k == key) { + Some((_, copies)) => copies.push(located), + None => sets.push((key, vec![located])), + } + } + sets.into_iter() + .flat_map(|(key, copies)| { + if copies.len() == 1 { + return copies; + } + match unlocked_choice(&key) { + Unlocked::Selected(located) if located.installed => vec![located], + Unlocked::Found(located) => vec![located], + _ => copies, + } + }) + .collect() +} + /// The records of `backend` in the catalog of `root`; for a shared root whose /// catalog is gone, its installations' receipts. fn records_of(catalog: &Catalog, root: &Path, backend: &str) -> Vec { @@ -993,9 +1023,6 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { if !enabled() { return vec![]; } - let Ok(b) = tv.backend() else { - return vec![]; - }; let mut bare = tv.clone(); bare.install_path = None; let own_identity = identity_of(&bare); @@ -1016,30 +1043,13 @@ pub fn variants_of(tv: &ToolVersion) -> Vec { { return vec![]; } - let backend = canonical_backend(&tv.ba().full()); - let (version, platform) = (tv.logical_pathname(), b.get_platform_key()); - let mut out = vec![]; - for root in roots() { - let catalog = Catalog::new(&root); - for record in records_of(&catalog, &root, &backend) { - let dir = catalog.install_dir(&record); - if record.identity.version == version - && record.identity.platform == platform - && same_graph(&record.identity, own_identity.as_ref()) - && own_identity - .as_ref() - .is_none_or(|own| extends(&record.identity, own)) - && own_request - .as_ref() - .is_none_or(|own| request_of(&record.identity) != *own) - && is_complete(&dir) - && belongs_to(tv.ba(), &record, &dir) - { - out.push(dir); - } - } - } - out + let Some(own_identity) = own_identity else { + return vec![]; + }; + variant_choices(tv.ba(), &own_identity) + .into_iter() + .map(|located| located.dir) + .collect() } /// The path to put on PATH for an unlocked `tv`: the link users know From f02d5f528f70118a1e92c3e65b9490aa5cbeae4f Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:45:38 +0000 Subject: [PATCH 18/19] fix(install): take an option set's selected copy only if it is one of this tool's Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45KTWV7M7GMVJ29BHHKA9TX --- src/install_layout/resolver.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index 3a97131b388..d9a633d057e 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -591,9 +591,13 @@ fn variant_choices(ba: &crate::args::BackendArg, identity: &InstallIdentity) -> if copies.len() == 1 { return copies; } + // The set's own choice, if it is one of this tool's copies. match unlocked_choice(&key) { - Unlocked::Selected(located) if located.installed => vec![located], - Unlocked::Found(located) => vec![located], + Unlocked::Selected(located) | Unlocked::Found(located) + if located.installed && copies.iter().any(|c| c.dir == located.dir) => + { + vec![located] + } _ => copies, } }) From 5b5803a9cf3b2722d9b578a4ae90b96936d6324c Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:59:28 +0000 Subject: [PATCH 19/19] fix(install): never take another copy of an option set whose selected copy is gone A bare `tool@version` on the command line stands for a variant installed with options. When one option set had two copies (one in the primary root and one in a shared root) and its selected copy had been pruned, `variant_choices` returned the surviving copy, so the command ran a build the user had not selected. It now consults the set's selection even when one copy remains: a selected copy that is gone stands for nothing. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M45MM7AC4QBS3WMM3E49S0HF --- e2e/cli/test_install_layout_cli_variants | 3 +++ src/install_layout/resolver.rs | 22 ++++++++++------------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/e2e/cli/test_install_layout_cli_variants b/e2e/cli/test_install_layout_cli_variants index e84d4768f95..1c52e16b412 100644 --- a/e2e/cli/test_install_layout_cli_variants +++ b/e2e/cli/test_install_layout_cli_variants @@ -136,6 +136,9 @@ assert "mise -C $work/sharedvar install --force" own_copy="$(mise -C "$work/sharedvar" where tiny)" [[ $own_copy == "$MISE_DATA_DIR/installs/"tiny-* ]] || fail "$own_copy is not in the user's root" assert "mise where tiny@1.0.1" "$own_copy" +# with the selected copy gone, the other copy is not taken in its place +rm -rf "$own_copy" +assert_fail "mise where tiny@1.0.1" unset MISE_SHARED_INSTALL_DIRS echo "=== a version named with options a configuration sets never stands in for another variant ===" diff --git a/src/install_layout/resolver.rs b/src/install_layout/resolver.rs index d9a633d057e..a46efc50ab9 100644 --- a/src/install_layout/resolver.rs +++ b/src/install_layout/resolver.rs @@ -573,7 +573,8 @@ fn same_graph(a: &InstallIdentity, b: Option<&InstallIdentity>) -> bool { /// The installations a bare version could stand for, one for each set of options /// they were made with: copies of one option set (another root, an older refresh /// generation) are that set's own choice, its selection or its only copy. A set -/// whose copies are not settled lists them all, for the user to select. +/// whose selected copy is gone stands for nothing; one whose copies are not +/// settled lists them all, for the user to select. fn variant_choices(ba: &crate::args::BackendArg, identity: &InstallIdentity) -> Vec { let mut sets: Vec<(InstallIdentity, Vec)> = vec![]; for located in variant_installations(ba, identity) { @@ -587,19 +588,16 @@ fn variant_choices(ba: &crate::args::BackendArg, identity: &InstallIdentity) -> } } sets.into_iter() - .flat_map(|(key, copies)| { - if copies.len() == 1 { - return copies; - } + .flat_map(|(key, copies)| match unlocked_choice(&key) { // The set's own choice, if it is one of this tool's copies. - match unlocked_choice(&key) { - Unlocked::Selected(located) | Unlocked::Found(located) - if located.installed && copies.iter().any(|c| c.dir == located.dir) => - { - vec![located] - } - _ => copies, + Unlocked::Selected(located) | Unlocked::Found(located) + if located.installed && copies.iter().any(|c| c.dir == located.dir) => + { + vec![located] } + // Its selected copy is gone (pruned): no other copy is taken for it. + Unlocked::Selected(located) if !located.installed => vec![], + _ => copies, }) .collect() }