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
41 changes: 41 additions & 0 deletions e2e/cli/test_link_lockfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env bash

# Test that linked tools are not reported as missing when a lockfile exists
# Regression test for https://github.com/jdx/mise/discussions/8049

export MISE_LOCKFILE=1

echo "=== Setup: install tiny and create a linked version ==="
rm -f mise.toml mise.lock
mise install tiny@3.1.0

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

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

This test installs tiny@3.1.0 but does not uninstall it during cleanup. If the e2e suite shares a persistent MISE data directory across tests, that leftover install can cause cross-test state leakage. Consider uninstalling tiny@3.1.0 in cleanup as well (or run the test under an isolated temp data dir if that鈥檚 the suite convention).

Copilot uses AI. Check for mistakes.

# Create a directory to link as a fake "brew" version
mkdir -p "$PWD/tmp/tiny-brew"

# Link it as tiny@brew (absolute symlink, like `mise link hk@brew $(brew --prefix hk)`)
mise link tiny@brew "$PWD/tmp/tiny-brew"
assert_contains "mise ls tiny" "brew (symlink)"

echo "=== Create mise.toml requesting latest and a lockfile pinning 3.1.0 ==="
cat <<EOF >mise.toml
[tools]
tiny = "latest"
EOF

# Create a lockfile that pins tiny to 3.1.0
cat <<EOF >mise.lock
[tools.tiny]
version = "3.1.0"
EOF

echo "=== Verify linked version is used instead of lockfile version ==="
# The linked version should take priority over the lockfile entry
# Previously this would show tiny@3.1.0 as (missing) alongside the linked version
assert_contains "mise ls tiny" "brew (symlink)"
assert_not_contains "mise ls tiny" "missing"

echo "=== Cleanup ==="
rm -rf mise.toml mise.lock tmp/tiny-brew
mise uninstall tiny@brew 2>/dev/null || true

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

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

This test installs tiny@3.1.0 but does not uninstall it during cleanup. If the e2e suite shares a persistent MISE data directory across tests, that leftover install can cause cross-test state leakage. Consider uninstalling tiny@3.1.0 in cleanup as well (or run the test under an isolated temp data dir if that鈥檚 the suite convention).

Suggested change
mise uninstall tiny@brew 2>/dev/null || true
mise uninstall tiny@brew 2>/dev/null || true
mise uninstall tiny@3.1.0 2>/dev/null || true

Copilot uses AI. Check for mistakes.

echo "mise link + lockfile tests passed!"
22 changes: 22 additions & 0 deletions src/toolset/tool_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl ToolVersion {
) -> Result<Self> {
trace!("resolving {} {}", &request, opts);
if opts.use_locked_version
&& !has_linked_version(request.ba())
&& let Some(lt) = request.lockfile_resolve(config)?
{
Comment on lines 52 to 55

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

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

has_linked_version() performs a filesystem read_dir scan of the installs directory and is now on the hot path for every resolution when use_locked_version is enabled. Consider caching the result per backend/tool for the duration of the process (or at least per ResolveOptions/resolution run) to avoid repeated directory scans during commands that resolve many tools.

Copilot uses AI. Check for mistakes.
let mut tv = Self::new(request.clone(), lt.version);
Expand Down Expand Up @@ -412,6 +413,27 @@ impl Default for ResolveOptions {
}
}

/// Check if a tool has any user-linked versions (created by `mise link`).
/// A linked version is an installed version whose path is a symlink to an absolute path,
/// as opposed to runtime symlinks which point to relative paths (starting with "./").
fn has_linked_version(ba: &BackendArg) -> bool {
let installs_dir = &ba.installs_path;
let Ok(entries) = std::fs::read_dir(installs_dir) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
if let Ok(Some(target)) = crate::file::resolve_symlink(&path) {
// Runtime symlinks start with "./" (e.g., latest -> ./1.35.0)
// User-linked symlinks point to absolute paths (e.g., brew -> /opt/homebrew/opt/hk)
if target.is_absolute() {
return true;
}
}
}
false
Comment on lines +424 to +434

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

This for loop can be refactored into a more concise and idiomatic iterator-based approach using any(). This improves readability by expressing the intent of checking if any entry satisfies the condition directly. The inline comment can be removed as its content is already covered by the function's docstring.

    entries.flatten().any(|entry| {
        if let Ok(Some(target)) = crate::file::resolve_symlink(&entry.path()) {
            target.is_absolute()
        } else {
            false
        }
    })

}
Comment on lines +419 to +435

Copilot AI Feb 7, 2026

Copy link

Choose a reason for hiding this comment

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

has_linked_version() assumes crate::file::resolve_symlink(&path) returns the raw symlink target so that target.is_absolute() can distinguish user links from runtime ./... links. If resolve_symlink instead returns a resolved/canonicalized path (which the name strongly suggests), then runtime symlinks like latest -> ./1.35.0 will resolve to an absolute path and be misclassified as a user-linked version, changing resolution behavior broadly. Prefer using a helper that returns the direct read_link() value (unresolved), or rename/split helpers so this function explicitly uses the non-resolving variant.

Copilot uses AI. Check for mistakes.

impl Display for ResolveOptions {
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
let mut opts = vec![];
Expand Down
Loading