Files
jdxandClaude Sonnet 5.5 da68febd37 feat(install): add an opt-in identity-based install layout (#13951)
<!-- entire-trail-link-start -->
https://entire.io/gh/jdx/mise/trails/418
<!-- entire-trail-link-end -->

<!-- ccr-projects-attribution: {"github_login":"jdx"} -->
_Requested by **Jeff**_

Implements #13678 behind a new opt-in setting, `install_layout =
"identity"` (it also needs `experimental = true`, but experimental users
do not get it unless they ask; a later release will fold it into
`experimental`): installs are named by what they are, so shorthands
share installs and incompatible variants of one version coexist.

Before: every non-core tool lives in `installs/<short>/<version>`, and
the backend that made it is recorded once per tool. `age` and
`aqua:FiloSottile/age` are two downloads, a registry move (or a
version-split tool like `hk`) reads one backend's files with another's
layout, two builds of one version overwrite each other, and on Windows
`installs/maven/3` is a text file that an IDE's SDK picker cannot
follow.

After, with `experimental = true` and `install_layout = "identity"` (or
`MISE_INSTALL_LAYOUT=identity`):

```text
installs/
  age/                        tool directory, as before
    1.2.1 -> ../age-hlencrst  version link (a junction on Windows)
    latest -> ./1.2.1         runtime aliases keep their ./<version> form
  age-hlencrst/               the installation: <label>-<hash of its identity>
    .mise-install.toml        receipt, written last (a directory without one is incomplete)
  .mise/                      catalog: assigned names, unlocked selections
```

```sh
$ mise install age@1.2.1 && mise where age
~/.local/share/mise/installs/age-hlencrst
$ mise install aqua:FiloSottile/age@1.2.1   # same canonical backend: already installed
```

- **Identity.** A hash of the canonical backend, concrete version,
platform, install-affecting options and any pinned artifact checksum or
dependency graph (`install_env` is hashed, never recorded; URL
credentials are stripped). Shorthand and explicit spellings share one
directory; variants that differ in an install-affecting option
(`restate-server` vs `restatectl`, `symlink_bins`, a different
`matching`) get their own. Names collide-extend by two hash characters
and are never renamed.
- **PATH.** Unlocked requests keep putting `installs/<tool>/<alias>/bin`
on PATH (`mise which node` still shows `installs/node/20/bin/node`) when
that link is the selected installation; locked requests use the
installation directory. `mise where` prints the real directory.
- **Lockfiles and selections.** A lockfile checksum is part of the
identity and adopts an unlocked install of the same artifact without
reinstalling it; an adopted install is never replaced in place, a later
forced refresh moves the shared unlocked selection to a new generation.
Restoring a pruned install from a different artifact than the one
recorded is an error.
- **Lifecycle.** `ls`, `uninstall`, `prune` and upgrade retention work
per installation (`mise ls` marks variants with their directory,
`uninstall` asks for `--all` when several match). Uninstall refuses
paths that are not receipt-backed installations and removes every link
that names the installation but keeps its catalog record, so a reinstall
lands in the same place.
- **Windows.** Version links and aliases are real junctions, never text
files (a failed link is a warning; the install still works through
mise). Verified on the Windows CI box.

What does not change: existing `installs/<tool>/<version>` installs are
used in place when their recorded backend matches (nothing is migrated),
`http`, `rust`, `dotnet`, system/shared/`install-into` destinations and
`mise link`ed versions keep the legacy layout, shared roots are never
written, and with the setting off (the default) nothing in this PR
changes where anything is installed. Hashed paths appear in generated
files (venvs, shebangs); python/ruby/pipx/gem are mapped through the
version link so patch upgrades keep working. See the new
`docs/dev-tools/install-layout.md`.

Not in this PR: a shorter default installs directory on Windows, an
explicit selection command, relocating legacy installs, and flipping the
default (deprecation policy applies first).

Validation: unit tests for the identity encoding, naming rule, catalog
allocation (collision, concurrency, rebuild from receipts), and
resolver; new e2e tests (`test_install_layout*`) and
`e2e-win/install_layout.Tests.ps1`; the existing e2e suite is unchanged
and runs on the legacy layout (the default), and the new layout has its
own e2e tests plus a run of the existing suite on it during development
(65 expectations that assumed `installs/<tool>/<version>` were triaged;
real bugs it found are fixed). Clippy `-D warnings` is clean on Linux
and Windows; a debug build was smoke-tested on the Windows runner
(junction links, `where`, `ls`, uninstall, prune, reinstall into the
same directory).

*AI-assisted — Tool: Claude Code; model: anthropic/claude-sonnet-5-5;
version: unavailable.*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> This changes core install paths, discovery, locking, uninstall/prune,
and Windows junction semantics; mistakes could corrupt installs, leave
dangling links, or split shared selections across projects.
> 
> **Overview**
> Adds an **opt-in experimental** `install_layout = "identity"` (with
`experimental = true`) that stores each install in
`installs/<label>-<hash>/` with a **`.mise-install.toml` receipt** and
an **`installs/.mise/` catalog**, while keeping
`installs/<tool>/<version>` and runtime aliases as **links** to that
directory.
> 
> The install pipeline is wired through allocation, locking,
finish/unpublish on failure, reuse of legacy and shared installs, and
uninstall/prune/`mise ls` behavior per installation (including variant
disambiguation). **PATH/`which`/`where`** rules differ for unlocked vs
lockfile-pinned requests.
> 
> **Windows** gets real directory junctions via new `make_dir_link` /
`remove_dir_link` (no text-file fallback), safer cleanup when junction
creation fails, and junction-aware `resolve_symlink`. **vfox**
extraction uses hidden temp dir prefixes so the installs root is not
polluted.
> 
> Backends gain **`install_identity_options`** (and related
ignored-option hooks) so shorthands can share installs and
option/signer/registry differences split variants. **Ruby gem** and
**pipx** shebang/venv logic map hashed python/ruby paths through version
links.
> 
> Documentation (`install-layout.md`, sidebar, hooks/shims/directories)
and broad **e2e** coverage (Unix + PowerShell) exercise catalog,
collisions, concurrency, lockfile adoption, and legacy coexistence.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4e3b766e8f. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 07:41:44 -05:00
..