Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b41b24f
fix(install): switch backends into a new installation, and resolve CL…
jdx Oct 5, 2026
73a8e73
fix(install): check every installs root on backend switch, apply opti…
jdx Oct 5, 2026
18dad59
fix(install): never install into or select a variant standing in for …
jdx Oct 5, 2026
efbbd67
fix(backends): find the old backend's version path when only one back…
jdx Oct 5, 2026
5ebb771
fix(install): do not cache a variant standing in for a bare command-l…
jdx Oct 5, 2026
846b888
fix(install): never take an installation built from another dependenc…
jdx Oct 5, 2026
4e6aa14
fix(backends): find the old backend's installation by its receipt whe…
jdx Oct 5, 2026
6dcb2a7
fix(install): do not run an always-run postinstall hook in a variant …
jdx Oct 5, 2026
913b787
fix(install): only take a variant of the same tool for a bare version
jdx Oct 5, 2026
009387f
perf(backends): list installations once, and only for switched versio…
jdx Oct 5, 2026
848e603
fix(backends): compare versions the way installations name them when …
jdx Oct 5, 2026
ec7d683
fix(install): tell a bare version's settings from request options whe…
jdx Oct 5, 2026
292cf2a
fix(install): let an installation stand for a command-line version wh…
jdx Oct 5, 2026
58a3fc1
fix(install): only a version with no options of its own stands for an…
jdx Oct 5, 2026
9bb9392
fix(install): count options written inline on the command line as the…
jdx Oct 5, 2026
bbd7741
fix(install): find a lone variant in a shared root whose catalog is gone
jdx Oct 5, 2026
e0b489d
fix(install): count variants by the options they were made with, not …
jdx Oct 5, 2026
f02d5f5
fix(install): take an option set's selected copy only if it is one of…
jdx Oct 5, 2026
5b5803a
fix(install): never take another copy of an option set whose selected…
jdx Oct 5, 2026
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
21 changes: 14 additions & 7 deletions docs/dev-tools/install-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ mise still writes the tool directory's `.mise.backend.toml` sidecar and
[Finding an installation](#finding-an-installation-mise-where-and-mise-which)
for when mise uses the installation directory instead.
- **Tool options, backends, and the registry work as before.** Only where files
land and how installations are found changes.
land and how installations are found changes. `mise backends switch` installs
the new backend's installation of each switched version (it is a different
installation from the old backend's, which stays until it is pruned) and
points the version link at it.

## How identity is decided

Expand Down Expand Up @@ -450,12 +453,16 @@ tools that have more than one variant of a version.
package-manager installs record the real installation path, which now contains
the hash. They stay valid for that installation but are not portable to
another one. Installing the same identity again lands in the same directory.
- **A version named on the command line carries no options.** An installation
made with install options a configuration file sets (`symlink_bins`, a
`matching` pattern) is a different installation from the plain version, so
`mise where tool@1.0` finds it only because it is the lone variant, and other
commands treat the unadorned request as not installed. Run them where the
configuration applies, or let `mise install` make the plain variant.
- **A version named on the command line carries only the options a
configuration gives it.** In a project, `mise where tool@1.0`, `mise x
tool@1.0` and the other commands use the install options the configuration
sets for that tool (`symlink_bins`, a `matching` pattern). Outside one, a
version named without options uses the installation of that version when
there is only one, whatever options it was made with, as before this layout.
With several, `mise where` lists them and other commands treat the version as
not installed; name the options to pick one, as in
`mise where 'tool[matching=server]@1.0'`, or install the plain version with
`mise install --force tool@1.0`.
- **A version link is not authority.** Hardcoded `installs/<tool>/<version>`
paths see only the most recently installed variant.
- **Directory names are less descriptive.** A label such as `berry` for yarn or
Expand Down
157 changes: 157 additions & 0 deletions e2e/cli/test_install_layout_cli_variants
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# A version named on the command line carries no install options, so under the
# identity install layout it can mean an installation a configuration made with
# options. `mise where` uses one such installation when it is the only one, and
# lists them instead of guessing when there are several.
export MISE_EXPERIMENTAL=1
export MISE_INSTALL_LAYOUT=identity
work="$PWD"

mkdir env opt
cat >env/mise.toml <<'EOF2'
[tools]
dummy = { version = "2.0.0", install_env = { DUMMY_VARIANT = "musl" } }
EOF2
cat >opt/mise.toml <<'EOF2'
[tools]
dummy = { version = "2.0.0", flavor = "full" }
EOF2

echo "=== the only installation of the version is found ==="
assert "mise -C $work/env install"
env_dir="$(mise -C "$work/env" where dummy)"
assert "mise where dummy@2.0.0" "$env_dir"

echo "=== several are listed, not guessed between ==="
assert "mise -C $work/opt install"
opt_dir="$(mise -C "$work/opt" where dummy)"
assert_fail "mise where dummy@2.0.0" "installed with several sets of options"
assert_fail "mise where dummy@2.0.0" "$(basename "$opt_dir")"
assert_fail "mise where dummy@2.0.0" "$(basename "$env_dir")"

echo "=== naming the options finds the variant ==="
assert "mise where 'dummy[flavor=full]@2.0.0'" "$opt_dir"
assert "mise -C $work/opt where dummy@2.0.0" "$opt_dir"

echo "=== a lone variant stands for the bare version in every command ==="
mkdir solo
cat >solo/mise.toml <<'EOF2'
[tools]
dummy = { version = "1.0.0", flavor = "full" }
EOF2
assert "mise -C $work/solo install"
solo_dir="$(mise -C "$work/solo" where dummy)"
assert "mise where dummy@1.0.0" "$solo_dir"
assert "mise bin-paths dummy@1.0.0 | xargs realpath" "$(realpath "$solo_dir/bin")"
assert "mise which dummy --tool dummy@1.0.0 | xargs realpath" "$(realpath "$solo_dir/bin/dummy")"
assert_contains "mise x dummy@1.0.0 -- dummy" "This is Dummy 1.0.0!"
assert_contains "mise install dummy@1.0.0 2>&1" "already installed"
assert "test \"\$(mise installs ls dummy --json | jq '[.[] | select(.version == \"1.0.0\")] | length')\" -eq 1"
# a forced install of the bare version makes its own installation, which it then uses
assert "mise install --force dummy@1.0.0"
assert "test \"\$(mise installs ls dummy --json | jq '[.[] | select(.version == \"1.0.0\")] | length')\" -eq 2"
plain_dir="$(mise where dummy@1.0.0)"
[[ $plain_dir != "$solo_dir" ]] || fail "the bare version still resolves to the variant"
assert "mise -C $work/solo where dummy" "$solo_dir"

echo "=== a lone variant that has to be installed again is not installed over for the bare version ==="
mkdir solo2
cat >solo2/mise.toml <<'EOF2'
[tools]
tiny = { version = "3.1.0", flavor = "full" }
EOF2
assert "mise -C $work/solo2 install"
solo2_dir="$(mise -C "$work/solo2" where tiny)"
assert "mise where tiny@3.1.0" "$solo2_dir"
# as if a refresh of the variant had been interrupted
mkdir -p "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")"
touch "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")/incomplete"
assert "mise install tiny@3.1.0"
bare_dir="$(mise where tiny@3.1.0)"
[[ $bare_dir != "$solo2_dir" ]] || fail "the bare version was installed into the variant"
# the variant is still the project's, once its own refresh is done
rm "$MISE_CACHE_DIR/tiny/$(basename "$solo2_dir")/incomplete"
assert "mise -C $work/solo2 where tiny" "$solo2_dir"
assert "mise installs ls tiny --json | jq -r '.[] | select(.selected and .version == \"3.1.0\") | .dir' | sort | tr '\n' ' '" "$(printf '%s\n' "$bare_dir" "$solo2_dir" | sort | tr '\n' ' ')"

