Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
[autofix.ci] apply automated fixes
  • Loading branch information
autofix-ci[bot] authored and jdx committed Nov 28, 2025
commit 61dcb6b490d2689f715fcd86bad0b7967f78e954
6 changes: 6 additions & 0 deletions docs/cli/lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,17 @@ Comma-separated list of platforms to target
e.g.: linux-x64,macos-arm64,windows-x64
If not specified, all platforms already in lockfile will be updated

### `--local`

Update mise.local.lock instead of mise.lock
Use for tools defined in .local.toml configs

Examples:

```
mise lock # update lockfile for all common platforms
mise lock node python # update only node and python
mise lock --platform linux-x64 # update only linux-x64 platform
mise lock --dry-run # show what would be updated
mise lock --local # update mise.local.lock for local configs
```
2 changes: 1 addition & 1 deletion e2e/cli/test_lock
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ output=$(mise lock 2>&1)
assert_contains "echo '$output'" "Targeting 5 platform(s)"
assert_contains "echo '$output'" "Processing 1 tool(s)"
assert_contains "echo '$output'" "Updated"
assert_contains "echo '$output'" "Lockfile written to mise.lock"
assert_contains "echo '$output'" "Lockfile written to"

# Verify lockfile was created with platform data
assert "test -f mise.lock"
Expand Down
4 changes: 4 additions & 0 deletions man/man1/mise.1
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,10 @@ Show what would be updated without making changes
Comma\-separated list of platforms to target
e.g.: linux\-x64,macos\-arm64,windows\-x64
If not specified, all platforms already in lockfile will be updated
.TP
\fB\-\-local\fR
Update mise.local.lock instead of mise.lock
Use for tools defined in .local.toml configs
\fBArguments:\fR
.PP
.TP
Expand Down
3 changes: 2 additions & 1 deletion mise.usage.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -501,14 +501,15 @@ cmd local hide=#true help="Sets/gets tool version in local .tool-versions or mis
}
cmd lock help="Update lockfile checksums and URLs for all specified platforms" {
long_help "Update lockfile checksums and URLs for all specified platforms\n\nUpdates checksums and download URLs for all platforms already specified in the lockfile.\nIf no lockfile exists, shows what would be created based on the current configuration.\nThis allows you to refresh lockfile data for platforms other than the one you're currently on.\nOperates on the lockfile in the current config root. Use TOOL arguments to target specific tools."
after_long_help "Examples:\n\n $ mise lock # update lockfile for all common platforms\n $ mise lock node python # update only node and python\n $ mise lock --platform linux-x64 # update only linux-x64 platform\n $ mise lock --dry-run # show what would be updated\n"
after_long_help "Examples:\n\n $ mise lock # update lockfile for all common platforms\n $ mise lock node python # update only node and python\n $ mise lock --platform linux-x64 # update only linux-x64 platform\n $ mise lock --dry-run # show what would be updated\n $ mise lock --local # update mise.local.lock for local configs\n"
flag "-j --jobs" help="Number of jobs to run in parallel" {
arg <JOBS>
}
flag "-n --dry-run" help="Show what would be updated without making changes"
flag "-p --platform" help="Comma-separated list of platforms to target\ne.g.: linux-x64,macos-arm64,windows-x64\nIf not specified, all platforms already in lockfile will be updated" var=#true {
arg <PLATFORM>
}
flag --local help="Update mise.local.lock instead of mise.lock\nUse for tools defined in .local.toml configs"
arg "[TOOL]…" help="Tool(s) to update in lockfile\ne.g.: node python\nIf not specified, all tools in lockfile will be updated" required=#false var=#true
}
cmd ls help="List installed and active tool versions" {
Expand Down
75 changes: 69 additions & 6 deletions src/cli/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ impl Lock {

// Get toolset and resolve versions
let ts = config.get_toolset().await?;
let tools = self.get_tools_to_lock(ts);
let tools = self.get_tools_to_lock(&config, ts);

if tools.is_empty() {
miseprintln!("{} No tools configured to lock", style("!").yellow());
Expand Down Expand Up @@ -177,13 +177,76 @@ impl Lock {

fn get_tools_to_lock(
&self,
config: &Config,
ts: &Toolset,
) -> Vec<(crate::cli::args::BackendArg, crate::toolset::ToolVersion)> {
let all_tools: Vec<_> = ts
.list_current_versions()
.into_iter()
.map(|(backend, tv)| (backend.ba().as_ref().clone(), tv))
.collect();
// Collect tools from ALL config files (not just resolved current versions)
// This ensures base config tools are included even when overridden by env configs
let mut all_tools: Vec<_> = Vec::new();
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();

// First, get all tools from the resolved toolset (these are the "current" versions)
for (backend, tv) in ts.list_current_versions() {
let key = (backend.ba().short.clone(), tv.version.clone());
if seen.insert(key) {
all_tools.push((backend.ba().as_ref().clone(), tv));
}
}

// Then, iterate ALL config files to find tools that may have been overridden
for (_path, cf) in config.config_files.iter() {
if let Ok(trs) = cf.to_tool_request_set() {
for (ba, requests, _source) in trs.iter() {
for request in requests {
// Try to get a resolved version for this request
if let Ok(backend) = ba.backend() {
// Check if we already have this tool+version in toolset
if let Some(resolved_tv) = ts.versions.get(ba.as_ref()) {
for tv in &resolved_tv.versions {
if tv.request.version() == request.version() {
let key = (ba.short.clone(), tv.version.clone());
if seen.insert(key) {
all_tools.push((ba.as_ref().clone(), tv.clone()));
}
}
}
}
// Also check installed versions that match this request
let installed = backend.list_installed_versions();
if request.version() == "latest" {
// For "latest", find the highest installed version
if let Some(latest_version) = installed.iter().max_by(|a, b| {
versions::Versioning::new(a).cmp(&versions::Versioning::new(b))
}) {
let key = (ba.short.clone(), latest_version.clone());
if seen.insert(key.clone()) {
let tv = crate::toolset::ToolVersion::new(
request.clone(),
latest_version.clone(),
);
all_tools.push((ba.as_ref().clone(), tv));
}
}
} else {
// For prefix requests, find matching versions
for version in installed {
if version.starts_with(&request.version()) {
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
let key = (ba.short.clone(), version.clone());
if seen.insert(key.clone()) {
let tv = crate::toolset::ToolVersion::new(
request.clone(),
version,
);
all_tools.push((ba.as_ref().clone(), tv));
}
}
}
}
}
}
}
}
}

if self.tool.is_empty() {
// Lock all tools
Expand Down
131 changes: 79 additions & 52 deletions src/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,36 @@ impl Lockfile {
/// Determines the lockfile path for a given config file path
/// Returns (lockfile_path, is_local)
pub fn lockfile_path_for_config(config_path: &Path) -> (PathBuf, bool) {
let root = config_root::config_root(config_path);
let is_local = is_local_config(config_path);
let lockfile_name = if is_local {
"mise.local.lock"
} else {
"mise.lock"
};

// Fast path: for simple project configs (mise.toml, mise.local.toml, etc.)
// just use the parent directory. This avoids the expensive config_root call.
if let Some(parent) = config_path.parent() {
let filename = config_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let parent_name = parent
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();

// If the config is directly in a project dir (not in .mise, .config, etc.)
// we can skip the full config_root calculation
if !matches!(parent_name, ".mise" | "mise" | ".config" | "conf.d")
&& (filename.starts_with("mise.") || filename.starts_with(".mise."))
{
return (parent.join(lockfile_name), is_local);
}
}

// Full path calculation for complex cases (.mise/, .config/mise/, etc.)
let root = config_root::config_root(config_path);
(root.join(lockfile_name), is_local)
}

Expand Down Expand Up @@ -357,7 +380,7 @@ pub fn update_lockfiles(config: &Config, ts: &Toolset, new_versions: &[ToolVersi
.unwrap_or_else(|err| handle_missing_lockfile(err, &lockfile_path));

// Retain tools that should stay even if not in current toolset
Comment thread
cursor[bot] marked this conversation as resolved.
existing_lockfile.tools.retain(|k, _| {
existing_lockfile.tools.retain(|k, tools| {
all_tool_names.contains(k)
|| !tool_enabled(
&Settings::get().enable_tools(),
Expand All @@ -367,6 +390,8 @@ pub fn update_lockfiles(config: &Config, ts: &Toolset, new_versions: &[ToolVersi
|| REGISTRY
.get(&k.as_str())
.is_some_and(|rt| !rt.is_supported_os())
// Preserve tools that have env-specific entries (from unloaded env configs)
|| tools.iter().any(|t| t.env.is_some())
});

// Collect all tools from all contributing configs with their env context
Expand Down Expand Up @@ -406,6 +431,7 @@ pub fn update_lockfiles(config: &Config, ts: &Toolset, new_versions: &[ToolVersi
/// Rules:
/// - Same version+options: if any has no env (base), keep only base entry; otherwise merge env arrays
/// - Different version/options: separate entries
/// - Preserve existing env-specific entries that aren't in new entries (env configs may not be loaded)
fn merge_tool_entries_with_env(
entries: Vec<(LockfileTool, Option<String>)>,
existing_tools: Option<&Vec<LockfileTool>>,
Expand Down Expand Up @@ -435,7 +461,7 @@ fn merge_tool_entries_with_env(
}
}

// Merge with existing tools to preserve platform info
// Merge with existing tools to preserve platform info AND env-specific entries
if let Some(existing) = existing_tools {
for existing_tool in existing {
let key = (existing_tool.version.clone(), existing_tool.options.clone());
Expand All @@ -449,13 +475,31 @@ fn merge_tool_entries_with_env(
.or_insert(info.clone());
}
// Preserve existing env if we have no new env info
if entry.1.is_empty() && !entry.2 {
if let Some(ref existing_env) = existing_tool.env {
for e in existing_env {
entry.1.insert(e.clone());
}
if entry.1.is_empty()
&& !entry.2
&& let Some(ref existing_env) = existing_tool.env
{
for e in existing_env {
entry.1.insert(e.clone());
}
}
} else if existing_tool.env.is_some() {
// Preserve env-specific entries that have no match in new entries
// This handles the case where env configs (e.g., mise.test.toml) aren't loaded
// but we don't want to lose their lockfile entries
by_key.insert(
key,
(
existing_tool.clone(),
existing_tool
.env
.clone()
.unwrap_or_default()
.into_iter()
.collect(),
false,
),
);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Expand Down Expand Up @@ -521,45 +565,20 @@ fn read_all_lockfiles(config: &Config) -> Lockfile {
}

fn read_lockfile_for(path: &Path) -> Result<Lockfile> {
// Cache by config path to avoid recomputing lockfile_path_for_config on every call
static CACHE: Lazy<Mutex<HashMap<PathBuf, Lockfile>>> = Lazy::new(Default::default);

let (lockfile_path, is_local) = lockfile_path_for_config(path);

let mut cache = CACHE.lock().unwrap();

// For non-local configs, merge local lockfile on top of main lockfile
if !is_local {
let local_path = lockfile_path.with_file_name("mise.local.lock");

// Get or read the main lockfile
let main = cache
.entry(lockfile_path.clone())
.or_insert_with(|| {
Lockfile::read(&lockfile_path)
.unwrap_or_else(|err| handle_missing_lockfile(err, &lockfile_path))
})
.clone();

// Get or read the local lockfile
let local = cache
.entry(local_path.clone())
.or_insert_with(|| Lockfile::read(&local_path).unwrap_or_default())
.clone();

// Merge local on top of main
let mut merged = main;
for (short, tools) in local.tools {
merged.tools.insert(short, tools);
}
return Ok(merged);
if let Some(cached) = cache.get(path) {
return Ok(cached.clone());
}

// For local configs, just read the local lockfile
cache.entry(lockfile_path.clone()).or_insert_with(|| {
Lockfile::read(&lockfile_path)
.unwrap_or_else(|err| handle_missing_lockfile(err, &lockfile_path))
});
let lockfile = cache.get(&lockfile_path).unwrap().clone();
// Only compute lockfile path when not cached
let (lockfile_path, _is_local) = lockfile_path_for_config(path);
let lockfile = Lockfile::read(&lockfile_path)
.unwrap_or_else(|err| handle_missing_lockfile(err, &lockfile_path));

cache.insert(path.to_path_buf(), lockfile.clone());
Ok(lockfile)
}

Expand Down Expand Up @@ -592,7 +611,7 @@ pub fn get_locked_version(

if let Some(tools) = lockfile.tools.get(short) {
// Filter by version prefix and options
let matching: Vec<_> = tools
let mut matching: Vec<_> = tools
.iter()
.filter(|v| {
let version_matches = prefix == "latest" || v.version.starts_with(prefix);
Expand All @@ -601,19 +620,27 @@ pub fn get_locked_version(
})
.collect();

// Only sort when prefix is "latest" and we have multiple matches
// This is expensive, so avoid it for specific version prefixes
if prefix == "latest" && matching.len() > 1 {
matching.sort_by(|a, b| {
versions::Versioning::new(&b.version).cmp(&versions::Versioning::new(&a.version))
});
}

// Priority: 1) env-specific match, 2) base entry (no env)
if !current_envs.is_empty() {
if let Some(env_match) = matching.iter().find(|t| {
if !current_envs.is_empty()
&& let Some(env_match) = matching.iter().find(|t| {
t.env
.as_ref()
.is_some_and(|envs| envs.iter().any(|e| current_envs.contains(e.as_str())))
}) {
trace!(
"[{short}@{prefix}] found {} in lockfile (env-specific: {:?})",
env_match.version, env_match.env
);
return Ok(Some((*env_match).clone()));
}
})
{
trace!(
"[{short}@{prefix}] found {} in lockfile (env-specific: {:?})",
env_match.version, env_match.env
);
return Ok(Some((*env_match).clone()));
}

// Fall back to base entry (no env field)
Expand Down
6 changes: 6 additions & 0 deletions xtasks/fig/src/mise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,12 @@ const completionSpec: Fig.Spec = {
name: "platform",
},
},
{
name: "--local",
description:
"Update mise.local.lock instead of mise.lock\nUse for tools defined in .local.toml configs",
isRepeatable: false,
},
],
args: {
name: "tool",
Expand Down
Loading