diff --git a/e2e/lockfile/test_lockfile_idiomatic_version_file b/e2e/lockfile/test_lockfile_idiomatic_version_file new file mode 100644 index 0000000000..ed85c3497f --- /dev/null +++ b/e2e/lockfile/test_lockfile_idiomatic_version_file @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +export MISE_LOCKFILE=1 + +detect_platform +PLATFORM="$MISE_PLATFORM" + +cat <<'EOF' >mise.toml +[settings] +idiomatic_version_file_enable_tools = ["node"] +lockfile = true +EOF + +echo "24" >.node-version + +assert_contains "mise ls --current node" ".node-version" + +mise lock --platform "$PLATFORM" + +assert_contains "cat mise.lock" '[[tools.node]]' +assert_contains "cat mise.lock" 'backend = "core:node"' +assert_contains "cat mise.lock" "\"platforms.$PLATFORM\"" + +assert_contains "mise install --locked --dry-run node 2>&1" "would install" + +rm -f mise.toml mise.lock .node-version + +cat <<'EOF' >mise.toml +[settings] +idiomatic_version_file_enable_tools = ["dummy"] +lockfile = true + +[tools] +dummy = "2" +EOF + +echo "1" >.dummy-version + +output=$(mise lock --dry-run --platform "$PLATFORM" 2>&1) +assert_contains "echo '$output'" "dummy@1.1.0" +assert_contains "echo '$output'" "dummy@2.0.0" + +rm -f mise.toml .dummy-version + +cat <<'EOF' >mise.toml +[settings] +idiomatic_version_file_enable_tools = ["dummy"] +lockfile = true +EOF + +echo "1" >.dummy-version +touch mise.lock + +mise install dummy + +assert_contains "cat mise.lock" '[[tools.dummy]]' +assert_contains "cat mise.lock" 'backend = "asdf:dummy"' +assert "mise install --locked --dry-run dummy >/dev/null 2>&1" + +rm -f mise.toml mise.lock .dummy-version diff --git a/src/cli/lock.rs b/src/cli/lock.rs index 6b5822f85d..574f11a418 100644 --- a/src/cli/lock.rs +++ b/src/cli/lock.rs @@ -516,11 +516,17 @@ impl Lock { // First pass: tools from the resolved toolset whose source maps to this lockfile for (backend, tv) in ts.list_current_versions() { - if let Some(source_path) = tv.request.source().path() { - let (source_lockfile, _) = lockfile::lockfile_path_for_config(source_path); + if let Some((source_lockfile, _)) = + lockfile::lockfile_path_for_tool_source(config, tv.request.source()) + { if source_lockfile != target_lockfile_path { continue; } + } else if tv.request.source().path().is_some() { + // Path-backed sources that do not map to a mise lockfile, such + // as .tool-versions and tool stubs, should not be folded into + // an arbitrary project mise.lock. + continue; } else { // Tools without a source path (env vars, CLI args) go to mise.lock only let is_base_lockfile = target_lockfile_path @@ -545,11 +551,16 @@ impl Lock { // Second pass: iterate config files matching this lockfile to catch // tools that were overridden by a higher-priority config for (path, cf) in config.config_files.iter() { - if !config_paths_set.contains(path) { + let source = cf.source(); + let source_lockfile_matches = lockfile::lockfile_path_for_tool_source(config, &source) + .is_some_and(|(source_lockfile, _)| source_lockfile == target_lockfile_path); + if !(config_paths_set.contains(path) + || source.is_idiomatic_version_file() && source_lockfile_matches) + { continue; } if let Ok(trs) = cf.to_tool_request_set() { - for (ba, requests, _source) in trs.iter() { + for (ba, requests, source) in trs.iter() { for request in requests { if ba.backend().is_ok() { // Check if the resolved toolset has a matching request. @@ -568,11 +579,13 @@ impl Lock { } } } - // Resolve overridden `latest` requests through the same path as - // active tools. When an install-before cutoff is active, bypass - // installed-version selection so the fallback still uses release - // dates from the remote version metadata. - if !matched_resolved && request.version() == "latest" { + // Resolve overridden requests through the same path as active + // tools when the request cannot be copied from the resolved + // toolset. Keep this broad only for idiomatic version files; + // other sources preserve the previous latest-only behavior. + let should_resolve_overridden = + request.version() == "latest" || source.is_idiomatic_version_file(); + if !matched_resolved && should_resolve_overridden { let mut resolve_options = match request .resolve_options(base_resolve_options) { diff --git a/src/config/mod.rs b/src/config/mod.rs index eca299731c..5b72d11b9e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1742,7 +1742,7 @@ pub async fn rebuild_shims_and_runtime_symlinks( let pre_install_platforms = if new_versions.is_empty() { Default::default() } else { - lockfile::snapshot_pre_install_platforms(new_versions) + lockfile::snapshot_pre_install_platforms(config, new_versions) }; measure!("updating lockfiles", { lockfile::update_lockfiles(config, ts, new_versions, lockfile_update_mode) @@ -1751,6 +1751,7 @@ pub async fn rebuild_shims_and_runtime_symlinks( if !new_versions.is_empty() { measure!("auto-locking platforms", { lockfile::auto_lock_new_versions( + config, new_versions, &pre_install_platforms, lockfile_update_mode, diff --git a/src/lockfile.rs b/src/lockfile.rs index b6aa1bf0b2..79035aee5c 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -894,6 +894,38 @@ pub fn lockfile_path_for_config(config_path: &Path) -> (PathBuf, bool) { (lockfile_dir.join(lockfile_name), is_local) } +/// Determines the lockfile path for a tool source. +/// +/// Idiomatic version files are not config files themselves, so their lock entries +/// belong to the nearest active mise config root that contains the version file. +pub fn lockfile_path_for_tool_source( + config: &Config, + source: &ToolSource, +) -> Option<(PathBuf, bool)> { + match source { + ToolSource::MiseToml(path) => Some(lockfile_path_for_config(path)), + ToolSource::IdiomaticVersionFile(path) => config + .config_files + .iter() + .filter(|(_, cf)| cf.source().is_mise_toml()) + .filter_map(|(config_path, cf)| { + let root = cf.project_root().unwrap_or_else(|| cf.config_root()); + let is_base = !is_local_config(config_path) + && extract_env_from_config_path(config_path).is_none(); + path.starts_with(&root).then(|| { + ( + root.components().count(), + is_base, + lockfile_path_for_config(config_path), + ) + }) + }) + .max_by_key(|(root_depth, is_base, _)| (*root_depth, *is_base)) + .map(|(_, _, lockfile)| lockfile), + _ => None, + } +} + /// Checks if a config path is a "local" config (should go to mise.local.lock) fn is_local_config(path: &Path) -> bool { let filename = path @@ -997,18 +1029,12 @@ pub fn update_lockfiles( // Process each lockfile, deferring provenance errors until all lockfiles are saved. let mut provenance_errors: Vec = Vec::new(); - for (lockfile_path, configs) in lockfile_configs { + for (lockfile_path, _configs) in lockfile_configs { // Only update existing lockfiles - creation is done elsewhere (e.g., by `mise lock`) if !lockfile_path.exists() { continue; } - trace!( - "updating lockfile {} from {} config files", - display_path(&lockfile_path), - configs.len() - ); - let mut existing_lockfile = Lockfile::read(&lockfile_path) .unwrap_or_else(|err| handle_lockfile_read_error(err, &lockfile_path)); @@ -1017,10 +1043,14 @@ pub fn update_lockfiles( // fuzzy request may still resolve through the old lockfile entry until // this update is written. let mut tool_versions_by_short: HashMap> = HashMap::new(); + let mut contributing_sources = 0; - for config_path in &configs { - let tool_source = ToolSource::MiseToml(config_path.clone()); - if let Some(tools) = tools_by_source.get(&tool_source) { + for (source, tools) in &tools_by_source { + let Some((source_lockfile, _)) = lockfile_path_for_tool_source(config, source) else { + continue; + }; + if source_lockfile == lockfile_path { + contributing_sources += 1; for (short, tvl) in tools { tool_versions_by_short .entry(short.clone()) @@ -1030,9 +1060,17 @@ pub fn update_lockfiles( } } + trace!( + "updating lockfile {} from {} source(s)", + display_path(&lockfile_path), + contributing_sources + ); + for new_version in new_versions { - if let Some(source_path) = new_version.request.source().path() { - if !configs.iter().any(|config| config == source_path) { + if let Some((source_lockfile, _)) = + lockfile_path_for_tool_source(config, new_version.request.source()) + { + if source_lockfile != lockfile_path { continue; } @@ -1232,17 +1270,15 @@ fn check_provenance_regression( /// lockfile (existing entries are authoritative) apart from a fresh one whose only /// current-platform entry was just added by this install. pub fn snapshot_pre_install_platforms( + config: &Config, new_versions: &[ToolVersion], ) -> HashMap> { let mut result: HashMap> = HashMap::new(); for tv in new_versions { - if !tv.request.source().is_mise_toml() { - continue; - } - let Some(source_path) = tv.request.source().path() else { + let Some((lockfile_path, _)) = lockfile_path_for_tool_source(config, tv.request.source()) + else { continue; }; - let (lockfile_path, _) = lockfile_path_for_config(source_path); if result.contains_key(&lockfile_path) { continue; } @@ -1331,6 +1367,7 @@ pub fn determine_existing_platforms(lockfile_path: &Path) -> Result>, mode: LockfileUpdateMode, @@ -1342,14 +1379,11 @@ pub async fn auto_lock_new_versions( return Ok(()); } - // Group new_versions by lockfile path (only mise.toml sources, matching update_lockfiles) + // Group new_versions by lockfile path, matching update_lockfiles. let mut versions_by_lockfile: HashMap> = HashMap::new(); for tv in new_versions { - if !tv.request.source().is_mise_toml() { - continue; - } - if let Some(source_path) = tv.request.source().path() { - let (lockfile_path, _) = lockfile_path_for_config(source_path); + if let Some((lockfile_path, _)) = lockfile_path_for_tool_source(config, tv.request.source()) + { versions_by_lockfile .entry(lockfile_path) .or_default()