echo "=== a postinstall hook that always runs never runs in a variant standing in ==="
mkdir variant hooked
cat >variant/mise.toml <<'EOF2'
[tools]
tiny = { version = "3.0.0", flavor = "full" }
EOF2
assert "mise -C $work/variant install"
variant_dir="$(mise -C "$work/variant" where tiny)"
cat >hooked/mise.toml <<'EOF2'
[tools]
tiny = { version = "3.0.0", postinstall = { run = "touch \"$MISE_TOOL_INSTALL_PATH/hooked\"", when = "always" } }
EOF2
assert "mise -C $work/hooked install tiny@3.0.0"
assert "test ! -e '$variant_dir/hooked'"
assert "test -e \"\$(mise -C '$work/hooked' where tiny@3.0.0)/hooked\""

echo "=== another tool aliasing the same backend never stands in ==="
mkdir aliased
cat >aliased/mise.toml <<EOF2
[tool_alias]
tinier = "$(mise tool tiny --backend)"

[tools]
tinier = { version = "2.1.0", flavor = "full" }
EOF2
assert "mise -C $work/aliased install"
tinier_dir="$(mise -C "$work/aliased" where tinier)"
[[ $tinier_dir == "$MISE_DATA_DIR/installs/"tiny-* ]] || fail "$tinier_dir is not an identity installation"
assert_fail "mise where tiny@2.1.0"

