From 6d8f8e18fe2ffad1f99ccbf6ab72f536d0ba5f23 Mon Sep 17 00:00:00 2001 From: Marukome0743 Date: Tue, 11 Aug 2026 22:45:26 +0900 Subject: [PATCH] fix(use): prefer exact releases when pinning --- docs/cli/use.md | 7 +++- e2e/cli/test_use_pin_exact | 79 +++++++++++++++++++++++++++++++++++++ man/man1/mise.1 | 7 +++- mise.usage.kdl | 13 +++--- src/cli/install.rs | 1 + src/cli/upgrade.rs | 2 + src/cli/use.rs | 11 ++++-- src/toolset/tool_version.rs | 59 +++++++++++++++++++++------ 8 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 e2e/cli/test_use_pin_exact diff --git a/docs/cli/use.md b/docs/cli/use.md index 2e2fc0923ca..9f46f7a6539 100644 --- a/docs/cli/use.md +++ b/docs/cli/use.md @@ -92,8 +92,11 @@ Supports absolute dates like "2024-06-01" and relative durations like "90d" or " ### `--pin` -Save exact version to config file -e.g.: `mise use --pin node@20` will save 20.0.0 as the version +Save the resolved concrete version to the config file + +If the request exactly matches an available release, that release is preferred over +installed fuzzy matches. Use `prefix:` to explicitly request recursive prefix matching. +e.g.: `mise use --pin node@20` will save the resolved `20.x.y` version Set `MISE_PIN=1` to make this the default behavior Consider using mise.lock as a better alternative to pinning in mise.toml: diff --git a/e2e/cli/test_use_pin_exact b/e2e/cli/test_use_pin_exact new file mode 100644 index 00000000000..eec1340e3c6 --- /dev/null +++ b/e2e/cli/test_use_pin_exact @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +# Pinning prefers an exact remote release over a more-specific installed match. +ln -s "$ROOT/test/data/plugins/tiny" "$MISE_DATA_DIR/plugins/tiny-pin" +mkdir -p "$MISE_DATA_DIR/installs/tiny-pin/1.0.0.1" +assert_contains \ + "mise use --pin --dry-run --path pin.toml tiny-pin@1.0.0 2>&1" \ + "tiny-pin@1.0.0 ⇢ would install" + +# An absent exact release, normal fuzzy requests, and explicit prefixes retain +# the existing installed-version fast path. +assert_contains \ + "mise use --pin --dry-run --path pin.toml tiny-pin@1.0 2>&1" \ + "tiny-pin@1.0.0.1 ⇢ already installed" +assert_contains \ + "mise use --dry-run --path pin.toml tiny-pin@1.0.0 2>&1" \ + "tiny-pin@1.0.0.1 ⇢ already installed" +assert_contains \ + "mise use --pin --dry-run --path pin.toml tiny-pin@prefix:1.0.0 2>&1" \ + "tiny-pin@1.0.0.1 ⇢ already installed" + +# Settings-based pinning uses the same behavior, while --fuzzy overrides it. +assert_contains \ + "MISE_PIN=1 mise use --dry-run --path pin.toml tiny-pin@1.0.0 2>&1" \ + "tiny-pin@1.0.0 ⇢ would install" +assert_contains \ + "MISE_ASDF_COMPAT=1 mise use --dry-run --path pin.toml tiny-pin@1.0.0 2>&1" \ + "tiny-pin@1.0.0 ⇢ would install" +assert_contains \ + "MISE_PIN=1 mise use --fuzzy --dry-run --path pin.toml tiny-pin@1.0.0 2>&1" \ + "tiny-pin@1.0.0.1 ⇢ already installed" + +# Explicit offline mode cannot verify remote releases and keeps local resolution. +assert_contains \ + "MISE_OFFLINE=1 mise use --pin --dry-run --path pin.toml tiny-pin@1.0.0 2>&1" \ + "tiny-pin@1.0.0.1 ⇢ already installed" + +mise use --pin --path pin.toml tiny-pin@1.0.0 +assert "cat pin.toml" '[tools] +tiny-pin = "1.0.0"' + +# An exact installed version does not need a remote lookup. +ln -s "$ROOT/test/data/plugins/tiny" "$MISE_DATA_DIR/plugins/tiny-pin-installed" +mkdir -p "$MISE_DATA_DIR/installs/tiny-pin-installed/1.0.0" +assert_contains \ + "RTX_TINY_LIST_ALL_FAIL=1 mise use --pin --dry-run --path installed.toml tiny-pin-installed@1.0.0 2>&1" \ + "tiny-pin-installed@1.0.0 ⇢ already installed" + +# Online exact-match lookup failures must not silently pin a fuzzy installed version. +ln -s "$ROOT/test/data/plugins/tiny" "$MISE_DATA_DIR/plugins/tiny-pin-fail" +mkdir -p "$MISE_DATA_DIR/installs/tiny-pin-fail/1.0.0.1" +assert_fail \ + "RTX_TINY_LIST_ALL_FAIL=1 mise use --pin --path pin-fail.toml tiny-pin-fail@1.0.0" \ + "error running list-all" +assert_fail "test -f pin-fail.toml" + +# Backends whose exact resolver only validates syntax must not treat a +# syntactically valid but unavailable release as proof that it exists. +NPM_BIN="$TMPDIR/npm-bin" +mkdir -p "$NPM_BIN" "$MISE_DATA_DIR/installs/npm-syntax-only/1.0.0+local.1" +cat >"$MISE_DATA_DIR/installs/npm-syntax-only/.mise.backend.toml" <<'EOF' +short = "npm:syntax-only" +full = "npm:syntax-only" +explicit_backend = true +EOF +cat >"$NPM_BIN/npm" <<'EOF' +#!/usr/bin/env bash +if [[ $1 == view && $3 == versions ]]; then + echo '{"versions":["1.0.1"],"time":{}}' + exit 0 +fi +echo "unexpected npm invocation: $*" >&2 +exit 1 +EOF +chmod +x "$NPM_BIN/npm" + +assert_contains \ + "PATH='$NPM_BIN:$PATH' MISE_NPM_SHELL_OUT=1 mise use --pin --dry-run --path npm.toml npm:syntax-only@1.0.0 2>&1" \ + "npm:syntax-only@1.0.0+local.1 ⇢ already installed" diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 7670684095d..45865dcdf7b 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -4846,8 +4846,11 @@ Only install versions released before this date or older than this duration Supports absolute dates like "2024\-06\-01" and relative durations like "90d" or "1y". .TP \fB\-\-pin\fR -Save exact version to config file -e.g.: `mise use \-\-pin node@20` will save 20.0.0 as the version +Save the resolved concrete version to the config file + +If the request exactly matches an available release, that release is preferred over +installed fuzzy matches. Use `prefix:` to explicitly request recursive prefix matching. +e.g.: `mise use \-\-pin node@20` will save the resolved `20.x.y` version Set `MISE_PIN=1` to make this the default behavior Consider using mise.lock as a better alternative to pinning in mise.toml: diff --git a/mise.usage.kdl b/mise.usage.kdl index d67173e523a..4df10a02235 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -4764,14 +4764,13 @@ Supports absolute dates like "2024-06-01" and relative durations like "90d" or " """# arg } - flag --pin help=#""" -Save exact version to config file -e.g.: `mise use --pin node@20` will save 20.0.0 as the version -Set `MISE_PIN=1` to make this the default behavior -"""# { + flag --pin help="Save the resolved concrete version to the config file" { long_help #""" -Save exact version to config file -e.g.: `mise use --pin node@20` will save 20.0.0 as the version +Save the resolved concrete version to the config file + +If the request exactly matches an available release, that release is preferred over +installed fuzzy matches. Use `prefix:` to explicitly request recursive prefix matching. +e.g.: `mise use --pin node@20` will save the resolved `20.x.y` version Set `MISE_PIN=1` to make this the default behavior Consider using mise.lock as a better alternative to pinning in mise.toml: diff --git a/src/cli/install.rs b/src/cli/install.rs index 2088481903c..a0aab8f6ab7 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -397,6 +397,7 @@ impl Install { resolve_options: ResolveOptions { use_locked_version: true, latest_versions: true, + prefer_exact_version: false, before_date: self.get_before_date()?, before_date_from_default: false, filter_installed_versions_by_release_date: false, diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs index 6d53ea496f0..03f853567f1 100644 --- a/src/cli/upgrade.rs +++ b/src/cli/upgrade.rs @@ -163,6 +163,7 @@ impl Upgrade { let opts = ResolveOptions { use_locked_version: false, latest_versions: true, + prefer_exact_version: false, before_date, before_date_from_default: false, filter_installed_versions_by_release_date: false, @@ -341,6 +342,7 @@ impl Upgrade { resolve_options: ResolveOptions { use_locked_version: false, latest_versions: true, + prefer_exact_version: false, before_date, before_date_from_default: false, filter_installed_versions_by_release_date: false, diff --git a/src/cli/use.rs b/src/cli/use.rs index 3aa309af228..8c53b86bd4a 100644 --- a/src/cli/use.rs +++ b/src/cli/use.rs @@ -105,8 +105,11 @@ pub struct Use { #[clap(long, alias = "before", verbatim_doc_comment)] minimum_release_age: Option, - /// Save exact version to config file - /// e.g.: `mise use --pin node@20` will save 20.0.0 as the version + /// Save the resolved concrete version to the config file + /// + /// If the request exactly matches an available release, that release is preferred over + /// installed fuzzy matches. Use `prefix:` to explicitly request recursive prefix matching. + /// e.g.: `mise use --pin node@20` will save the resolved `20.x.y` version /// Set `MISE_PIN=1` to make this the default behavior /// /// Consider using mise.lock as a better alternative to pinning in mise.toml: @@ -145,9 +148,11 @@ impl Use { .build(&config) .await?; let cf = self.get_config_file().await?; + let pin = self.pin || !self.fuzzy && (Settings::get().pin || Settings::get().asdf_compat); let mut resolve_options = ResolveOptions { latest_versions: false, use_locked_version: true, + prefer_exact_version: pin, before_date: self.get_before_date()?, before_date_from_default: false, filter_installed_versions_by_release_date: false, @@ -192,8 +197,6 @@ impl Use { ) .await?; - let pin = self.pin || !self.fuzzy && (Settings::get().pin || Settings::get().asdf_compat); - for (ba, tvl) in &versions.iter().chunk_by(|tv| tv.ba()) { let versions: Vec<_> = tvl .into_iter() diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index 9e14d484bdd..168ebf7f948 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -282,6 +282,7 @@ impl ToolVersion { let opts = ResolveOptions { latest_versions: true, use_locked_version: false, + prefer_exact_version: false, before_date: base_opts.before_date, before_date_from_default: base_opts.before_date_from_default, filter_installed_versions_by_release_date: base_opts @@ -492,15 +493,38 @@ impl ToolVersion { // through to normal resolution, which still matches the literal // channel name in the backend's version list as before. } - if !opts.latest_versions { - let matches = backend.list_installed_versions_matching(&v); + let installed_matches = + (!opts.latest_versions).then(|| backend.list_installed_versions_matching(&v)); + if installed_matches + .as_ref() + .is_some_and(|matches| matches.contains(&v)) + { + return build(v); + } + + let mut pin_remote_matches = None; + if opts.prefer_exact_version && !is_offline && !crate::semver::is_npm_semver_range_query(&v) + { + // Pinning must prove that the exact release exists before it + // bypasses an installed fuzzy match. Some backend exact resolvers + // only validate syntax and defer existence checks to installation. + let matches = backend + .list_versions_matching_with_opts(config, &v, None, opts.refresh_remote_versions) + .await?; if matches.contains(&v) { return build(v); } - if !should_filter_installed_versions && let Some(v) = matches.last() { - return build(v.clone()); + if opts.before_date.is_none() { + pin_remote_matches = Some(matches); } } + if !should_filter_installed_versions + && let Some(v) = installed_matches + .as_ref() + .and_then(|matches| matches.last()) + { + return build(v.clone()); + } if matches!( request.source(), ToolSource::IdiomaticVersionFile(path) @@ -566,14 +590,19 @@ impl ToolVersion { return build(v); } // First try with date filter (common case) - let matches = backend - .list_versions_matching_with_opts( - config, - &v, - opts.before_date, - opts.refresh_remote_versions, - ) - .await?; + let matches = match pin_remote_matches { + Some(matches) => matches, + None => { + backend + .list_versions_matching_with_opts( + config, + &v, + opts.before_date, + opts.refresh_remote_versions, + ) + .await? + } + }; if matches.contains(&v) { return build(v); } @@ -773,6 +802,8 @@ impl Hash for ToolVersion { pub struct ResolveOptions { pub latest_versions: bool, pub use_locked_version: bool, + /// Prefer an exact remote release over a fuzzy installed match. + pub prefer_exact_version: bool, /// Only consider versions released before this timestamp pub before_date: Option, /// `before_date` came from the built-in default release age rather than @@ -799,6 +830,7 @@ impl Default for ResolveOptions { Self { latest_versions: false, use_locked_version: true, + prefer_exact_version: false, before_date: None, before_date_from_default: false, filter_installed_versions_by_release_date: false, @@ -899,6 +931,9 @@ impl Display for ResolveOptions { if self.use_locked_version { opts.push("use_locked_version".to_string()); } + if self.prefer_exact_version { + opts.push("prefer_exact_version".to_string()); + } if let Some(ts) = &self.before_date { if self.before_date_from_default { opts.push(format!("before_date={ts} (default)"));