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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions e2e/cli/test_hook_env_no_remote_fetch
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env bash

# Regression test for https://github.com/jdx/mise/discussions/10308
#
# `mise hook-env` runs on every shell prompt, so it must NEVER fetch remote
# version lists when an installed version satisfies the request. Release-age
# cutoffs (the `minimum_release_age` setting and its built-in default) force
# date-aware remote resolution on regular commands, but prefer-offline paths
# like hook-env must keep resolving from installed versions — otherwise every
# shell prompt becomes a network round-trip per tool.

# Copy the dummy plugin into one whose remote version-listing scripts leave a
# marker file behind when they run, making any remote fetch directly
# observable. (cat > preserves the executable bit from the copied scripts.)
marker="$HOME/remote-versions-fetched"
cp -RL "$MISE_DATA_DIR/plugins/dummy" "$MISE_DATA_DIR/plugins/spy"
cat >"$MISE_DATA_DIR/plugins/spy/bin/list-all" <<EOF
#!/usr/bin/env bash
touch "$marker"
echo "1.0.0 1.1.0 2.0.0"
EOF
cat >"$MISE_DATA_DIR/plugins/spy/bin/latest-stable" <<EOF
#!/usr/bin/env bash
touch "$marker"
echo "2.0.0"
EOF

mise install spy@1.0.0

# Drop the marker and the remote-versions cache written during install. The
# cache must go too: a regressed hook-env with a warm cache would resolve
# remotely without re-running list-all, hiding the fetch from the marker.
rm -f "$marker"
rm -rf "$MISE_CACHE_DIR/spy"

# Fuzzy requests ("latest", a prefix) are the dangerous ones: those are what
# date cutoffs push onto the remote date-aware resolution path. Newer remote
# versions (1.1.0, 2.0.0) exist, so remote resolution would also put a
# non-installed version on PATH and break the `dummy` execution check below.
for mra in unset 24h; do
if [[ $mra == unset ]]; then
unset MISE_MINIMUM_RELEASE_AGE
else
export MISE_MINIMUM_RELEASE_AGE="$mra"
fi

for version in latest 1 1.0.0; do
cat >mise.toml <<EOF
[tools]
spy = "$version"
EOF

# Activate in a subshell and run the installed tool to prove the request
# resolved to the installed version.
actual="$(eval "$(mise hook-env -s bash)" && dummy)" || true

if [[ -e $marker ]]; then
fail "mise hook-env fetched remote versions resolving spy@$version (minimum_release_age=$mra)"
fi
assert_contains_text "$actual" "This is Dummy 1.0.0!"
done
done
32 changes: 32 additions & 0 deletions e2e/cli/test_which_no_remote_fetch
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash

# Regression test for https://github.com/jdx/mise/discussions/10308
#
# The built-in default `minimum_release_age` cutoff must not force remote
# version resolution when an installed version satisfies the request. In
# 2026.6.2 it disabled the installed-version fast paths for every
# non-prefer-offline command (`mise which`, `mise use`, ...), turning each
# invocation into a remote version-list fetch per tool (~65s shell startups).
#
# aqua-backed jq is used because the built-in default applies to it (the asdf
# dummy plugin is exempt). An explicit `minimum_release_age` setting still
# forces date-aware remote resolution — only the built-in default must not.

mise install jq@1.7.1

cat >mise.toml <<EOF
[tools]
jq = "1"
EOF

# Drop the remote-versions cache written during install so a remote fetch is
# observable as the cache file reappearing.
rm -rf "$MISE_CACHE_DIR/jq"

# The fuzzy request must resolve to the installed version from disk.
# shellcheck disable=SC2016 # the expression expands inside the assert helper
assert_contains '"$(mise which jq)" --version' "jq-1.7.1"