echo "=== a version a configuration gives options never takes an installation with more ==="
mkdir env2 more
cat >env2/mise.toml <<'EOF2'
[tools]
tiny = { version = "1.0.0", install_env = { TINY_BUILD = "1" } }
EOF2
cat >more/mise.toml <<'EOF2'
[tools]
tiny = { version = "1.0.0", install_env = { TINY_BUILD = "1" }, flavor = "full" }
EOF2
assert "mise -C $work/more install"
assert_fail "mise -C $work/env2 where tiny@1.0.0"
# while the version named alone does find it
assert "mise where tiny@1.0.0" "$(mise -C "$work/more" where tiny)"
# options written on the command line are the version's own too
assert_fail "mise where 'tiny[flavor=full]@1.0.0'"

echo "=== a lone variant in a shared root whose catalog is gone still stands in ==="
mkdir sharedvar
cat >sharedvar/mise.toml <<'EOF2'
[tools]
tiny = { version = "1.0.1", flavor = "full" }
EOF2
assert "MISE_INSTALLS_DIR='$HOME/shared-variants' mise -C $work/sharedvar install"
mv "$HOME/shared-variants/.mise" "$HOME/shared-variants/.mise.lost"
assert_matches "MISE_SHARED_INSTALL_DIRS='$HOME/shared-variants' mise where tiny@1.0.1" "^$HOME/shared-variants/tiny-"
# a second copy made with the same options is the same variant: its selection says which
export MISE_SHARED_INSTALL_DIRS="$HOME/shared-variants"
assert "mise -C $work/sharedvar install --force"
own_copy="$(mise -C "$work/sharedvar" where tiny)"
[[ $own_copy == "$MISE_DATA_DIR/installs/"tiny-* ]] || fail "$own_copy is not in the user's root"
assert "mise where tiny@1.0.1" "$own_copy"
# with the selected copy gone, the other copy is not taken in its place
rm -rf "$own_copy"
assert_fail "mise where tiny@1.0.1"
unset MISE_SHARED_INSTALL_DIRS

echo "=== a version named with options a configuration sets never stands in for another variant ==="
mkdir minimal
cat >minimal/mise.toml <<'EOF2'
[tools]
dummy = { version = "1.1.0", flavor = "minimal" }
EOF2
assert "mise -C $work/minimal install"
mkdir full
cat >full/mise.toml <<'EOF2'
[tools]
dummy = { version = "1.1.0", flavor = "full" }
EOF2
assert_fail "mise -C $work/full where dummy@1.1.0"
assert_fail "mise -C $work/full where dummy 1.1.0"
38 changes: 38 additions & 0 deletions e2e/cli/test_install_layout_switch
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# `mise backends switch` under the identity install layout: the new backend's
# installation is a different one from the old backend's, so the switch installs
# it (instead of finding the version "already installed"), and the version link
# moves to it. hk moved from Aqua to Packslip at 1.58.1.
export MISE_EXPERIMENTAL=1
export MISE_INSTALL_LAYOUT=identity
export MISE_LOCKFILE=1
export MISE_MINIMUM_RELEASE_AGE=0
detect_platform
installs="$MISE_DATA_DIR/installs"

cat <<'EOF2' >mise.toml
[tools]
hk = "1"
EOF2
cat <<'EOF2' >mise.lock
lockfile_version = 2

[[tools.hk]]
version = "1.58.1"
backend = "aqua:jdx/hk"
specifiers = ["1"]
EOF2

assert_contains "mise install 2>&1" "mise backends switch hk"
old_dir="$(mise where hk)"
assert_contains "cat '$old_dir/.mise-install.toml'" 'backend = "aqua:jdx/hk"'

