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
60 changes: 60 additions & 0 deletions e2e/lockfile/test_lockfile_idiomatic_version_file
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
greptile-apps[bot] marked this conversation as resolved.

rm -f mise.toml mise.lock .dummy-version
31 changes: 22 additions & 9 deletions src/cli/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Comment thread
cursor[bot] marked this conversation as resolved.
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
Expand All @@ -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.
Expand All @@ -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)
{
Expand Down
3 changes: 2 additions & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
80 changes: 57 additions & 23 deletions src/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ambiguous idiomatic lockfile mapping

Medium Severity

When several base mise.toml configs share the same project_root, lockfile_path_for_tool_source picks the target lockfile via max_by_key on (root_depth, is_base) over forward config_files iteration. Ties (e.g. root mise.toml vs .mise/config.toml) keep the first match in map order, not the highest-precedence config used elsewhere, so idiomatic tools can land in the wrong mise.lock.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4af9d2e. Configure here.

_ => 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
Expand Down Expand Up @@ -997,18 +1029,12 @@ pub fn update_lockfiles(
// Process each lockfile, deferring provenance errors until all lockfiles are saved.
let mut provenance_errors: Vec<String> = 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));

Expand All @@ -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<String, Vec<ToolVersion>> = 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())
Expand All @@ -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;
}

Expand Down Expand Up @@ -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<PathBuf, BTreeSet<String>> {
let mut result: HashMap<PathBuf, BTreeSet<String>> = 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;
}
Expand Down Expand Up @@ -1331,6 +1367,7 @@ pub fn determine_existing_platforms(lockfile_path: &Path) -> Result<Vec<Platform
/// so the lockfile is complete and doesn't change when other developers on different
/// platforms run `mise install`.
pub async fn auto_lock_new_versions(
config: &Config,
new_versions: &[ToolVersion],
pre_install_platforms: &HashMap<PathBuf, BTreeSet<String>>,
mode: LockfileUpdateMode,
Expand All @@ -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<PathBuf, Vec<&ToolVersion>> = 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()
Expand Down
Loading