From 449dad1093ebb610e347a0bb4622ed90af542a26 Mon Sep 17 00:00:00 2001 From: jiminu Date: Tue, 15 Sep 2026 00:04:50 +0900 Subject: [PATCH] feat: prune unused mise tools after updates --- .github/workflows/ci.yml | 3 + AGENTS.md | 11 +++ docs/UPDATES.md | 29 ++++++- lib/commands/update.sh | 10 ++- lib/packages.sh | 76 +++++++++++++++++++ tests/updates_test.bash | 160 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 286 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1074689..4e29ee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,9 @@ jobs: source lib/dependencies.sh dependency_install mise linux amd64 + - name: Verify update cleanup with pinned mise + run: PATH="$HOME/.local/bin:$PATH" bash tests/updates_test.bash + - name: Read pinned Neovim toolchain run: | mise_version() { diff --git a/AGENTS.md b/AGENTS.md index 5fa6ffc..814fcf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,17 @@ directory so a caller's project cannot override approved tool versions. Status and doctor query installed versions, not merely configured requests; an orphaned mise shim does not count as an external tool installation. +Successful tool/configuration updates prune unused mise versions by default, +scoped to the current platform's declared mise tools. Preserve current pins +through mise's tracked configuration. Exclude the previous release's mise +configuration during cleanup, even if already tracked; rollback-only tool +versions are not retained. Keep the CLI rollback release and project tracking +intact. Never call prune with an empty tool list. Skip cleanup on incomplete +setup (including optional package failures), `--skip-packages`, and CLI-only/no-op updates. Dry-run must +not invoke mise, since even its read-only commands can write metadata. Cleanup +failures warn without invalidating a completed synchronization. See +`docs/UPDATES.md` for project-version retention limits. + When the tools/configuration phase runs, `--skip-packages` must skip package and tool installation and apply managed configuration only. A default update whose target release is already active exits before that phase; use diff --git a/docs/UPDATES.md b/docs/UPDATES.md index bc7e534..6ac4b77 100644 --- a/docs/UPDATES.md +++ b/docs/UPDATES.md @@ -46,6 +46,29 @@ configuration for the current release. skips package and tool installation and applies managed configuration only, the same contract `selfishell install` follows. +After a successful tools/configuration update, Selfishell automatically runs +`mise prune --tools --yes`, scoped to its mise tools for the current platform. +It first registers the current release configuration with mise so its pinned +versions remain available. During cleanup only, the previous release's mise +configuration is excluded, even if mise already tracks it. Rollback-only tool +versions are not retained; versions still needed by mise's tracked, trusted +project configurations are retained. The previous CLI release and its +configuration files remain intact. Unrelated tools and +tracked configuration links are not removed. This is mise's unused-version +cleanup, not just removal of versions installed by Selfishell: versions of these +tools used only via environment variables, one-off `mise exec tool@version`, or +untracked projects can be removed. Keep such versions in a project configuration +and load it with mise before updating. See [mise prune](https://mise.jdx.dev/cli/prune.html). + +Cleanup does not run on installation, rollback, `--cli-only`, `--skip-packages`, +an already-current default update, or a failed synchronization. If an optional +package failed to install, cleanup is skipped too. Cleanup or retention-preflight +failures produce a warning without undoing the successful synchronization. +For safety, configured mise `ignored_config_paths` also disables automatic +cleanup: ignored configurations cannot protect their pinned versions. +`--dry-run` describes the cleanup scope without invoking mise or writing its +tracking/cache metadata; it does not enumerate individual deletion candidates. + `status` reports local CLI, rollback, tools, and managed-resource state only; it never checks the network. Use `selfishell version --available` to check the latest published release, or rely on the automatic update notice. @@ -67,8 +90,10 @@ to select an exact release. `--version` cannot be combined with `--tools-only`. configuration, or the active CLI release. `selfishell rollback` exchanges the `current` and `previous` release links and -does not use the network. An exact retained version can be selected with -`selfishell rollback VERSION`. +does not use the network. It restores the CLI release, not the managed +configuration or tool installations. Reapplying the older environment may +require downloading tool versions removed by cleanup. An exact retained +version can be selected with `selfishell rollback VERSION`. Direct download and Git dependency versions are changed only by reviewing and updating `dependencies.conf` in a new Selfishell release. diff --git a/lib/commands/update.sh b/lib/commands/update.sh index 76105b3..2c0de7d 100644 --- a/lib/commands/update.sh +++ b/lib/commands/update.sh @@ -21,6 +21,12 @@ Already installed apt/Homebrew packages are left at their current version (mise-managed and Selfishell direct tools are synced to their pinned versions). Selfishell update does not perform a general Apt/Homebrew upgrade. + +After successful tool/configuration synchronization, unused versions of +Selfishell's mise tools are pruned automatically. Current versions and those +needed by mise's tracked project configurations are retained; versions needed +only by the rollback release are not. +--skip-packages also skips cleanup; --dry-run only describes the cleanup scope. EOF } @@ -46,7 +52,7 @@ update_tools_and_configuration() { package_manifest_load || return [[ ! -r "$SELFISHELL_STATE_DIR/ghostty" ]] || ghostty_enabled="$(<"$SELFISHELL_STATE_DIR/ghostty")" [[ "$ghostty_enabled" == "1" ]] || ghostty_enabled=0 - confirm_action "Synchronize Selfishell packages and configuration?" "$assume_yes" "$dry_run" || return + confirm_action "Synchronize Selfishell packages and configuration (including unused mise version cleanup unless --skip-packages)?" "$assume_yes" "$dry_run" || return platform="$(detect_platform)" managed_preflight_zsh_loader "$assume_yes" "$dry_run" || return managed_preflight_block_target user-zprofile "$HOME/.zprofile" "$assume_yes" "$dry_run" || return @@ -73,6 +79,8 @@ update_tools_and_configuration() { install_managed_configuration "$platform" "$dry_run" "$ghostty_enabled" "$assume_yes" if [[ "$skip_packages" == "0" ]]; then install_neovim_plugins "$dry_run" || return + packages_prune_mise "$platform" "$dry_run" || + cli_warn "Could not prune unused mise versions; tools and configuration were synchronized." fi if [[ "$dry_run" == 1 ]]; then printf '%sTool/configuration dry run complete.%s\n' "$SELFISHELL_COLOR_CYAN" "$SELFISHELL_COLOR_RESET" diff --git a/lib/packages.sh b/lib/packages.sh index 50544ea..965c1ac 100644 --- a/lib/packages.sh +++ b/lib/packages.sh @@ -1,5 +1,81 @@ #!/usr/bin/env bash +# Run in a subshell so cleanup's offline/trust settings cannot affect installs. +packages_prune_mise() ( + local platform="$1" dry_run="$2" + local index package_platform mise_command previous version tracked ignored + local config="$SELFISHELL_ROOT/config/shared/mise.toml" previous_config="" + local tools=() + + package_platform="$(platform_package_platform "$platform")" || return + for ((index = 0; index < ${#PACKAGE_NAMES[@]}; index++)); do + [[ "${PACKAGE_MANAGERS[$index]}" == mise ]] || continue + [[ "${PACKAGE_PLATFORMS[$index]}" == all || "${PACKAGE_PLATFORMS[$index]}" == "$package_platform" ]] || continue + tools+=("${PACKAGE_NAMES[$index]}") + done + # An empty argument list means ALL installed tools to mise prune. + ((${#tools[@]} > 0)) || return 0 + if [[ "$dry_run" == 1 ]]; then + # Even mise's read-only commands can write tracking/cache metadata. + printf 'Would prune unused mise versions for: %s (keeping current and tracked project versions, not rollback-only versions).\n' "${tools[*]}" + return 0 + fi + if ((${#SELFISHELL_SKIPPED_OPTIONAL_PACKAGES[@]} > 0)); then + cli_warn "Skipping mise cleanup because some optional packages could not be installed." + return 0 + fi + mise_command="$(selfishell_mise_command)" || return + + if release_installation_paths 2>/dev/null; then + previous="$SELFISHELL_SHARE_DIR/previous" + if [[ -e "$previous" || -L "$previous" ]]; then + [[ -L "$previous" ]] || return 1 + previous="$(readlink "$previous")" || return + version="${previous##*/}" + selfishell_version_is_valid "$version" || return + [[ "$previous" == "releases/$version" || "$previous" == "$SELFISHELL_RELEASES_DIR/$version" ]] || return 1 + release_directory_is_valid "$version" || return + previous_config="$SELFISHELL_RELEASES_DIR/$version/config/shared/mise.toml" + fi + fi + + export MISE_OFFLINE=1 + # Trust only the current release directory for this invocation, not HOME. + [[ -f "$config" && -r "$config" ]] || return 1 + export MISE_TRUSTED_CONFIG_PATHS="${MISE_TRUSTED_CONFIG_PATHS:+$MISE_TRUSTED_CONFIG_PATHS:}${config%/*}" + export MISE_GLOBAL_CONFIG_FILE="$config" + ignored="$("$mise_command" -C "$SELFISHELL_ROOT/config/shared" settings get ignored_config_paths)" || return + if [[ "$ignored" != '[]' ]]; then + # Tracking entries survive ignored-config filtering; they aren't protection. + cli_warn "Skipping mise cleanup because ignored_config_paths can exclude retained versions." + return 1 + fi + # Previous releases may already be tracked by mise. Ignore their pins only + # during cleanup; leave the release files and mise's tracking state intact. + if [[ -n "$previous_config" && "$previous_config" != "$config" && ! "$previous_config" -ef "$config" ]]; then + # mise canonicalizes ignore paths; a replaced link could exclude a project. + [[ ! -L "$SELFISHELL_RELEASES_DIR/$version/config" && + ! -L "$SELFISHELL_RELEASES_DIR/$version/config/shared" && + ! -L "$previous_config" ]] || return 1 + # mise parses this as an OS-separated list of glob-capable paths. + case "$previous_config" in + *:* | *'*'* | *'?'* | *'['* | *'{'*) + cli_warn "Cannot safely exclude the rollback configuration from mise cleanup: $previous_config" + return 1 + ;; + esac + export MISE_IGNORED_CONFIG_PATHS="$previous_config" + fi + "$mise_command" -C "${config%/*}" config ls >/dev/null || return + tracked="$("$mise_command" -C "$SELFISHELL_ROOT/config/shared" config ls --tracked-configs)" || return + # mise reports tracking-write failures as warnings; don't prune without pins. + case $'\n'"$tracked"$'\n' in + *$'\n'"$config"$'\n'*) ;; + *) return 1 ;; + esac + "$mise_command" -C "$SELFISHELL_ROOT/config/shared" prune --tools --yes "${tools[@]}" +) + packages_install() { local platform="$1" local dry_run="$2" diff --git a/tests/updates_test.bash b/tests/updates_test.bash index ad3f245..e5f8394 100644 --- a/tests/updates_test.bash +++ b/tests/updates_test.bash @@ -48,6 +48,7 @@ test_tools_update_synchronizes_packages() { fail "Tools update did not synchronize package-manager packages" [[ "$output" == *'git'* ]] || fail "Tools update did not include the environment packages" [[ "$output" == *'Neovim plugins'* ]] || fail "Tools update omitted Neovim plugin setup" + [[ "$output" == *'Would prune unused mise versions'* ]] || fail "Tools update omitted default mise cleanup" } test_tools_update_skip_packages_avoids_package_operations() { @@ -61,6 +62,165 @@ test_tools_update_skip_packages_avoids_package_operations() { fail "--skip-packages did not report skipping package and tool installation: $output" [[ "$output" != *'apt packages'* ]] || fail "--skip-packages still touched apt packages: $output" + [[ "$output" != *'prune'* ]] || fail "--skip-packages still planned cleanup: $output" +} + +# Keep lifecycle orchestration real; replace only the installing operations. +run_update_cleanup_fixture() { + bash -euo pipefail -c ' + export SELFISHELL_ROOT="$1" + for module in common paths platform package_manifest installers packages releases commands/update; do + source "$1/lib/$module.sh" + done + confirm_action() { return 0; } + managed_preflight_zsh_loader() { return 0; } + managed_preflight_block_target() { return 0; } + packages_install() { + printf "packages\n" + SELFISHELL_SKIPPED_OPTIONAL_PACKAGES=() + [[ "$2" != 1 ]] || return 0 + [[ "$FAIL_PHASE" != optional ]] || SELFISHELL_SKIPPED_OPTIONAL_PACKAGES=(bat) + [[ "$FAIL_PHASE" != packages ]] + } + install_managed_configuration() { printf "configuration\n"; [[ "$FAIL_PHASE" != configuration ]]; } + install_neovim_plugins() { printf "plugins\n"; [[ "$FAIL_PHASE" != plugins ]]; } + selfishell_mise_command() { printf "mise\n"; } + mise() { + case "$*" in + *"settings get ignored_config_paths") printf "[]\n" ;; + *"config ls --tracked-configs") [[ "$FAIL_PHASE" != tracking ]] || return 0; printf "%s/config/shared/mise.toml\n" "$SELFISHELL_ROOT" ;; + *"config ls") [[ "$FAIL_PHASE" != retention ]] ;; + *"prune --tools --yes"*) printf "pruned\n"; [[ "$FAIL_PHASE" != prune ]] ;; + *) return 91 ;; + esac + } + update_tools_and_configuration 1 0 1 "$2" 0 + ' _ "$ROOT_DIR" "${1:-0}" +} + +test_update_prunes_only_after_successful_setup() { + local output phase status + mkdir -p "$XDG_STATE_HOME/selfishell" + printf '1\n' >"$XDG_STATE_HOME/selfishell/configured" + output="$(FAIL_PHASE=none run_update_cleanup_fixture)" + [[ "$output" == *$'packages\nconfiguration\nplugins\npruned\n'* ]] || fail "Cleanup did not follow successful setup: $output" + for phase in packages configuration plugins; do + status=0 + output="$(FAIL_PHASE="$phase" run_update_cleanup_fixture 2>&1)" || status=$? + [[ "$status" != 0 && "$output" != *pruned* ]] || fail "Failed $phase phase still pruned or succeeded: $output" + done + output="$(FAIL_PHASE=optional run_update_cleanup_fixture 2>&1)" + [[ "$output" != *pruned* && "$output" == *'Skipping mise cleanup'* ]] || fail "Partial tool update still pruned: $output" + output="$(FAIL_PHASE=none run_update_cleanup_fixture 1)" + [[ "$output" != *pruned* ]] || fail "Configuration-only update pruned tools" + output="$(FAIL_PHASE=prune run_update_cleanup_fixture 2>&1)" + [[ "$output" == *'Could not prune'* && "$output" == *'synchronized.'* ]] || fail "Cleanup failure hid successful update: $output" + for phase in tracking retention; do + output="$(FAIL_PHASE="$phase" run_update_cleanup_fixture 2>&1)" + [[ "$output" != *pruned* && "$output" == *'Could not prune'* ]] || fail "Failed retention preflight still pruned: $output" + done +} + +test_mise_cleanup_preserves_current_and_project_versions_not_rollback_only() { + local mise_binary version dir before after module + mise_binary="$(command -v mise)" || skip "mise is unavailable for cleanup integration" + [[ -x "$mise_binary" && -f "$mise_binary" ]] || skip "mise executable is unavailable" + export XDG_CONFIG_HOME="$HOME/.config" XDG_DATA_HOME="$HOME/.local/share" XDG_CACHE_HOME="$HOME/.cache" + export MISE_DATA_DIR="$XDG_DATA_HOME/mise" MISE_CACHE_DIR="$XDG_CACHE_HOME/mise" MISE_STATE_DIR="$XDG_STATE_HOME/mise" + export MISE_OFFLINE=1 MISE_TRUSTED_CONFIG_PATHS="$HOME/project with spaces" + unset MISE_GLOBAL_CONFIG_FILE MISE_IGNORED_CONFIG_PATHS __MISE_DIFF __MISE_SESSION + for module in package_manifest platform packages installers releases; do + # shellcheck disable=SC1090 # Load the same modules as the CLI. + source "$ROOT_DIR/lib/$module.sh" + done + export SELFISHELL_ROOT="$HOME/selfishell/releases/2.0.0" + mkdir -p "$SELFISHELL_ROOT/config/shared" "$HOME/selfishell/releases/1.0.0/config/shared" "$HOME/project with spaces" + ln -s releases/2.0.0 "$HOME/selfishell/current" + ln -s releases/1.0.0 "$HOME/selfishell/previous" + for version in 1.0.0 2.0.0; do + mkdir -p "$HOME/selfishell/releases/$version/bin" + touch "$HOME/selfishell/releases/$version/bin/selfishell" + chmod +x "$HOME/selfishell/releases/$version/bin/selfishell" + printf '%s\n' "$version" >"$HOME/selfishell/releases/$version/VERSION" + done + printf '[tools]\nnode = "24.18.0"\n' >"$SELFISHELL_ROOT/config/shared/mise.toml" + printf '[tools]\nnode = "24.13.0"\n' >"$HOME/selfishell/releases/1.0.0/config/shared/mise.toml" + printf '[tools]\nnode = "22.0.0"\n' >"$HOME/project with spaces/mise.toml" + printf 'package all required mise node\npackage macos optional mise go\n' >"$SELFISHELL_ROOT/packages.conf" + package_manifest_load + # shellcheck disable=SC2034 # Consumed by packages_prune_mise. + SELFISHELL_SKIPPED_OPTIONAL_PACKAGES=() + # Real mise inventory/prune with inert installed-version fixtures; no downloads. + for version in 20.0.0 22.0.0 24.13.0 24.18.0; do + dir="$MISE_DATA_DIR/installs/node/$version/bin" + mkdir -p "$dir" + printf '#!/bin/sh\nexit 0\n' >"$dir/node" + chmod +x "$dir/node" + done + mkdir -p "$MISE_DATA_DIR/installs/go/1.20.0/bin" + printf '#!/bin/sh\nexit 0\n' >"$MISE_DATA_DIR/installs/go/1.20.0/bin/go" + chmod +x "$MISE_DATA_DIR/installs/go/1.20.0/bin/go" + "$mise_binary" -C "$HOME/project with spaces" config ls >/dev/null + cd "$HOME/project with spaces" + before="$(find "$HOME" -print | sort)" + packages_prune_mise ubuntu-wsl 1 + after="$(find "$HOME" -print | sort)" + [[ "$before" == "$after" ]] || fail "Cleanup dry-run changed the filesystem" + printf 'invalid\n' >"$HOME/selfishell/releases/1.0.0/VERSION" + if packages_prune_mise ubuntu-wsl 0; then + fail "Cleanup accepted a corrupt rollback release" + fi + [[ -d "$MISE_DATA_DIR/installs/node/20.0.0" ]] || fail "Retention failure deleted tools" + printf '1.0.0\n' >"$HOME/selfishell/releases/1.0.0/VERSION" + MISE_TRUSTED_CONFIG_PATHS="$TEST_ROOT" "$mise_binary" -C "$HOME/selfishell/releases/1.0.0/config/shared" config ls >/dev/null + if MISE_IGNORED_CONFIG_PATHS="$HOME/selfishell/releases/1.0.0" packages_prune_mise ubuntu-wsl 0; then + fail "Cleanup accepted ignored rollback pins despite existing tracking metadata" + fi + [[ -x "$MISE_DATA_DIR/installs/node/24.13.0/bin/node" ]] || fail "Ignored rollback pin was removed" + packages_prune_mise ubuntu-wsl 0 + [[ ! -e "$MISE_DATA_DIR/installs/node/20.0.0" ]] || fail "Unused managed version survived cleanup" + [[ ! -e "$MISE_DATA_DIR/installs/node/24.13.0" ]] || fail "Rollback-only version survived cleanup" + for version in 22.0.0 24.18.0; do + [[ -x "$MISE_DATA_DIR/installs/node/$version/bin/node" ]] || fail "Needed node@$version was removed" + done + [[ -d "$MISE_DATA_DIR/installs/go/1.20.0" ]] || fail "Another platform's tool was removed" + assert_file_content '[tools] +node = "24.13.0"' "$HOME/selfishell/releases/1.0.0/config/shared/mise.toml" + [[ "$(readlink "$HOME/selfishell/previous")" == releases/1.0.0 ]] || fail "Cleanup changed CLI rollback" + [[ -z "${MISE_IGNORED_CONFIG_PATHS:-}" ]] || fail "Cleanup leaked ignored paths into the shell" + # The same rollback version must survive when a project still needs it. + dir="$MISE_DATA_DIR/installs/node/24.13.0/bin" + mkdir -p "$dir" + printf '#!/bin/sh\nexit 0\n' >"$dir/node" + chmod +x "$dir/node" + printf '[tools]\nnode = ["22.0.0", "24.13.0"]\n' >"$HOME/project with spaces/mise.toml" + packages_prune_mise ubuntu-wsl 0 + [[ -x "$dir/node" ]] || fail "Rollback version needed by a project was removed" + mv "$HOME/selfishell/releases/1.0.0/config/shared/mise.toml" "$TEST_ROOT/previous.toml" + ln -s "$HOME/project with spaces/mise.toml" "$HOME/selfishell/releases/1.0.0/config/shared/mise.toml" + if packages_prune_mise ubuntu-wsl 0; then + fail "Cleanup accepted a rollback config linked to a project" + fi + [[ -x "$MISE_DATA_DIR/installs/node/22.0.0/bin/node" ]] || fail "Linked project version was removed" + rm "$HOME/selfishell/releases/1.0.0/config/shared/mise.toml" + mv "$TEST_ROOT/previous.toml" "$HOME/selfishell/releases/1.0.0/config/shared/mise.toml" + # A previous link pointing at current must not exclude the active config. + release_atomic_link releases/2.0.0 "$HOME/selfishell/previous" + [[ "$(readlink "$HOME/selfishell/previous")" == releases/2.0.0 ]] || fail "Fixture did not point previous at current" + packages_prune_mise ubuntu-wsl 0 + [[ -x "$MISE_DATA_DIR/installs/node/24.18.0/bin/node" ]] || fail "Cleanup excluded the current release" + # A literal path must not turn into multiple ignored paths or a glob. + release_atomic_link releases/1.0.0 "$HOME/selfishell/previous" + mv "$HOME/selfishell" "$HOME/selfishell:unsafe" + SELFISHELL_ROOT="$HOME/selfishell:unsafe/releases/2.0.0" + if packages_prune_mise ubuntu-wsl 0; then + fail "Cleanup accepted a rollback path containing mise's list separator" + fi + # Empty scope must never turn into mise's global prune. + printf 'package all required apt git\n' >"$SELFISHELL_ROOT/packages.conf" + package_manifest_load + packages_prune_mise ubuntu 0 + [[ -d "$MISE_DATA_DIR/installs/go/1.20.0" ]] || fail "Empty scope pruned unrelated tools" } test_download_dependency_is_checksum_verified_and_recorded() {