if find "$MISE_CACHE_DIR" -name 'remote_versions*' | grep -q .; then
fail "mise which fetched remote versions despite an installed match (the default release-age cutoff must not force remote resolution)"
fi
1 change: 1 addition & 0 deletions src/cli/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ impl Install {
use_locked_version: true,
latest_versions: true,
before_date: self.get_before_date()?,
before_date_from_default: false,
offline: false,
refresh_remote_versions: false,
inactive: false,
Expand Down
35 changes: 15 additions & 20 deletions src/cli/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use crate::cli::args::{BackendArg, ToolArg};
use crate::config::{Config, config_file};
use crate::duration::parse_into_timestamp;
use crate::file::display_path;
use crate::install_before::resolve_before_date_for_tool;
use crate::semver::split_version_prefix;
use crate::toolset::is_outdated_version;
use crate::toolset::outdated_info::OutdatedInfo;
Expand Down Expand Up @@ -126,6 +125,7 @@ impl Upgrade {
use_locked_version: false,
latest_versions: true,
before_date,
before_date_from_default: false,
offline: false,
refresh_remote_versions: false,
inactive: self.inactive,
Expand Down Expand Up @@ -280,6 +280,7 @@ impl Upgrade {
use_locked_version: false,
latest_versions: true,
before_date,
before_date_from_default: false,
offline: false,
refresh_remote_versions: false,
inactive: self.inactive,
Expand Down Expand Up @@ -576,25 +577,19 @@ impl Upgrade {
if !warned.insert(warning_key) {
continue;
}
let before_date = match resolve_before_date_for_tool(
tv.ba(),
opts.before_date,
tv.request.options().minimum_release_age(),
) {
Ok(Some(before_date)) => before_date,
Ok(None) => continue,
Err(err) => {
warn!(
"Error resolving minimum_release_age for {}: {err:#}",
tv.ba()
);
continue;
}
};
let opts_with_effective_before_date = ResolveOptions {
before_date: Some(before_date),
..opts.clone()
};
let mut opts_with_effective_before_date = opts.clone();
if let Err(err) = opts_with_effective_before_date
.apply_before_date_for_tool(tv.ba(), tv.request.options().minimum_release_age())
{
warn!(
"Error resolving minimum_release_age for {}: {err:#}",
tv.ba()
);
continue;
}
if opts_with_effective_before_date.before_date.is_none() {
continue;
}
let eligible_latest = self
.latest_for_upgrade(config, &tv, &opts_with_effective_before_date)
.await;
Expand Down
1 change: 1 addition & 0 deletions src/cli/use.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ impl Use {
latest_versions: false,
use_locked_version: true,
before_date: self.get_before_date()?,
before_date_from_default: false,
offline: false,
refresh_remote_versions: false,
inactive: false,
Expand Down
97 changes: 90 additions & 7 deletions src/install_before.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ use crate::duration::{parse_duration, parse_into_timestamp};

const DEFAULT_MINIMUM_RELEASE_AGE: &str = "24h";

/// Where an effective release-age cutoff came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BeforeDateSource {
/// Pre-resolved by the caller (e.g. the `--minimum-release-age` CLI flag
/// or a `ResolveOptions` cutoff threaded through from another resolution).
/// The caller already knows whether it was explicit or default.
Provided,
/// A per-tool `minimum_release_age` option or the explicit
/// `minimum_release_age` setting.
Explicit,
/// The built-in default for backends that report release timestamps.
/// This only gates which versions remote resolution may pick — it must
/// not disable installed-version fast paths, otherwise every resolution
/// becomes a remote fetch (https://github.com/jdx/mise/discussions/10308).
Default,
}

/// Resolve the effective `minimum_release_age` cutoff.
///
/// Precedence (highest to lowest):
Expand All @@ -29,14 +46,31 @@ pub fn resolve_before_date(
before_date: Option<Timestamp>,
minimum_release_age: Option<&str>,
) -> Result<Option<Timestamp>> {
resolve_before_date_with_excludes(None, before_date, minimum_release_age, false)
Ok(
resolve_before_date_with_excludes(None, before_date, minimum_release_age, false)?
.map(|(ts, _)| ts),
)
}

pub fn resolve_before_date_for_tool(
backend_arg: &BackendArg,
before_date: Option<Timestamp>,
minimum_release_age: Option<&str>,
) -> Result<Option<Timestamp>> {
Ok(
resolve_before_date_for_tool_with_source(backend_arg, before_date, minimum_release_age)?
.map(|(ts, _)| ts),
)
}

/// Like `resolve_before_date_for_tool` but also reports where the cutoff came
/// from, so callers can treat the built-in default differently from explicit
/// configuration.
pub fn resolve_before_date_for_tool_with_source(
backend_arg: &BackendArg,
before_date: Option<Timestamp>,
minimum_release_age: Option<&str>,
) -> Result<Option<(Timestamp, BeforeDateSource)>> {
resolve_before_date_with_excludes(
Some(backend_arg),
before_date,
Expand All @@ -50,24 +84,33 @@ fn resolve_before_date_with_excludes(
before_date: Option<Timestamp>,
minimum_release_age: Option<&str>,
excluded: bool,
) -> Result<Option<Timestamp>> {
) -> Result<Option<(Timestamp, BeforeDateSource)>> {
if let Some(before_date) = before_date {
return Ok(Some(before_date));
return Ok(Some((before_date, BeforeDateSource::Provided)));
}
if let Some(before) = minimum_release_age {
if parse_duration(before).is_ok_and(|duration| duration.is_zero()) {
return Ok(None);
}
return Ok(Some(parse_into_timestamp(before)?));
return Ok(Some((
parse_into_timestamp(before)?,
BeforeDateSource::Explicit,
)));
}
if !excluded && let Some(before) = &Settings::get().minimum_release_age {
if parse_duration(before).is_ok_and(|duration| duration.is_zero()) {
return Ok(None);
}
return Ok(Some(parse_into_timestamp(before)?));
return Ok(Some((
parse_into_timestamp(before)?,
BeforeDateSource::Explicit,
)));
}
if !excluded && backend_arg.is_some_and(default_minimum_release_age_applies) {
return Ok(Some(parse_into_timestamp(DEFAULT_MINIMUM_RELEASE_AGE)?));
return Ok(Some((
parse_into_timestamp(DEFAULT_MINIMUM_RELEASE_AGE)?,
BeforeDateSource::Default,
)));
}
Ok(None)
}
Expand Down Expand Up @@ -139,7 +182,10 @@ pub(crate) async fn resolve_before_date_for_backend<B: Backend + ?Sized>(

#[cfg(test)]
mod tests {
use super::{DEFAULT_MINIMUM_RELEASE_AGE, resolve_before_date, resolve_before_date_for_tool};
use super::{
BeforeDateSource, DEFAULT_MINIMUM_RELEASE_AGE, resolve_before_date,
resolve_before_date_for_tool, resolve_before_date_for_tool_with_source,
};
use crate::cli::args::BackendArg;
use crate::config::settings::{Settings, SettingsPartial};
use confique::Layer;
Expand Down Expand Up @@ -300,6 +346,43 @@ mod tests {
Settings::reset(None);
}

#[test]
fn test_before_date_source_distinguishes_default_from_explicit() {
Settings::reset(None);
let ba: BackendArg = "npm:prettier".into();

// Built-in default → Default source
let (_, source) = resolve_before_date_for_tool_with_source(&ba, None, None)
.unwrap()
.unwrap();
assert_eq!(source, BeforeDateSource::Default);

// Per-tool option → Explicit source
let (_, source) = resolve_before_date_for_tool_with_source(&ba, None, Some("7d"))
.unwrap()
.unwrap();
assert_eq!(source, BeforeDateSource::Explicit);

// Explicit global setting → Explicit source
let mut partial = SettingsPartial::empty();
partial.minimum_release_age = Some("7d".to_string());
Settings::reset(Some(partial));
let (_, source) = resolve_before_date_for_tool_with_source(&ba, None, None)
.unwrap()
.unwrap();
assert_eq!(source, BeforeDateSource::Explicit);
Settings::reset(None);

// Pre-resolved cutoff → Provided source
let cli_before = "2024-01-02T03:04:05Z".parse().unwrap();
let (ts, source) = resolve_before_date_for_tool_with_source(&ba, Some(cli_before), None)
.unwrap()
.unwrap();
assert_eq!(ts, cli_before);
assert_eq!(source, BeforeDateSource::Provided);
Settings::reset(None);
}

#[test]
fn test_effective_before_date_stable_within_process() {
// Covers the invariant behind #9156: relative durations resolve
Expand Down
7 changes: 1 addition & 6 deletions src/toolset/tool_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use crate::cli::args::BackendArg;
use crate::config::config_file::config_root;
use crate::dirs;
use crate::env;
use crate::install_before::resolve_before_date_for_tool;
use crate::lockfile::LockfileTool;
use crate::path::PathExt;
use crate::runtime_symlinks::is_runtime_symlink;
Expand Down Expand Up @@ -376,11 +375,7 @@ impl ToolRequest {
pub fn resolve_options(&self, opts: &ResolveOptions) -> Result<ResolveOptions> {
let minimum_release_age = self.options().minimum_release_age().map(str::to_string);
let mut opts = opts.clone();
opts.before_date = resolve_before_date_for_tool(
self.ba(),
opts.before_date,
minimum_release_age.as_deref(),
)?;
opts.apply_before_date_for_tool(self.ba(), minimum_release_age.as_deref())?;
Ok(opts)
}

Expand Down
Loading
Loading