mise backends switch hk
assert_contains "cat mise.lock" 'backend = "packslip:github.com/jdx/hk"'
new_dir="$(mise where hk)"
[[ $new_dir != "$old_dir" ]] || fail "the switch kept the aqua installation $old_dir"
assert_contains "cat '$new_dir/.mise-install.toml'" 'backend = "packslip:github.com/jdx/hk"'
assert "readlink '$installs/hk/1.58.1'" "../$(basename "$new_dir")"
assert_contains "mise exec -- hk --version" "hk 1.58.1"
# the old backend's installation is left for prune, not deleted
assert "test -f '$old_dir/.mise-install.toml'"
16 changes: 11 additions & 5 deletions src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3974,13 +3974,19 @@ pub trait Backend: Debug + Send + Sync {
.await
&& !rolling_reinstall;

// An identity-layout installation found in a read-only shared root is used as
// it is; anything that would write (a forced or incompatible install) is
// allocated afresh in the primary root first, before it can touch the shared
// directory.
// An identity-layout installation found in a read-only shared root, or a
// variant standing in for a bare version, is used as it is; anything that
// would write (a forced or incompatible install, or a postinstall hook that
// runs even when nothing is installed) is allocated afresh in the primary
// root first, before it can touch that directory.
let mut allocated = allocated;
let always_postinstall = tv
.request
.options()
.postinstall()
.is_some_and(|(_, always)| always);
if allocated.as_ref().is_some_and(|a| a.read_only)
&& (ctx.force || rolling_reinstall || !install_satisfied)
&& (ctx.force || rolling_reinstall || !install_satisfied || always_postinstall)
{
let mut bare = tv.clone();
bare.install_path = None;
Expand Down
67 changes: 61 additions & 6 deletions src/cli/backends/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,21 +455,47 @@ impl BackendsSwitch {
}

/// Reinstall the switched versions that are installed, from the new
/// backend. Installs are keyed by tool and version, not backend, so an
/// install from the old backend would otherwise keep satisfying the new
/// lock entry.
/// backend. In the legacy layout installs are keyed by tool and version, not
/// backend, so an install from the old backend would otherwise keep
/// satisfying the new lock entry. In the identity layout the new backend's
/// installation is a different one, so a version is reinstalled when its
/// version path (the old backend's install, or its version link) is there.
async fn reinstall(
&self,
switched: &BTreeSet<(String, String)>,
relocked_tools: &BTreeSet<(PathBuf, String)>,
) -> Result<()> {
let mut config = Config::reset().await?;
let mut requests = vec![];
// Read once, and only if some switched version has no version path.
let mut installations = None;
for (lockfile, tv) in self.scoped_versions(&config).await? {
if (switched.contains(&(tv.short().to_string(), tv.version.clone()))
|| relocked_tools.contains(&(lockfile, tv.short().to_string())))
&& tv.backend()?.is_version_installed(&config, &tv, false)
if !switched.contains(&(tv.short().to_string(), tv.version.clone()))
&& !relocked_tools.contains(&(lockfile, tv.short().to_string()))
{
continue;
}
// The old backend's install may be in the user's root or in a shared
// or system one, and may have no version link (Windows without
// junctions), so its receipt is looked for too.
let installed = tv.backend()?.is_version_installed(&config, &tv, false)
|| (crate::install_layout::resolver::enabled()
&& (std::iter::once(crate::dirs::INSTALLS.to_path_buf())
.chain(crate::env::shared_install_dirs())
.any(|root| {
tv.ba()
.installs_path()
.file_name()
.is_some_and(|tool_dir| has_version_path(&root.join(tool_dir), &tv))
})
|| installations
.get_or_insert_with(crate::install_layout::resolver::installations)
.iter()
.any(|i| {
i.version == layout_version(&tv)
&& i.requested_as.as_deref() == Some(tv.short())
Comment thread
jdx marked this conversation as resolved.
Resolved
})));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if installed {
requests.push(tv.request);
}
}
Expand Down Expand Up @@ -556,3 +582,32 @@ impl Snapshot {
}
}
}

/// `tv`'s version as installations and version paths name it (`ref-main` for
/// `ref:main`).
fn layout_version(tv: &ToolVersion) -> String {
crate::install_layout::resolver::identity_scope(tv)
.map(|(_, version)| version)
.unwrap_or_else(|| tv.version.replace([':', '/'], "-"))
}

/// Whether the tool directory `tool_dir` has a version path for `tv`'s version,
/// as the backend before the switch named it. That is `tv`'s own path name, or
/// the version with the private dependency-graph suffix an aube or uv install
/// adds (`1.0.0~aube~…`), which only one of the two backends may carry.
fn has_version_path(tool_dir: &Path, tv: &ToolVersion) -> bool {
let version = layout_version(tv);
if tool_dir.join(tv.tv_pathname()).exists() || tool_dir.join(&version).exists() {
return true;
}
let suffixed = format!("{version}~");
std::fs::read_dir(tool_dir).is_ok_and(|entries| {
entries.flatten().any(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(&suffixed))
&& entry.path().exists()
})
})
}
Loading
Loading