diff --git a/README.md b/README.md index bc12416..80de9fe 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,8 @@ Ghostty, and platform notes. ## Development Environment -`selfishell install` sets up one consistent environment: Zsh, Git, Vim, -Starship, Zinit, Neovim, mise-managed runtimes, CLI tools, and build tooling. -See [Environment](docs/ENVIRONMENT.md) for tool management and editor behavior. +See [Environment](docs/ENVIRONMENT.md) for managed tools, prompt behavior, +and the Neovim workflow. ## Everyday Commands diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 331cc56..38ff0e3 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -4,18 +4,15 @@ Selfishell installs one development environment. `packages.conf` declares its packages: Zsh, Git, Vim, Starship, Zinit, Neovim, CLI tools, language runtimes, compiler tooling, and optional macOS terminal fonts. -Selfishell installs a pinned mise binary and activates it for -interactive Zsh. Selfishell keeps its defaults in -`${XDG_CONFIG_HOME:-$HOME/.config}/selfishell/mise/selfishell.toml` (which is symlinked to `~/.config/mise/conf.d/selfishell.toml` so it is automatically loaded by `mise`); a project's -`mise.toml` can select different tool versions. - -Developer tools managed by mise use exact reviewed versions pinned in -`config/shared/mise.toml`, the single source of truth for these versions. This -includes Starship, FZF, Zoxide, Ripgrep, Eza, Bat, jq, Neovim, Tree-sitter CLI, Node.js, -Python, uv, and GitHub CLI on both macOS and Ubuntu. Eza and Bat remain optional; -failure to install either does not stop setup. Projects remain free to override -the defaults in a local `mise.toml`. Updating the defaults requires a normal -Selfishell release and never happens during shell startup. +Selfishell installs a pinned mise binary and activates it for interactive Zsh. +Its defaults live under `${XDG_CONFIG_HOME:-$HOME/.config}/selfishell/mise/`, +linked into mise's `conf.d/selfishell.toml`. Project-local `mise.toml` files +can override these defaults. + +`config/shared/mise.toml` pins the reviewed versions of Starship, FZF, Zoxide, +Ripgrep, Eza, Bat, jq, Neovim, Tree-sitter CLI, Node.js, Python, uv, and GitHub +CLI on both macOS and Ubuntu. These defaults change through Selfishell releases; +shell startup never updates them. Mise manages the Starship executable and its version; Selfishell still manages `starship.toml` and prompt initialization. @@ -43,11 +40,9 @@ Install the environment: selfishell install --yes ``` -`selfishell update` uses `packages.conf` to install missing Apt, Homebrew, and directly -managed tools and synchronize mise tools before updating configuration. Apt and -Homebrew retain responsibility for packages still declared through them. -Copies of a tool left from an older release are not removed -automatically; after mise activation, its pinned tool version takes precedence. +See [Updates and rollback](UPDATES.md) for synchronization and cleanup behavior. +Copies of tools left from older package managers are preserved; after mise +activation, its pinned versions take precedence. Package requirements have two failure policies: @@ -55,12 +50,10 @@ Package requirements have two failure policies: - `optional` packages are recommended and attempted automatically, but an unavailable package or installation failure does not stop the rest of setup. -`optional` does not mean that Selfishell asks about each package. Ghostty is the -separate interactive installation choice on macOS. - -On macOS, interactive installation separately asks whether to install Ghostty -and manage its configuration. `--yes` accepts that choice automatically. The -choice is saved and reused by `selfishell update`. +Eza and Bat are optional. Only Ghostty has a separate installation choice: +on macOS, interactive setup asks whether to install it and manage its +configuration. `--yes` accepts that choice automatically. The choice is saved +and reused by `selfishell update`. ## Neovim workflow diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 9e1750c..79cd4c2 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -19,8 +19,9 @@ SELFISHELL_BENCHMARK_PROFILE=full bash scripts/benchmark.sh ``` Each metric reports the mean, median (`p50`), 95th percentile (`p95`), and -maximum duration in milliseconds. `interactive-cached` starts a complete -interactive Zsh through the platform `.zshrc`. +maximum duration in milliseconds. `interactive-cached` loads the platform +`.zshrc` and exits; it does not measure a visible prompt, command-to-prompt +latency, or deferred plugin readiness. Measure those separately in a terminal. ### Base mode @@ -37,21 +38,14 @@ checkout. ### Full-environment mode -Full mode additionally provisions the pinned mise, Starship, fzf, zoxide, and Zinit -- with -its pinned Zsh plugins -- into the benchmark's own isolated `HOME`, via the -same code path the real installer uses, so `interactive-cached` reflects a -real full-environment startup rather than whatever happens to already be on -the runner's `PATH`. It: - -- uses an isolated, temporary `HOME`; the real user `HOME` is never read or - changed; -- uses that home as its working directory and gives mise an isolated global - config; -- installs the pinned mise and Zinit (with its pinned plugins), plus Starship, - fzf, and zoxide through mise using the release's exact pins; -- needs network access to provision those tools, so it is not part of the - regular (network-free) unit test suite, or run in CI -- run it locally - when needed. +Full mode provisions pinned mise, Starship, fzf, zoxide, Zinit, and its Zsh +plugins through the production installers into the temporary `HOME`. It uses +the release's exact pins and an isolated mise configuration, so measurements +include the managed shell integrations without changing the developer's tools +or plugin checkouts. Provisioning requires network access; run it locally when +needed, outside CI and the network-free test suite. + +## Startup caches Interactive startup audits completion directories on first use and once daily. The `.zcompdump.audit` marker records the audit separately from `.zcompdump`, @@ -89,11 +83,9 @@ SELFISHELL_BENCHMARK_ZPROF_FILE=/tmp/selfishell-startup.zprof \ bash scripts/benchmark.sh --mode full ``` -The report ranks initialization functions by time and is intended for finding -expensive startup paths. The benchmark loads Zsh's built-in `zsh/zprof` module -only for this additional diagnostic startup, after every reported metric and -budget check has completed. It adds no code or dependency to ordinary shell -startup and does not enforce a performance threshold. +The report ranks initialization functions by time. The profiler runs once after +all timed measurements and budget checks; it adds no overhead to ordinary +startup and enforces no threshold. ## Budgets diff --git a/docs/PYTHON.md b/docs/PYTHON.md index fcc5ac5..6cea481 100644 --- a/docs/PYTHON.md +++ b/docs/PYTHON.md @@ -1,20 +1,13 @@ # Python Development with Selfishell -Selfishell manages Python runtimes and packages using a modern, fast toolchain powered by **`mise`** and **`uv`**. This workflow ensures high performance, reproducibility, and clean environment isolation. - -## Toolchain Overview - -* **Runtime Manager (`mise`)**: Handles the installation of global and local Python versions. -* **Package & Virtualenv Manager (`uv`)**: Handles dependencies, virtual environments, and project bootstrapping at near-instant speed. -* **Auto-Activation**: When configured, entering a uv project directory (one with a `uv.lock` file) will automatically manage and activate its virtual environment. - ---- +Selfishell uses `mise` for Python versions and `uv` for packages and virtual +environments. Project settings can enable automatic virtualenv activation. ## Getting Started ### 1. Creating a Virtual Environment -Navigate to your Python project directory and run `uv venv` to create a virtual environment. It will be created in a `.venv` folder by default. +Create a `.venv` in your project directory: ```bash cd /path/to/project @@ -29,30 +22,30 @@ uv venv --python 3.12 ### 2. Auto-Activation -`python.uv_venv_auto` integrates mise with uv to manage a project's virtual environment, but it applies to uv projects that have a `uv.lock` file, not simply any directory containing a `.venv`. A `uv.lock` is normally created as part of a uv project workflow, for example by `uv lock` or `uv sync`. +`python.uv_venv_auto` requires a uv project with a `uv.lock` file, created by +`uv lock` or `uv sync`; a `.venv` directory alone is insufficient. -Add the setting to the `[settings]` section of your project's local `mise.toml` file: +Add this setting to your project's `mise.toml`: ```toml [settings] python.uv_venv_auto = "create|source" ``` -`"source"` activates an existing virtual environment; `"create|source"` also creates one when necessary before activating it. +`"source"` activates an existing virtual environment; `"create|source"` also +creates one when necessary. Once configured, entering the uv project directory will activate it: ```bash cd /path/to/project -# Your shell prompt (Starship) will show the active virtual environment (.venv) -# Run which to verify which python -# Should output: /path/to/project/.venv/bin/python +# Expected: /path/to/project/.venv/bin/python ``` ### 3. Installing Packages -Use `uv pip` to install packages inside the active virtual environment at high speeds: +Install packages in the virtual environment: ```bash uv pip install requests @@ -70,11 +63,8 @@ To generate a pinned lock file from dependency specifications: uv pip compile pyproject.toml -o requirements.txt ``` ---- - ## Editor Integration (Neovim) -Selfishell's built-in Neovim configuration integrates with Python LSP and tools. - -To ensure Neovim can resolve your project dependencies, always run `nvim` from the project root after the virtual environment has been created and packages have been installed. -Once auto-activation is configured, `mise` will automatically activate the uv project's `.venv` when entering its directory, allowing Neovim to inherit the correct path to the local virtualenv Python interpreter. +Create the virtual environment and install dependencies before launching +`nvim` from the project root. With auto-activation configured, Neovim inherits +the uv project's virtualenv Python path for its Python tooling. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b08adfa..0f6bb2f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -102,9 +102,4 @@ Review the diff and CI results, merge when ready, then publish a normal patch release by creating the next release tag. Use `scripts/next-patch-version.sh` when you want the helper to calculate that patch version. -Every release asset receives signed build provenance through GitHub Artifact -Attestations before publication. Verification requires GitHub CLI: - -```bash -gh attestation verify PATH_TO_ARCHIVE --repo jiminu/selfishell -``` +For manual archive verification, see the [security model](SECURITY.md). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b268561..b4bc4bc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -11,7 +11,8 @@ release provenance and preservation of existing files are security boundaries. checksums or stronger provenance when supported by the selected backend. - Git dependencies use an approved tag or commit. - Existing configuration is backed up and tracked before managed replacement. -- Interactive shell startup performs no network update. +- Shell startup never installs updates; release metadata can refresh in the + background. See [update notices](UPDATES.md#status-and-update-notices). - LSP servers added with `:LspInstall` are not version-approved the way the default servers (lua_ls, pyright, bashls, jsonls, yamlls, tombi, marksman) are: they install from the Mason registry, unpinned, the diff --git a/docs/UPDATES.md b/docs/UPDATES.md index 6ac4b77..b815224 100644 --- a/docs/UPDATES.md +++ b/docs/UPDATES.md @@ -1,131 +1,113 @@ # Updates and Rollback -By default, `selfishell update` updates to the latest release and then -synchronizes that release's managed environment. The CLI release is switched -first; if it changed, the new CLI continues the same command so packages newly -added to that release's package list are included. If the target release is -already installed, the command reports that and exits without changing -anything. Use `--tools-only` to explicitly resynchronize the current release's -tools and configuration regardless of whether a new release is available. - -A successful update finishes with one result line naming the version -transition, for example `Selfishell updated: 1.2.10 -> 1.2.14`. Release details -stay on the GitHub Release rather than being reproduced in the CLI, and output -for work that actually changed the environment, warnings, errors, and -`--dry-run` previews are unaffected. Because that result closes the whole -command, declining the tools/configuration confirmation or failing in that -phase ends the run without reporting the version change even though the CLI -release has already switched; `selfishell version` confirms the active release. -`--tools-only` closes with `Selfishell tools and configuration synchronized.` -and no version transition: that phase resynchronizes the release's tools and -configuration whether or not anything changes, so its result does not claim -one. +`selfishell update` switches to the latest CLI release, then uses that release's +package list to synchronize tools and configuration. If the target release is +already active, it exits without changing anything; use `--tools-only` to +resynchronize the current environment. + +## Update modes + +| Command | Effect | +| --- | --- | +| `selfishell update` | Update the CLI, then synchronize tools and configuration. | +| `selfishell update --cli-only` | Update only the CLI release. | +| `selfishell update --tools-only` | Synchronize the current release's tools and configuration. | +| `selfishell update --skip-packages` | Update the CLI, then apply configuration if the release changed. | +| `selfishell update --tools-only --skip-packages` | Reapply current configuration without network access. | + +Add `--yes` for non-interactive confirmation or `--dry-run` to preview the +selected phases without changing tools, configuration, or the active release. +Use `--version VERSION` to select an exact release; it cannot be combined with +`--tools-only`. + +A successful update reports the version transition, such as +`Selfishell updated: 1.2.10 -> 1.2.14`; release notes are on GitHub. +If the tools/configuration phase is declined or fails, the CLI may already have +switched: check `selfishell version` for the active release. Tools-only updates +finish with `Selfishell tools and configuration synchronized.` + +## Tools and configuration + +Synchronization installs missing Apt or Homebrew packages from `packages.conf`, +applies approved direct-tool and Git dependency versions from `dependencies.conf`, +synchronizes mise tools and Neovim plugins, and reapplies managed configuration. +Tree-sitter parsers install on first opening their filetype. Existing Apt and +Homebrew packages are not upgraded; use `brew upgrade` or the operating system's +Apt upgrade policy separately. + +`--skip-packages` skips all package and tool installation when this phase runs, +matching `selfishell install --skip-packages`. CLI-only and already-current +default updates skip the entire phase. + +### Unused mise versions + +After successful synchronization, Selfishell runs `mise prune --tools --yes` +for its declared mise tools on the current platform. It registers the current +release's configuration to protect its pins and excludes the previous release's +configuration during cleanup, even if already tracked. Rollback-only tool +versions can be removed; the previous CLI release and its files remain intact. + +Versions needed by tracked, trusted project configurations are retained. +Unrelated tools and configuration tracking remain intact. Cleanup also covers +versions installed outside Selfishell: versions used only through environment +variables, one-off `mise exec tool@version`, or untracked projects can be +removed. Record those versions in project configuration and load it with mise +before updating. See [mise prune](https://mise.jdx.dev/cli/prune.html). + +Cleanup is skipped on installation, rollback, `--cli-only`, `--skip-packages`, +an already-current default update, or incomplete synchronization, including +optional package failures. Configured mise `ignored_config_paths` also disables +cleanup because ignored configurations cannot protect their pins. Cleanup or +retention-preflight failures warn without undoing the successful synchronization. + +`--dry-run` describes the cleanup scope without invoking mise or writing its +tracking/cache metadata; it does not list individual deletion candidates. + +## CLI releases and rollback + +The CLI phase downloads a versioned platform archive, verifies its SHA-256 +checksum, and switches `current` only after validation. It retains the active +and previous releases, removing older inactive releases. Version discovery +prefers the latest stable release; if none exists, it accepts the newest version +tag only when that exact release's `VERSION` asset is published. ```sh -selfishell status -selfishell update --yes -selfishell update --cli-only --yes -selfishell update --tools-only --yes -selfishell update --skip-packages --yes selfishell rollback --yes ``` -The tools/configuration phase synchronizes apt or Homebrew packages from the -release's `packages.conf`, installs directly managed tools at the approved versions in -`dependencies.conf`, synchronizes mise-managed developer tools, reapplies -managed configuration, and synchronizes Neovim plugins; -Tree-sitter parsers install lazily the first time their filetype is -opened. Already installed operating-system packages remain -managed by apt or Homebrew; this command does not perform a general package -upgrade. A CLI-only update skips this phase, as does a default update -that finds the target release already installed; -`selfishell update --tools-only --skip-packages` reapplies just the managed -configuration for the current release. - -`--skip-packages` applies only when the tools/configuration phase runs: it -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. - -`status` does not check Apt or Homebrew for available package updates. Use -`brew upgrade` or the operating system's Apt upgrade policy to apply system -package updates explicitly. - -The CLI phase downloads a versioned platform archive, verifies its published -SHA-256 checksum, retains the active release, and switches `current` only after -validation. After a successful switch, only the active release and the previous -rollback release are retained; older inactive releases are removed. Automatic -version discovery prefers the latest stable release. If -there is no stable release, it checks the newest version tag and accepts it only -when that exact release's `VERSION` asset is available. Use `--version VERSION` -to select an exact release. `--version` cannot be combined with `--tools-only`. - -`--dry-run` previews every selected phase without changing tools, -configuration, or the active CLI release. - -`selfishell rollback` exchanges the `current` and `previous` release links and -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. -That manifest is also the source of truth for exact Neovim plugin commits, so a -repository `lazy-lock.json` is intentionally unnecessary. lazy.nvim may write a -runtime lock under the Selfishell state directory, but updates cannot move a -plugin beyond the commit approved in the release manifest. -`packages.conf` declares mise-managed developer tool membership; -exact default versions are pinned only in `config/shared/mise.toml`, updated through -the same review-and-release boundary. Individual project `mise.toml` files -remain outside Selfishell's update lifecycle. - -Maintainers can run `scripts/update-dependencies.sh` to discover current -upstream releases, download mise platform artifacts, calculate their -checksums, and bump mise-managed tool versions, including Starship. The weekly `Dependency -updates` workflow uses the same script and opens or refreshes a review PR -only when a tracked file changes. It never merges the PR or publishes a -Selfishell release. Review -upstream release notes and the generated checksums before merging, then publish -a normal Selfishell patch release so users receive the approved versions through -`selfishell update`. - -Merging the generated `automation/dependency-updates` PR does not publish a -release by itself. A maintainer runs the normal manual release process -afterward, the same as for any other change. - -Interactive Zsh sessions read the installed `VERSION` file and show a cached -notification when a newer Selfishell CLI release is available. The cache is -refreshed in the background at most once per day, so neither a CLI process nor -the network request blocks shell startup. The notification never installs an -update automatically. Disable it or change its interval in `~/.zshrc`, outside -the marked Selfishell loader block: +Rollback exchanges `current` and `previous` without network access. It restores +only the CLI, leaving configuration and tools unchanged. Reapplying an older +environment may require downloading tool versions removed by cleanup. Select +an exact retained version with `selfishell rollback VERSION`. + +## Approved versions + +Direct-tool and Git dependency versions, including exact Neovim plugin commits, +are approved in `dependencies.conf`. A repository `lazy-lock.json` is unnecessary: +lazy.nvim's runtime lock lives under Selfishell's state directory, and updates +cannot move plugins beyond approved commits. + +`packages.conf` declares mise tool membership; `config/shared/mise.toml` pins +exact defaults. Both manifests change through review and a Selfishell release. +Project-local `mise.toml` files remain outside this lifecycle. + +Maintainers use `scripts/update-dependencies.sh` to discover upstream releases, +calculate downloaded mise artifact checksums, and update mise tool pins. The +weekly workflow runs the same script and opens or refreshes +`automation/dependency-updates` only when tracked files change. It never merges +or publishes. Review upstream release notes, checksums, and CI before merging, +then follow the [release procedure](RELEASING.md) to deliver the changes. + +## Status and update notices + +`selfishell status` reports local CLI, rollback, tool, and managed-resource state +without checking the network or available Apt/Homebrew updates. Use +`selfishell version --available` to check the latest release. + +Interactive Zsh reads the installed `VERSION` and displays a cached notice for +newer releases. Metadata refreshes in the background at most once per day; +startup does not wait for a CLI process or network request, and notices never +install updates. Configure notices in `~/.zshrc`, outside the marked loader block: ```zsh export SELFISHELL_UPDATE_NOTICE=0 diff --git a/lib/managed.sh b/lib/managed.sh index a3b059d..c73a01c 100644 --- a/lib/managed.sh +++ b/lib/managed.sh @@ -59,14 +59,18 @@ managed_write_state() { local backup="$6" local checksum="$7" local state_file - local temporary_file + local temporary_file state_content - mkdir -p "$SELFISHELL_RESOURCE_STATE_DIR" || return "$SELFISHELL_EXIT_ERROR" state_file="$(managed_state_path "$resource")" + printf -v state_content '2\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + "$type" "$status" "$target" "$reference" "$backup" "$checksum" + if managed_path_is_regular_file "$state_file" && cmp -s "$state_file" <(printf '%s' "$state_content"); then + return 0 + fi + mkdir -p "$SELFISHELL_RESOURCE_STATE_DIR" || return "$SELFISHELL_EXIT_ERROR" temporary_file="$(mktemp "${state_file}.tmp.XXXXXX")" || return "$SELFISHELL_EXIT_ERROR" - if ! printf '2\n%s\n%s\n%s\n%s\n%s\n%s\n' \ - "$type" "$status" "$target" "$reference" "$backup" "$checksum" >"$temporary_file"; then + if ! printf '%s' "$state_content" >"$temporary_file"; then rm -f "$temporary_file" return "$SELFISHELL_EXIT_ERROR" fi diff --git a/lib/packages.sh b/lib/packages.sh index 965c1ac..6bbdb07 100644 --- a/lib/packages.sh +++ b/lib/packages.sh @@ -4,6 +4,7 @@ packages_prune_mise() ( local platform="$1" dry_run="$2" local index package_platform mise_command previous version tracked ignored + local tracked_config config_tracked=0 local config="$SELFISHELL_ROOT/config/shared/mise.toml" previous_config="" local tools=() @@ -68,11 +69,14 @@ packages_prune_mise() ( 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 + # Require tracked pins despite warning-only failures; mise returns canonical paths. + while IFS= read -r tracked_config; do + if [[ "$tracked_config" -ef "$config" ]]; then + config_tracked=1 + break + fi + done <<<"$tracked" + ((config_tracked)) || return 1 "$mise_command" -C "$SELFISHELL_ROOT/config/shared" prune --tools --yes "${tools[@]}" ) diff --git a/scripts/neovim-e2e.sh b/scripts/neovim-e2e.sh index dccda37..830247e 100644 --- a/scripts/neovim-e2e.sh +++ b/scripts/neovim-e2e.sh @@ -118,13 +118,8 @@ done <"$ROOT_DIR/dependencies.conf" [[ -r "$XDG_STATE_HOME/selfishell/nvim/lazy-lock.json" ]] || fail "lazy.nvim runtime lock is missing" [[ ! -e "$XDG_CONFIG_HOME/selfishell/nvim/lazy-lock.json" ]] || fail "lazy.nvim lock polluted managed configuration" -# Parsers install lazily in the background on first open of a filetype, so the -# checks below poll with vim.wait() rather than asserting presence immediately. -# -# config.autocmds no longer re-fires FileType after an install, which used to -# make rainbow-delimiters retry once a parser appeared. So a first-ever open -# proves only the install and Tree-sitter highlighting; rainbow-delimiters is -# checked separately below against an already-installed parser. +# Wait for first-use parser installation and highlighting. Test rainbow-delimiters +# separately with an installed parser: installation does not re-fire FileType. printf 'terraform { required_version = ">= 1.0" }\n' >"$TEST_ROOT/main.tf" if ! smoke_output="$(nvim --headless "$TEST_ROOT/main.tf" \ @@ -150,15 +145,8 @@ fi fail "Python highlighting smoke did not complete" } -# A second Neovim process: the Python parser is already on disk, so this is -# the ordinary FileType flow with no in-flight install. -# -# start() attaches a LanguageTree but parses lazily, normally at the next -# redraw. rainbow-delimiters attaches synchronously at FileType and reads -# whatever tree exists then, so interactively the post-startup redraw is what -# produces the first highlight -- and headless has no redraw. parse() here is -# what that redraw would trigger internally, firing the same on_changedtree -# callback that populates its marks. +# Reopen with the installed parser. Headless Neovim has no redraw, so parse() +# triggers the tree-change callback that populates rainbow-delimiters marks. if ! rainbow_smoke_output="$(nvim --headless "$TEST_ROOT/main.py" \ '+lua local bufnr = vim.api.nvim_get_current_buf(); assert(vim.bo.filetype == "python", "unexpected filetype: " .. vim.bo.filetype); local parser_ok, parser = pcall(vim.treesitter.get_parser, bufnr, "python"); assert(parser_ok, "Python parser was not already installed for the second process"); parser:parse(); local rainbow = require("rainbow-delimiters.lib"); local attached = vim.wait(5000, function() local settings = rainbow.buffers[bufnr]; if not settings then return false end; local marks = vim.api.nvim_buf_get_extmarks(bufnr, rainbow.nsids.python, 0, -1, { details = true }); for _, mark in ipairs(marks) do local hl = mark[4].hl_group; if type(hl) == "string" and hl:find("RainbowDelimiter", 1, true) == 1 then return true end end return false end); assert(attached, "rainbow-delimiters did not highlight a Python buffer with an already-installed parser"); print("Rainbow-delimiters smoke: OK")' \ +qa 2>&1)"; then diff --git a/tests/managed_install_test.bash b/tests/managed_install_test.bash index babcc69..4170f83 100755 --- a/tests/managed_install_test.bash +++ b/tests/managed_install_test.bash @@ -119,7 +119,18 @@ test_install_copies_configuration_and_tracks_resources() { fail "Zsh loader state version was not recorded" [[ "$(sed -n '2p' "$XDG_STATE_HOME/selfishell/resources/user-zshrc.state")" == block ]] || fail "Zsh loader was not recorded as a managed block" - [[ -f "$XDG_CONFIG_HOME/mise/config.toml" ]] || fail "Install omitted the user mise config" + assert_symlink_to "$XDG_CONFIG_HOME/selfishell/nvim" "$XDG_CONFIG_HOME/nvim" + assert_symlink_to "$XDG_CONFIG_HOME/selfishell/mise/selfishell.toml" "$XDG_CONFIG_HOME/mise/conf.d/selfishell.toml" + cmp -s "$ROOT_DIR/config/shared/nvim/init.lua" "$XDG_CONFIG_HOME/selfishell/nvim/init.lua" || + fail "Neovim init.lua was not installed for the development environment" + cmp -s "$ROOT_DIR/config/shared/nvim/lua/config/options.lua" "$XDG_CONFIG_HOME/selfishell/nvim/lua/config/options.lua" || + fail "Neovim options module was not installed for the development environment" + cmp -s "$ROOT_DIR/config/shared/nvim/lua/plugins/lsp.lua" "$XDG_CONFIG_HOME/selfishell/nvim/lua/plugins/lsp.lua" || + fail "Neovim lsp plugin was not installed for the development environment" + [[ -f "$XDG_CONFIG_HOME/mise/config.toml" && ! -L "$XDG_CONFIG_HOME/mise/config.toml" ]] || + fail "Developer install did not create a user-owned mise config" + ! grep -Fqx "$XDG_CONFIG_HOME/mise/config.toml" "$SELFISHELL_RESOURCE_STATE_DIR"/*.state || + fail "User-owned mise config was recorded as a managed resource" } test_install_switches_login_shell_to_zsh() { @@ -144,24 +155,6 @@ EOF [[ "$chsh_arguments" == *zsh* ]] || fail "Install did not request a Zsh login shell" } -test_install_includes_neovim_configuration() { - printf 'original zshrc' >"$HOME/.zshrc" - run_selfishell install --skip-packages --yes >/dev/null - - assert_symlink_to "$XDG_CONFIG_HOME/selfishell/nvim" "$XDG_CONFIG_HOME/nvim" - assert_symlink_to "$XDG_CONFIG_HOME/selfishell/mise/selfishell.toml" "$XDG_CONFIG_HOME/mise/conf.d/selfishell.toml" - cmp -s "$ROOT_DIR/config/shared/nvim/init.lua" "$XDG_CONFIG_HOME/selfishell/nvim/init.lua" || - fail "Neovim init.lua was not installed for the development environment" - cmp -s "$ROOT_DIR/config/shared/nvim/lua/config/options.lua" "$XDG_CONFIG_HOME/selfishell/nvim/lua/config/options.lua" || - fail "Neovim options module was not installed for the development environment" - cmp -s "$ROOT_DIR/config/shared/nvim/lua/plugins/lsp.lua" "$XDG_CONFIG_HOME/selfishell/nvim/lua/plugins/lsp.lua" || - fail "Neovim lsp plugin was not installed for the development environment" - [[ -f "$XDG_CONFIG_HOME/mise/config.toml" && ! -L "$XDG_CONFIG_HOME/mise/config.toml" ]] || - fail "Developer install did not create a user-owned mise config" - ! grep -Fqx "$XDG_CONFIG_HOME/mise/config.toml" "$SELFISHELL_RESOURCE_STATE_DIR"/*.state || - fail "User-owned mise config was recorded as a managed resource" -} - test_macos_install_includes_ghostty_configuration() { export SELFISHELL_TEST_SYSTEM_NAME=Darwin mkdir -p "$XDG_CONFIG_HOME/ghostty" @@ -631,11 +624,20 @@ test_status_does_not_inspect_user_ghostty() { test_install_is_idempotent() { local first_backup_count local second_backup_count + local state_file printf 'original zshrc' >"$HOME/.zshrc" run_selfishell install --skip-packages --yes >/dev/null first_backup_count="$(find "$HOME" -name '*.backup.*' | wc -l)" + mkdir "$TEST_ROOT/saved-states" + for state_file in "$SELFISHELL_RESOURCE_STATE_DIR"/*.state; do + ln "$state_file" "$TEST_ROOT/saved-states/${state_file##*/}" + done run_selfishell install --skip-packages --yes >/dev/null + for state_file in "$SELFISHELL_RESOURCE_STATE_DIR"/*.state; do + [[ "$state_file" -ef "$TEST_ROOT/saved-states/${state_file##*/}" ]] || + fail "A second installation rewrote unchanged state: $state_file" + done second_backup_count="$(find "$HOME" -name '*.backup.*' | wc -l)" [[ "$second_backup_count" -eq "$first_backup_count" ]] || @@ -1802,11 +1804,14 @@ EOF fail "A forced managed_write_state failure left a temporary file behind" } -test_unchanged_block_state_refresh_failure_does_not_report_unchanged() { +test_pending_block_state_refresh_failure_does_not_report_unchanged() { local fake_bin="$TEST_ROOT/bin" local status=0 run_selfishell install --skip-packages --yes >/dev/null + local state_file="$SELFISHELL_RESOURCE_STATE_DIR/user-zshrc.state" + sed '3s/active/pending/' "$state_file" >"$TEST_ROOT/pending.state" + mv "$TEST_ROOT/pending.state" "$state_file" mkdir -p "$fake_bin" cat >"$fake_bin/mv" <<'EOF' @@ -1825,7 +1830,7 @@ EOF status=$? set -e - ((status != 0)) || fail "A forced state-refresh failure for an unchanged block should propagate" + ((status != 0)) || fail "A forced state-refresh failure for a pending block should propagate" } test_managed_file_overwrite_conflict_atomic_copy_failure_preserves_backup_and_state() { diff --git a/tests/package_adapters_test.bash b/tests/package_adapters_test.bash index 32d6479..d2c994d 100755 --- a/tests/package_adapters_test.bash +++ b/tests/package_adapters_test.bash @@ -114,37 +114,34 @@ test_apt_reinstalls_removed_but_not_purged_package() { fail "A removed-but-not-purged (rc) package was not reinstalled: got [$MOCK_INSTALLED_PACKAGES]" } -test_apt_skips_index_update_when_packages_are_installed() { +test_apt_leaves_installed_packages_unchanged_and_quiet() { + setup_test_home reset_package_mocks MOCK_DPKG_PACKAGES="first second" - apt_install_managed_packages required 0 first second + apt_install_managed_packages required 0 first second >"$TEST_ROOT/output" [[ "$MOCK_APT_UPDATE_COUNT" -eq 0 ]] || fail "Installed apt packages triggered an index update" [[ -z "$MOCK_INSTALLED_PACKAGES" ]] || fail "Installed apt packages were reinstalled" + [[ ! -s "$TEST_ROOT/output" ]] || fail "Installed apt packages produced output" } -test_apt_suppresses_already_installed_output() { - local output - reset_package_mocks - MOCK_DPKG_PACKAGES="first second" +test_homebrew_leaves_installed_packages_unchanged_and_quiet() { + local manager requirement + setup_test_home - output="$(apt_install_managed_packages required 0 first second)" + for manager in formula cask; do + reset_package_mocks + MOCK_BREW_FORMULAE=$'first\nsecond' + MOCK_BREW_CASKS=$'first\nsecond' - [[ -z "$output" ]] || fail "Installed apt packages produced output: $output" -} - -test_homebrew_suppresses_already_installed_output() { - local output - reset_package_mocks - MOCK_BREW_FORMULAE="installed" - MOCK_BREW_CASKS="font-one" + requirement=optional + [[ "$manager" != formula ]] || requirement=required + homebrew_install_packages "$requirement" "$manager" 0 first second >"$TEST_ROOT/output" - output="$(homebrew_install_packages required formula 0 installed)" - [[ -z "$output" ]] || fail "Installed Homebrew formulae produced output: $output" - - output="$(homebrew_install_packages optional cask 0 font-one)" - [[ -z "$output" ]] || fail "Installed Homebrew casks produced output: $output" + [[ -z "$MOCK_INSTALLED_PACKAGES" ]] || fail "Installed Homebrew $manager packages were reinstalled" + [[ ! -s "$TEST_ROOT/output" ]] || fail "Installed Homebrew $manager packages produced output" + done } test_apt_non_root_requires_sudo() { @@ -215,15 +212,6 @@ test_homebrew_installs_only_missing_packages() { [[ "$MOCK_INSTALLED_PACKAGES" == missing ]] || fail "Homebrew did not filter installed formulae" } -test_homebrew_skips_install_when_packages_are_installed() { - reset_package_mocks - MOCK_BREW_CASKS=$'font-one\nfont-two' - - homebrew_install_packages optional cask 0 font-one font-two - - [[ -z "$MOCK_INSTALLED_PACKAGES" ]] || fail "Installed Homebrew casks were reinstalled" -} - test_homebrew_suppresses_duplicate_confirmation() { reset_package_mocks @@ -242,4 +230,4 @@ test_homebrew_required_failure_fails() { fi } -run_discovered_tests +run_discovered_tests '' teardown_test_home diff --git a/tests/release_bootstrap_test.bash b/tests/release_bootstrap_test.bash index 026647f..060ba16 100755 --- a/tests/release_bootstrap_test.bash +++ b/tests/release_bootstrap_test.bash @@ -491,14 +491,8 @@ test_update_preserves_non_link_previous_path_with_a_warning() { fail "A preserved non-symlink previous path did not warn: $output" } -# release_installation_paths already refuses to treat a non-symlink current -# path as a versioned installation at all, so a corrupted current can never -# reach release_atomic_link to be silently replaced. -# -# Invokes the retained release's own binary, not prefix/bin/selfishell: that -# entrypoint embeds "current" as a path component, so once current is a regular -# file the shell fails with ENOTDIR before release_installation_paths runs -- -# and the test would pass even with that guard deleted. +# Run the retained binary so the guard handles a non-link current path; +# the public entrypoint would fail with ENOTDIR before reaching the guard. test_update_rejects_non_link_current_path() { local version status retained_cli diff --git a/tests/run.bash b/tests/run.bash index 51c879b..f1dcaf3 100755 --- a/tests/run.bash +++ b/tests/run.bash @@ -22,10 +22,8 @@ run_suite() { } main() { - local suite_index=0 local suite_jobs="${SELFISHELL_SUITE_JOBS:-4}" - local batch_index - local failures=0 + local failures=0 result=0 local suite local suite_path local failed_suites=() @@ -35,9 +33,6 @@ main() { lifecycle_e2e_test.bash common_zsh_test.bash ) - local batch_logs=() - local batch_pids=() - local batch_suites=() case "$suite_jobs" in '' | *[!0-9]* | 0) @@ -58,31 +53,23 @@ main() { trap 'rm -rf "$log_root"' EXIT HUP INT TERM printf 'Running %d test suites (jobs: %d)\n' "${#suites[@]}" "$suite_jobs" - while ((suite_index < ${#suites[@]})); do - batch_logs=() - batch_pids=() - batch_suites=() + export ROOT_DIR log_root + export -f run_suite + # shellcheck disable=SC2016 # Expand variables in each worker. + printf '%s\0' "${suites[@]}" | xargs -0 -n 1 -P "$suite_jobs" bash -c ' + status=0 + run_suite "$1" >"$log_root/$1.log" 2>&1 || status=$? + printf "%s\n" "$status" >"$log_root/$1.status" + ' _ || result=1 - for ((batch_index = 0; batch_index < suite_jobs && suite_index < ${#suites[@]}; batch_index++)); do - suite="${suites[$suite_index]}" - batch_logs+=("$log_root/$suite_index.log") - batch_suites+=("$suite") - run_suite "$suite" >"$log_root/$suite_index.log" 2>&1 & - batch_pids+=("$!") - suite_index=$((suite_index + 1)) - done - - for batch_index in "${!batch_pids[@]}"; do - if ! wait "${batch_pids[$batch_index]}"; then - failures=$((failures + 1)) - failed_suites+=("${batch_suites[$batch_index]}") - fi - cat "${batch_logs[$batch_index]}" - done + for suite in "${suites[@]}"; do + [[ ! -f "$log_root/$suite.log" ]] || cat "$log_root/$suite.log" + if [[ ! -f "$log_root/$suite.status" || "$(<"$log_root/$suite.status")" != 0 ]]; then + failures=$((failures + 1)) + failed_suites+=("$suite") + fi done - # Name the suites: every suite's output is interleaved above, so a bare count - # leaves the reader grepping hundreds of lines for the failure. if ((failures > 0)); then printf '%d test suite(s) failed: %s\n' "$failures" "${failed_suites[*]}" >&2 return 1 @@ -90,6 +77,7 @@ main() { trap - EXIT HUP INT TERM rm -rf "$log_root" + return "$result" } main "$@" diff --git a/tests/test_helper_test.bash b/tests/test_helper_test.bash index 0b18524..18ce3a4 100644 --- a/tests/test_helper_test.bash +++ b/tests/test_helper_test.bash @@ -195,4 +195,42 @@ EOF teardown_test_home } +# shellcheck disable=SC2016 # Fixture variables expand in child shells. +test_suite_runner_refills_slots_and_preserves_failure_reports() { + local fixture output status=0 + + setup_test_home + fixture="$TEST_ROOT/fixture/tests" + mkdir -p "$fixture" + cp "$ROOT_DIR/tests/run.bash" "$fixture/run.bash" + cat >"$fixture/managed_install_test.bash" <<'EOF' +for ((attempt = 0; attempt < 100; attempt++)); do + [[ ! -f "$HOME/released" ]] || break + sleep 0.1 +done +[[ -f "$HOME/released" ]] || exit 1 +: >"$HOME/completed" +EOF + printf 'exit 1\n' >"$fixture/release_bootstrap_test.bash" + printf ': >"$HOME/released"\n' >"$fixture/lifecycle_e2e_test.bash" + printf 'exit 0\n' >"$fixture/common_zsh_test.bash" + printf ': >"$HOME/last-suite"\n' >"$fixture/extra_test.bash" + + output="$(SELFISHELL_SUITE_JOBS=2 bash "$fixture/run.bash" 2>&1)" || status=$? + + [[ -f "$HOME/completed" ]] || fail "An idle suite slot waited for the whole batch" + [[ -f "$HOME/last-suite" ]] || fail "A failing suite prevented later suites from running" + [[ "$status" == 1 && "$output" == *'1 test suite(s) failed: release_bootstrap_test.bash'* ]] || + fail "Suite runner lost the failing suite's name or exit status: $output" + [[ "$(grep -c '^SUITE:' <<<"$output")" == 5 ]] || fail "Suite runner omitted a suite log: $output" + + mkdir "$TEST_ROOT/bin" + printf '#!/bin/sh\nexit 1\n' >"$TEST_ROOT/bin/xargs" + chmod +x "$TEST_ROOT/bin/xargs" + status=0 + output="$(PATH="$TEST_ROOT/bin:$PATH" SELFISHELL_SUITE_JOBS=2 bash "$fixture/run.bash" 2>&1)" || status=$? + [[ "$status" == 1 && "$output" == *'5 test suite(s) failed:'* ]] || + fail "Dispatcher failure was counted as an extra suite: $output" +} + run_discovered_tests '' teardown_test_home diff --git a/tests/updates_test.bash b/tests/updates_test.bash index e5f8394..369464a 100644 --- a/tests/updates_test.bash +++ b/tests/updates_test.bash @@ -135,6 +135,9 @@ test_mise_cleanup_preserves_current_and_project_versions_not_rollback_only() { 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" + # Exercise symlinked parents and repeated separators on every platform. + ln -s "$HOME/selfishell" "$HOME/release alias" + SELFISHELL_ROOT="$HOME/release alias//releases/2.0.0" 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