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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions docs/UPDATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion lib/commands/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -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"
Expand Down
76 changes: 76 additions & 0 deletions lib/packages.sh
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
160 changes: 160 additions & 0 deletions tests/updates_test.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down