diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3da342..7e9d8f91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,11 +37,7 @@ jobs: if [[ "$EVENT_NAME" == workflow_dispatch ]]; then classification=$'runtime=true\nubuntu_container_e2e=true' else - # Run the classifier as it exists at $BASE_SHA, not the checked-out - # (possibly PR-modified) copy -- otherwise a PR could edit this - # script to always report "nothing changed" and suppress its own - # e2e coverage. $BASE_SHA predates the PR/push, so its content - # can't have been written by it. + # Use the base revision so a PR cannot change the classifier to skip its own E2E checks. classifier_script="$(mktemp)" git show "$BASE_SHA:scripts/classify-ci-changes.sh" >"$classifier_script" classification="$(bash "$classifier_script" "$BASE_SHA" "$GITHUB_SHA")" diff --git a/bin/selfishell b/bin/selfishell index fea8a8e4..7ef2e7ca 100755 --- a/bin/selfishell +++ b/bin/selfishell @@ -2,11 +2,7 @@ set -euo pipefail -# Every command pays for this resolution, so strip the directory with -# parameter expansion instead of an external `dirname`: that one process is -# roughly a third of `selfishell version`'s runtime. `${path%/*}` leaves a bare -# filename unchanged, and a path with no directory means the current one -- -# what `dirname` reports as ".". +# Avoid spawning dirname on every invocation; a bare filename needs a "." fallback. SELFISHELL_SOURCE="${BASH_SOURCE[0]}" while [[ -L "$SELFISHELL_SOURCE" ]]; do SELFISHELL_SOURCE_DIR="${SELFISHELL_SOURCE%/*}" diff --git a/config/macos/zshrc b/config/macos/zshrc index f1bd9d18..057397b9 100644 --- a/config/macos/zshrc +++ b/config/macos/zshrc @@ -1,7 +1,4 @@ -# -------------------------------------------------- -# Homebrew # Detect Homebrew even when a new Mac does not have it in PATH yet -# -------------------------------------------------- if ! command -v brew >/dev/null 2>&1; then if [[ -x /opt/homebrew/bin/brew ]]; then @@ -11,21 +8,11 @@ if ! command -v brew >/dev/null 2>&1; then fi fi - -# -------------------------------------------------- -# PATH # Register paths before plugins look for commands such as kubectl -# -------------------------------------------------- typeset -U path PATH path=("$HOME/.local/bin" "$HOME/.rd/bin" $path) - - -# -------------------------------------------------- -# Common settings -# -------------------------------------------------- - COMMON_ZSH="${XDG_CONFIG_HOME:-$HOME/.config}/selfishell/zsh/common.zsh" if [[ -r "$COMMON_ZSH" ]]; then diff --git a/config/shared/nvim/lua/config/autocmds.lua b/config/shared/nvim/lua/config/autocmds.lua index c29832ec..d59e2423 100644 --- a/config/shared/nvim/lua/config/autocmds.lua +++ b/config/shared/nvim/lua/config/autocmds.lua @@ -1,14 +1,10 @@ local group = vim.api.nvim_create_augroup("UserGeneralAutocmds", { clear = true }) --- Neovim 0.12 uses the built-in Tree-sitter highlighter. The current --- nvim-treesitter plugin no longer enables it through setup()/opts. +-- nvim-treesitter installs parsers; Neovim enables highlighting separately. vim.treesitter.language.register("terraform", "tf") --- nvim-treesitter 1.0+ also dropped ensure_installed/auto_install, so --- parsers install lazily on FileType. Neovim fires FileType more than once --- per buffer, and a second install() before the first finishes blocks on a --- nested vim.wait() -- observed to leave a highlighter never started -- so --- track in-flight installs here rather than relying on its own guard. +-- Repeated FileType events can nest install() waits and prevent highlighting. +-- Track each in-flight language until installation finishes. local pending_installs = {} -- Suppresses a repeat notification only, never the retry: pending_installs @@ -44,8 +40,6 @@ local function ensure_parser_installed(buf, lang) local buffers = pending_installs[lang] pending_installs[lang] = nil if not installed then - -- Report once per language per session rather than leaving - -- highlighting silently missing. if not notified_failures[lang] then notified_failures[lang] = true vim.notify( @@ -96,8 +90,7 @@ vim.api.nvim_create_autocmd("VimResized", { end, }) --- A motion yank shows nothing, and 'report' (default 2) silences short ones --- too, so flash the range to catch an off-by-one text object before paste. +-- Highlight yanks that Vim's normal change reporting does not show. vim.api.nvim_create_autocmd("TextYankPost", { group = group, callback = function() diff --git a/config/shared/nvim/lua/config/keymaps.lua b/config/shared/nvim/lua/config/keymaps.lua index 5f72f387..8d6120e2 100644 --- a/config/shared/nvim/lua/config/keymaps.lua +++ b/config/shared/nvim/lua/config/keymaps.lua @@ -1,7 +1,6 @@ local map = vim.keymap.set local M = {} --- Clear search highlighting map("n", "", "nohlsearch", { silent = true, desc = "Clear search highlight", @@ -37,7 +36,6 @@ end, { desc = "Delete buffer", }) --- Keep the selection active while adjusting indentation. map("x", "<", "", ">gv", { desc = "Indent right and reselect", }) --- Diagnostic navigation map("n", "[d", function() vim.diagnostic.jump({ count = -1, float = true }) end, { desc = "Previous diagnostic" }) diff --git a/config/shared/nvim/lua/config/options.lua b/config/shared/nvim/lua/config/options.lua index 14347694..f2c119e3 100644 --- a/config/shared/nvim/lua/config/options.lua +++ b/config/shared/nvim/lua/config/options.lua @@ -1,6 +1,5 @@ local opt = vim.opt --- Floating windows (diagnostics, hover, signature help, ...) vim.o.winborder = "rounded" -- UI @@ -8,8 +7,7 @@ opt.number = true opt.relativenumber = true opt.hlsearch = true opt.ruler = false --- lualine renders the mode in its first section; leaving showmode on --- prints "-- INSERT --" on the command line right below it as well. +-- lualine already displays the mode. opt.showmode = false opt.termguicolors = true opt.signcolumn = "yes" @@ -43,12 +41,9 @@ opt.fileencodings = { "utf-8", "euc-kr" } -- Completion menu behavior opt.completeopt = { "menu", "menuone", "noselect" } --- nvim-cmp reads this for its own menu and treats 0 as "no limit", which --- makes the menu as tall as the number of candidates -- easily most of the --- screen for an LSP source. +-- Also caps nvim-cmp's menu; 0 leaves it unlimited. opt.pumheight = 10 --- Diagnostic display vim.diagnostic.config({ virtual_text = { prefix = "●", diff --git a/config/shared/nvim/lua/config/plugin_versions.lua b/config/shared/nvim/lua/config/plugin_versions.lua index e7c51883..a9b1c733 100644 --- a/config/shared/nvim/lua/config/plugin_versions.lua +++ b/config/shared/nvim/lua/config/plugin_versions.lua @@ -2,9 +2,7 @@ local M = {} local revisions = {} local manifest = vim.fn.stdpath("config") .. "/plugin-versions.conf" --- io.lines throws on a missing manifest (a partial install, or Neovim run --- straight against this config), crashing `require` with a raw traceback. --- Degrade to empty: callers already report each missing revision clearly. +-- A missing manifest must not crash require; callers report missing revisions. local file = io.open(manifest, "r") if file then for line in file:lines() do diff --git a/config/shared/nvim/lua/plugins/completion.lua b/config/shared/nvim/lua/plugins/completion.lua index 97303b66..5f478754 100644 --- a/config/shared/nvim/lua/plugins/completion.lua +++ b/config/shared/nvim/lua/plugins/completion.lua @@ -12,8 +12,6 @@ return { local cmp = require("cmp") cmp.setup({ - -- Neovim 0.12's native snippet engine replaces LuaSnip; it expands - -- the same LSP snippet syntax cmp already hands it. snippet = { expand = function(args) vim.snippet.expand(args.body) @@ -26,8 +24,7 @@ return { [""] = cmp.mapping.scroll_docs(4), [""] = cmp.mapping.complete(), - -- Preserves the existing behavior: Enter accepts the first item - -- even when it has not been explicitly selected. + -- Enter accepts the first item even without an explicit selection. [""] = cmp.mapping.confirm({ select = true }), [""] = cmp.mapping(function(fallback) diff --git a/config/shared/nvim/lua/plugins/editor.lua b/config/shared/nvim/lua/plugins/editor.lua index 7813f63a..1eb4720c 100644 --- a/config/shared/nvim/lua/plugins/editor.lua +++ b/config/shared/nvim/lua/plugins/editor.lua @@ -1,7 +1,6 @@ local plugin = require("config.plugin_versions").spec return { - -- Automatic bracket/quote pairs. plugin("windwp/nvim-autopairs", { event = "InsertEnter", opts = {}, @@ -18,7 +17,6 @@ return { end, }), - -- VS Code-style colored delimiters. plugin("HiPhish/rainbow-delimiters.nvim", { -- Load before the initial buffer's FileType event so the plugin can attach. event = { "BufReadPre", "BufNewFile" }, @@ -34,14 +32,11 @@ return { end, }), - -- Keymap guide: helpful for Space leader mappings. plugin("folke/which-key.nvim", { event = "VeryLazy", opts = { icons = { - -- Disable per-mapping filetype/devicons icon lookups; which-key - -- deep-merges `keys` with its Nerd Font defaults, so every key - -- must be listed here explicitly or it keeps its default glyph. + -- which-key deep-merges keys, so every Nerd Font default needs an override. mappings = false, keys = { Up = "Up ", diff --git a/config/shared/nvim/lua/plugins/lsp.lua b/config/shared/nvim/lua/plugins/lsp.lua index 1387820d..e7e69994 100644 --- a/config/shared/nvim/lua/plugins/lsp.lua +++ b/config/shared/nvim/lua/plugins/lsp.lua @@ -29,7 +29,6 @@ return { plugin("hrsh7th/cmp-nvim-lsp"), }, config = function() - -- Apply completion capabilities to every LSP config. vim.lsp.config("*", { capabilities = require("cmp_nvim_lsp").default_capabilities(), }) diff --git a/config/shared/nvim/lua/plugins/ui.lua b/config/shared/nvim/lua/plugins/ui.lua index 1c32c8c2..e7cadc53 100644 --- a/config/shared/nvim/lua/plugins/ui.lua +++ b/config/shared/nvim/lua/plugins/ui.lua @@ -49,7 +49,6 @@ local function lualine_mode_color() end return { - -- Theme: must be available during startup. plugin("mofiqul/vscode.nvim", { lazy = false, priority = 1000, @@ -58,7 +57,6 @@ return { end, }), - -- File explorer: loaded only when its command or keymap is used. plugin("nvim-tree/nvim-tree.lua", { main = "nvim-tree", cmd = { @@ -98,8 +96,6 @@ return { git_ignored = false, }, renderer = { - -- Keep folders distinct without spending the narrow tree's width on - -- icons, and show native branch guides for nested directories. add_trailing = true, group_empty = true, highlight_git = "name", @@ -138,8 +134,6 @@ return { }, }), - -- Buffer tabs: keep the critical startup path clear and hide the bar when a - -- single buffer is open. plugin("akinsho/bufferline.nvim", { event = "VeryLazy", keys = { @@ -157,8 +151,7 @@ return { opts = { options = { always_show_bufferline = false, - -- Disabling this avoids ever touching nvim-web-devicons: it's - -- checked before the (also pcall-guarded) require. + -- Disable icon lookups to avoid loading nvim-web-devicons. show_buffer_icons = false, close_command = function(bufnr) Snacks.bufdelete(bufnr) @@ -279,7 +272,6 @@ return { }, }), - -- Statusline: not required for the critical startup path. plugin("nvim-lualine/lualine.nvim", { event = "VeryLazy", opts = { @@ -346,11 +338,7 @@ return { }, }), - -- Scope comes from the node's ancestry rather than a per-language - -- node-type whitelist, falling back to indentation when Tree-sitter finds - -- nothing. Loaded eagerly because Snacks defers indent to BufReadPost, - -- which never fires for BufNewFile or the initial unnamed buffer; the - -- direct indent.enable() below covers those and is idempotent. + -- Load eagerly and enable directly: Snacks' BufReadPost hook misses new and unnamed buffers. plugin("folke/snacks.nvim", { lazy = false, priority = 1000, @@ -464,9 +452,7 @@ return { }, }, opts = { - -- ripgrep is guaranteed by the developer profile; fd is not, so the - -- picker shouldn't vary by what's personally installed. No source shows - -- a file icon: without nvim-web-devicons there's no icon set to use. + -- The developer profile guarantees ripgrep, but not fd. picker = { ui_select = false, sources = { @@ -480,13 +466,10 @@ return { buffers = { icons = { files = { enabled = false } }, }, - -- Telescope's diagnostics picker showed the whole workspace, not - -- just the cwd; Snacks defaults to cwd-only. + -- Include diagnostics across the whole workspace. diagnostics = { filter = { cwd = false }, }, - -- Both reach the same filename formatter as the sources above. The - -- git log pickers don't -- their commit glyph is Snacks' own. git_status = { icons = { files = { enabled = false } }, }, @@ -528,7 +511,6 @@ return { end, }), - -- Git changes, hunk actions, and blame information. plugin("lewis6991/gitsigns.nvim", { event = { "BufReadPre", "BufNewFile" }, opts = { @@ -567,7 +549,6 @@ return { end end, "Previous Git change") - -- Hunk actions. map("n", "hp", gitsigns.preview_hunk, "Preview Git hunk") map("n", "hi", gitsigns.preview_hunk_inline, "Preview Git hunk inline") map("n", "hs", gitsigns.stage_hunk, "Stage Git hunk") @@ -587,7 +568,6 @@ return { }) end, "Reset selected Git lines") - -- Blame and diff. map("n", "hb", function() gitsigns.blame_line({ full = true }) end, "Show Git blame") @@ -598,26 +578,21 @@ return { gitsigns.diffthis("~") end, "Diff against previous commit") - -- Optional visual features. map("n", "ub", gitsigns.toggle_current_line_blame, "Toggle Git blame") map("n", "uw", gitsigns.toggle_word_diff, "Toggle Git word diff") - -- Git hunk text object. map({ "o", "x" }, "ih", gitsigns.select_hunk, "Select Git hunk") end, }, }), - -- Scrollbar with the current viewport and diagnostics. plugin("petertriho/nvim-scrollbar", { main = "scrollbar", event = { "BufReadPost", "BufNewFile" }, opts = { show_in_active_only = true, hide_if_all_visible = true, - -- The default handle color (linked to CursorColumn) is nearly - -- indistinguishable from vscode.nvim's background. Use VS Code's own - -- scrollbar slider color/opacity instead of a fully opaque gray. + -- The default CursorColumn color is hard to see against this theme. handle = { blend = 60, color = "#797979", @@ -631,9 +606,7 @@ return { "mason", "help", }, - -- gitsigns is off: the sign column already shows these hunks, and - -- mirroring them doubled the redraw triggers. cursor is off to drop the - -- CursorMoved-driven redraw on every cursor move. + -- Avoid duplicate hunk redraws and a redraw on every cursor move. handlers = { cursor = false, diagnostic = true, @@ -644,7 +617,6 @@ return { }, }), - -- Inline document preview. plugin("OXY2DEV/markview.nvim", { ft = { "markdown", "quarto", "rmd", "typst", "asciidoc" }, keys = { @@ -656,7 +628,6 @@ return { }, opts = { preview = { - -- Avoid nvim-web-devicons, as elsewhere in this file. icon_provider = "internal", }, }, diff --git a/config/shared/starship.toml b/config/shared/starship.toml index 5709d2d3..23f74a19 100644 --- a/config/shared/starship.toml +++ b/config/shared/starship.toml @@ -1,68 +1,37 @@ -# ~/.config/starship.toml - -# Add a blank line between commands add_newline = true -# Left prompt format = '$directory$git_branch$git_commit$git_state$git_status$character' -# Right prompt right_format = '$status$cmd_duration$jobs$username$hostname$python$nodejs$java$aws$kubernetes' -# ───────────────────────────── -# Current directory -# ───────────────────────────── [directory] format = '[$path]($style) ' style = 'bold blue' -# Display paths like ~/code/projects/... with limited truncation truncation_length = 2 truncation_symbol = '.../' truncate_to_repo = false - -# ───────────────────────────── -# Git branch -# ───────────────────────────── [git_branch] format = '[$branch]($style) ' style = 'green' - -# ───────────────────────────── -# Detached HEAD commit -# ───────────────────────────── [git_commit] format = '[$hash]($style) ' style = 'green' only_detached = true - -# ───────────────────────────── -# Git operation state -# Display only during rebase, merge, cherry-pick, etc. -# ───────────────────────────── [git_state] style = 'bold yellow' - -# ───────────────────────────── -# Git status -# ───────────────────────────── [git_status] format = '(($all_status )$ahead_behind)' -# Colour groups states -- yellow an unstaged edit, green the index, red losing a -# file, blue untracked, grey a stash set aside. Seven hues collide set inline. - -# Display counts such as !1 modified = '[!${count}](yellow)' staged = '[+${count}](green)' untracked = '[?${count}](bright-blue)' deleted = '[-](red)' renamed = '[»](green)' -# Counted where the number moves; deleted and renamed would always read 1. conflicted = '[~${count}](bold bright-red)' stashed = '[*${count}](bright-black)' @@ -70,46 +39,24 @@ ahead = '[⇡${count}](green) ' behind = '[⇣${count}](red) ' diverged = '[⇡${ahead_count}](bold green)[⇣${behind_count}](bold red) ' - -# ───────────────────────────── -# Command prompt character -# ───────────────────────────── [character] success_symbol = '[❯](bold green)' error_symbol = '[❯](bold red)' vimcmd_symbol = '[❮](bold green)' - -# ───────────────────────────── -# Exit code of the previous command -# Hidden when the command succeeds -# ───────────────────────────── [status] disabled = false format = '[✘ $status]($style) ' style = 'bold red' - -# ───────────────────────────── -# Duration of long-running commands -# ───────────────────────────── [cmd_duration] min_time = 2000 format = '[$duration]($style) ' style = 'bright-black' - -# ───────────────────────────── -# Background jobs -# Hidden when there are no jobs in the current shell -# ───────────────────────────── [jobs] style = 'cyan' - -# ───────────────────────────── -# Remote session -# ───────────────────────────── [username] format = '[$user]($style)' style_user = 'bright-black' @@ -121,11 +68,6 @@ ssh_only = true format = '[@](bright-black)[$hostname]($style) ' style = 'bold yellow' - -# ───────────────────────────── -# Cloud environment -# Display the AWS profile and Kubernetes context -# ───────────────────────────── [aws] format = '([aws:$profile]($style) )' style = 'bold yellow' @@ -150,11 +92,6 @@ detect_folders = ['k8s', 'kubernetes', 'helm', 'charts'] context_pattern = 'arn:aws:eks:[^:]+:[^:]+:cluster/(?P[^/]+)' context_alias = '$cluster' - -# ───────────────────────────── -# Project runtime versions -# Display only when related files exist in the current directory -# ───────────────────────────── [python] format = '[($virtualenv )]($style)' style = 'yellow' diff --git a/config/shared/zsh/completion.zsh b/config/shared/zsh/completion.zsh index 1353b3c4..fb9ec04e 100644 --- a/config/shared/zsh/completion.zsh +++ b/config/shared/zsh/completion.zsh @@ -21,30 +21,39 @@ zstyle ':completion:*' matcher-list \ autoload -Uz compinit compaudit ZCOMPDUMP="${ZDOTDIR:-$HOME}/.zcompdump" -# (#q) needs EXTENDED_GLOB, which is off by default; without it the test never -# globs, every dump reads as stale, and compaudit re-runs each startup (~10ms). -_selfishell_zcompdump_is_stale() { +# The dump can be created by a noninteractive shell or reused without being +# rewritten, so its mtime cannot tell us when a security audit last ran. +_selfishell_completion_needs_audit() { setopt localoptions extendedglob - [[ -n "$1"(#qN.mh+24) ]] + [[ ! -s "$1" || ! -f "$1.audit" || -L "$1.audit" || -s "$1.audit" || + -n "$1.audit"(#qN.mh+24) ]] } -if [[ ! -o interactive ]]; then - # -C skips compaudit entirely regardless of -u/-i, so there is no security - # check to perform (or bypass) on this path. - compinit -C -d "$ZCOMPDUMP" -elif _selfishell_zcompdump_is_stale "$ZCOMPDUMP"; then - # Scan ourselves to warn and continue: compinit's default blocks startup on - # a `read -q` prompt, and -u would skip the scan altogether. - if [[ -n "$(compaudit 2>/dev/null)" ]]; then - print -u2 "selfishell: insecure completion directories detected; run 'compaudit' for details." - compinit -i -d "$ZCOMPDUMP" - else - compinit -d "$ZCOMPDUMP" +if [[ -o interactive ]] && _selfishell_completion_needs_audit "$ZCOMPDUMP"; then + # -i excludes insecure entries; -D rebuilds even when an unaudited dump has + # the same file count. Recompile immediately to replace same-age bytecode. + unset _comp_secure + if compinit -i -d "$ZCOMPDUMP" -D; then + if [[ "${_comp_secure:-}" == yes ]]; then + print -u2 "selfishell: insecure completion directories detected; run 'compaudit' for details." + fi + # Only an empty regular marker is ours. Preserve user replacements and + # keep auditing instead of following a link or changing an occupied path. + if [[ ! -L "$ZCOMPDUMP.audit" && ( ! -e "$ZCOMPDUMP.audit" || + ( -f "$ZCOMPDUMP.audit" && ! -s "$ZCOMPDUMP.audit" ) ) ]]; then + command rm -f "$ZCOMPDUMP.audit" 2>/dev/null + fi + # Invalidate the old marker before rebuilding; a failed write must not + # let the next shell skip the audit and restore insecure fpath entries. + if compdump && zcompile "$ZCOMPDUMP" && [[ "${_comp_secure:-}" != yes && + ! -e "$ZCOMPDUMP.audit" && ! -L "$ZCOMPDUMP.audit" ]]; then + command touch "$ZCOMPDUMP.audit" 2>/dev/null + fi fi else compinit -C -d "$ZCOMPDUMP" fi -unfunction _selfishell_zcompdump_is_stale +unfunction _selfishell_completion_needs_audit if [[ -s "$ZCOMPDUMP" && ( ! -s "$ZCOMPDUMP.zwc" || "$ZCOMPDUMP" -nt "$ZCOMPDUMP.zwc" ) ]]; then zcompile "$ZCOMPDUMP" diff --git a/config/shared/zsh/history.zsh b/config/shared/zsh/history.zsh index 11c4aed4..7e247f5e 100644 --- a/config/shared/zsh/history.zsh +++ b/config/shared/zsh/history.zsh @@ -1,6 +1,4 @@ -# Persistent Zsh command history. -# Keep history available across shell sessions and store timestamps/durations -# so execution time can be inspected on demand without occupying the prompt. +# Persist history with timestamps and durations across shell sessions. HISTFILE="${ZDOTDIR:-$HOME}/.zsh_history" HISTSIZE=10000 SAVEHIST=10000 @@ -9,7 +7,5 @@ setopt EXTENDED_HISTORY setopt INC_APPEND_HISTORY_TIME setopt HIST_IGNORE_SPACE -# fzf's Ctrl-R drops duplicates by exact string match, so a stray double space -# makes a second entry. Normalizing feeds that dedup rather than adding another -# layer, and unlike the dup-pruning options it keeps timestamp and duration. +# Normalize whitespace for fzf Ctrl-R deduplication while retaining timestamps and durations. setopt HIST_REDUCE_BLANKS diff --git a/config/shared/zsh/interactive.zsh b/config/shared/zsh/interactive.zsh index ced75351..3b6ce510 100644 --- a/config/shared/zsh/interactive.zsh +++ b/config/shared/zsh/interactive.zsh @@ -1,14 +1,24 @@ -# Aliases source "$SELFISHELL_COMMON_DIR/aliases.zsh" # Shell tools configure key bindings before interactive plugins load. SELFISHELL_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/selfishell" -# Writes "$@"'s stdout to $target through a temp file, validated non-empty and -# zsh-syntax-clean before the atomic rename: the caller's [[ -s ]] can't tell -# "empty" from "truncated", so a kill mid-generation would otherwise leave a -# partial cache sourced forever. fzf's copied-file fallback doesn't fit this -# shape and stays separate below. +# Compare executable identity, not cache age: rollbacks can restore an older +# mtime. Keep the key in the cache itself so it activates with the validated code. +# The generators below consume the key set by this check, without another stat. +_selfishell_zsh_cache_current() { + local target="$1" binary="${2:A}" header + local -A info + _selfishell_zsh_cache_key="" + zmodload -F zsh/stat b:zstat 2>/dev/null || return 1 + zstat -H info -- "$binary" 2>/dev/null || return 1 + _selfishell_zsh_cache_key="# selfishell-tool ${(q)binary} $info[device] $info[inode] $info[size] $info[mtime] $info[ctime]" + [[ -s "$target" ]] && IFS= read -r header <"$target" && + [[ "$header" == "$_selfishell_zsh_cache_key" ]] +} + +# Validate before atomic activation so interrupted generation cannot leave a +# partial cache that subsequent startups would source. _selfishell_generate_zsh_cache() { local target="$1" shift @@ -28,6 +38,11 @@ _selfishell_generate_zsh_cache() { command rm -f "$temporary" return 1 } + local init="$(<"$temporary")" + print -r -- "${_selfishell_zsh_cache_key:-}"$'\n'"$init" >|"$temporary" || { + command rm -f "$temporary" + return 1 + } command mv -f "$temporary" "$target" || { command rm -f "$temporary" return 1 @@ -63,6 +78,11 @@ _selfishell_generate_fzf_cache() { command rm -f "$temporary" return 1 } + local init="$(<"$temporary")" + print -r -- "${_selfishell_zsh_cache_key:-}"$'\n'"$init" >|"$temporary" || { + command rm -f "$temporary" + return 1 + } command mv -f "$temporary" "$target" || { command rm -f "$temporary" return 1 @@ -71,7 +91,7 @@ _selfishell_generate_fzf_cache() { if _selfishell_zoxide_bin="$(command -v zoxide)"; then _selfishell_zoxide_cache="$SELFISHELL_CACHE_DIR/zoxide-init.zsh" - if [[ ! -s "$_selfishell_zoxide_cache" || "$_selfishell_zoxide_bin" -nt "$_selfishell_zoxide_cache" ]]; then + if ! _selfishell_zsh_cache_current "$_selfishell_zoxide_cache" "$_selfishell_zoxide_bin"; then _selfishell_generate_zsh_cache "$_selfishell_zoxide_cache" zoxide init zsh fi [[ -s "$_selfishell_zoxide_cache" ]] && source "$_selfishell_zoxide_cache" @@ -80,15 +100,11 @@ fi unset _selfishell_zoxide_bin if _selfishell_fzf_bin="$(command -v fzf)"; then - # Scheme 16 keeps fzf to the terminal's own colors, as the prompt does by - # naming colors. Spelled 16, not base16: that alias postdates the fzf Ubuntu - # 24.04 ships (0.44.1), which rejects an unknown scheme outright and would - # take every invocation down with it. The environment wins, so this is a - # default, not a policy, and it reaches only Ctrl-T and Ctrl-R. + # Ubuntu 24.04's fzf supports "16", but not the newer "base16" alias. export FZF_DEFAULT_OPTS="${FZF_DEFAULT_OPTS:---color=16}" _selfishell_fzf_cache="$SELFISHELL_CACHE_DIR/fzf-init.zsh" - if [[ ! -s "$_selfishell_fzf_cache" || "$_selfishell_fzf_bin" -nt "$_selfishell_fzf_cache" ]]; then + if ! _selfishell_zsh_cache_current "$_selfishell_fzf_cache" "$_selfishell_fzf_bin"; then _selfishell_generate_fzf_cache "$_selfishell_fzf_cache" fi [[ -s "$_selfishell_fzf_cache" ]] && source "$_selfishell_fzf_cache" @@ -104,16 +120,13 @@ if (($+functions[zinit])); then zinit ice ver'24105b15714bfec37989ed5c5b6e60f572253019' zinit light Aloxaf/fzf-tab - # These styles are read only when a completion runs, so startup pays - # nothing, and each preview runs in an fzf worker. Rules stay per-command - # on purpose: a catch-all would fire for option flags too. + # Keep previews per-command so they do not run for option flags. # fzf-tab blanks FZF_DEFAULT_OPTS, so hand it the palette directly. # use-fzf-default-opts would forward the rest of the user's variable, and # --with-nth defeats the NUL encoding it uses for candidates. zstyle ':fzf-tab:*' fzf-flags --color=16 - # Group headers ([files], [directories], ...) above each candidate block. zstyle ':completion:*:descriptions' format '[%d]' # $realpath is the full path; $word is only the part after the common @@ -148,10 +161,7 @@ if (($+functions[zinit])); then git log --oneline --decorate --color=always -10 "${ref:-$word}" 2>/dev/null ' - # The pending change matters here, not the file's contents, and plain - # `git diff` is what all three commands act on by default. Options that - # move the target (`restore --staged`) would need command-line parsing and - # are not read; those candidates preview empty, as untracked files do. + # Preview unstaged changes. Command-line options such as --staged are not parsed. zstyle ':fzf-tab:complete:git-(add|restore|diff):*' fzf-preview \ 'git diff --color=always -- "${realpath:-$word}" 2>/dev/null | head -n 200' @@ -161,9 +171,7 @@ if (($+functions[zinit])); then zstyle ':fzf-tab:complete:git-stash-(show|pop|apply|drop|branch):*' fzf-preview \ 'git stash show -p --color=always "$word" 2>/dev/null | head -n 200' - # An explicit -o format is the subset BSD (macOS) and procps (Ubuntu) - # agree on. $USERNAME because zsh always defines it, unlike $USER: `ps -u ''` - # swallows the next argument and complains instead of listing. + # Use a ps format shared by BSD and procps; zsh always defines USERNAME. zstyle ':completion:*:*:*:*:processes' command "ps -u $USERNAME -o pid,user,comm" zstyle ':fzf-tab:complete:(kill|ps):argument-rest' fzf-preview \ 'ps -p "$word" -o pid,user,%cpu,%mem,command 2>/dev/null' @@ -184,7 +192,7 @@ fi if _selfishell_starship_bin="$(command -v starship)"; then _selfishell_starship_cache="$SELFISHELL_CACHE_DIR/starship-init.zsh" - if [[ ! -s "$_selfishell_starship_cache" || "$_selfishell_starship_bin" -nt "$_selfishell_starship_cache" ]]; then + if ! _selfishell_zsh_cache_current "$_selfishell_starship_cache" "$_selfishell_starship_bin"; then _selfishell_generate_zsh_cache "$_selfishell_starship_cache" starship init zsh fi [[ -s "$_selfishell_starship_cache" ]] && source "$_selfishell_starship_cache" @@ -192,4 +200,4 @@ if _selfishell_starship_bin="$(command -v starship)"; then fi unset _selfishell_starship_bin -unset SELFISHELL_CACHE_DIR +unset SELFISHELL_CACHE_DIR _selfishell_zsh_cache_key diff --git a/config/shared/zsh/update-notice.zsh b/config/shared/zsh/update-notice.zsh index 6b1c6dd9..6aed2600 100644 --- a/config/shared/zsh/update-notice.zsh +++ b/config/shared/zsh/update-notice.zsh @@ -78,9 +78,7 @@ _selfishell_update_notice_refresh() { command rm -f "$temporary" fi } always { - # Runs even when the block above returns early, so an ordinary failure - # can't leak the lock. rm -rf, not rmdir: the directory holds metadata - # files, and a failing rmdir would wedge the next check behind it. + # Release the lock, including its metadata, even on an early return. command rm -rf "$lock_dir" 2>/dev/null } } diff --git a/config/ubuntu/zshrc b/config/ubuntu/zshrc index 2596ea9a..db80df34 100644 --- a/config/ubuntu/zshrc +++ b/config/ubuntu/zshrc @@ -1,7 +1,4 @@ -# -------------------------------------------------- -# PATH # Prefer user-local binaries on Ubuntu/WSL -# -------------------------------------------------- typeset -U path PATH path=("$HOME/.local/bin" "$HOME/.rd/bin" $path) @@ -13,11 +10,6 @@ if [[ -n "${WSL_DISTRO_NAME:-}" ]]; then path=("${(@)path:#/mnt/[a-zA-Z]/*}") fi - -# -------------------------------------------------- -# Common settings -# -------------------------------------------------- - COMMON_ZSH="${XDG_CONFIG_HOME:-$HOME/.config}/selfishell/zsh/common.zsh" if [[ -r "$COMMON_ZSH" ]]; then diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 5af3c6e7..05f3eb7a 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -56,7 +56,29 @@ the runner's `PATH`. It: regular (network-free) unit test suite, or run in CI -- run it locally when needed. -`common-first` is the once-per-day completion cache generation cost. +Interactive startup audits completion directories on first use and once daily. +The `.zcompdump.audit` marker records the audit separately from `.zcompdump`, +which can be reused without rewriting it. Insecure entries are excluded without +a prompt. After a clean audit, warm startups reuse the dump without another +audit. Insecure paths are audited on each startup until repaired, so restoring +the completion search path cannot reintroduce an excluded directory. A replaced +audit marker (a symlink, nonempty file, or another path type) is preserved and +also causes startup to audit again. + +fzf, zoxide, and Starship initialization caches survive unchanged installs and +configuration reapplication. A changed `zsh/interactive.zsh` generator invalidates +these caches before installation; unrelated managed configuration changes leave +them intact. Startup compares the resolved executable path and file identity +using Zsh's built-in stat module, so replacing a tool or rolling back to an older +binary regenerates its initialization even when its timestamp is preserved. +The identity comment and syntax-checked initialization are activated together by +an atomic rename. Failed generation retains the previous cache and retries on +the next startup. Older caches without an identity comment regenerate once. +Identity checks use whole-second timestamps rather than hashing the executable +on every startup. A same-size edit within the same second that preserves both +the inode and mtime can go undetected; remove that tool's init cache to regenerate it. + +`common-first` measures initial completion cache generation. `common-cached` and `interactive-cached` represent ordinary warm startup. The first-run metric is informational and does not have a performance budget. diff --git a/docs/PROFILES.md b/docs/PROFILES.md index 53321d6c..3c3432c7 100644 --- a/docs/PROFILES.md +++ b/docs/PROFILES.md @@ -39,6 +39,14 @@ uses that recorded profile to install missing Apt, Homebrew, and directly managed tools before updating configuration. Apt and Homebrew retain responsibility for versions of packages they already manage. +Changing from `developer` to `minimal` changes future tool synchronization; +it does not remove previously installed tools or configuration. Existing +Neovim and mise configuration links remain active, and `selfishell status` +continues to check all tracked paths, including retained developer resources. +To remove managed configuration before setting up a smaller profile, run +`selfishell uninstall --restore`, then `selfishell install --profile minimal`. +Uninstall leaves installed packages and user-created files in place. + Profile package requirements have two failure policies: - `required` packages must be available and install successfully; diff --git a/install.sh b/install.sh index b81054fa..38827dff 100755 --- a/install.sh +++ b/install.sh @@ -6,9 +6,7 @@ SELFISHELL_RELEASE_ROOT="${SELFISHELL_RELEASE_ROOT:-https://github.com/jiminu/se SELFISHELL_TEMP_DIR="" SELFISHELL_STAGING_DIR="" -# This runs before any Selfishell code is on disk, so it can't share -# lib/common.sh's colors. Gated per stream the same way, so non-terminal -# output stays plain text. +# Bootstrap cannot source lib/common.sh yet; gate colors separately for each stream. if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then SELFISHELL_COLOR_GREEN=$'\033[32m' SELFISHELL_COLOR_YELLOW=$'\033[33m' @@ -367,9 +365,7 @@ main() { bootstrap_curl transfer "$release_url/$archive_name" -o "$archive_file" bootstrap_curl transfer "$release_url/SHA256SUMS" -o "$checksum_file" - # A duplicate SHA256SUMS line, even an identical one, would make - # $expected_checksum multi-line and never match. `sort -u` collapses - # agreeing duplicates and still fails below on conflicting ones. + # Collapse agreeing checksum entries; conflicting duplicates still fail verification. expected_checksum="$(awk -v archive="$archive_name" '$2 == archive { print $1 }' "$checksum_file" | sort -u)" if [[ -z "$expected_checksum" || "$expected_checksum" == *[!0-9a-fA-F]* ]]; then bootstrap_error "No valid checksum found for $archive_name" diff --git a/lib/commands/install.sh b/lib/commands/install.sh index a62bb8fd..74c210b8 100644 --- a/lib/commands/install.sh +++ b/lib/commands/install.sh @@ -44,6 +44,11 @@ install_managed_configuration() { if [[ "$resource_name" == ghostty-config ]]; then [[ "$platform" == "macos" && "$ghostty_enabled" == "1" ]] || continue fi + # Invalidate before replacing the generator so an interrupted install + # cannot leave new configuration paired with old initialization code. + if [[ "$dry_run" == "0" && "$resource_name" == zsh-interactive ]] && ! cmp -s "$resource_source" "$resource_target"; then + rm -f "$SELFISHELL_CACHE_DIR"/zoxide-init.zsh "$SELFISHELL_CACHE_DIR"/fzf-init.zsh "$SELFISHELL_CACHE_DIR"/starship-init.zsh 2>/dev/null + fi managed_install_file "$resource_name" "$resource_source" "$resource_target" "$dry_run" "$assume_yes" ;; link) @@ -68,7 +73,6 @@ install_managed_configuration() { done < <(selfishell_managed_resources) if [[ "$dry_run" == "0" ]]; then - rm -f "$SELFISHELL_CACHE_DIR"/zoxide-init.zsh "$SELFISHELL_CACHE_DIR"/fzf-init.zsh "$SELFISHELL_CACHE_DIR"/starship-init.zsh 2>/dev/null selfishell_mise_trust fi } diff --git a/lib/commands/status.sh b/lib/commands/status.sh index f462ad4d..7054ff77 100644 --- a/lib/commands/status.sh +++ b/lib/commands/status.sh @@ -2,7 +2,7 @@ status_resource() { local resource="$1" - local current_checksum + local current_checksum="" if ! managed_read_state "$resource"; then if managed_state_exists "$resource"; then @@ -45,7 +45,7 @@ status_resource() { fi ;; file) - if [[ -f "$MANAGED_STATE_TARGET" ]]; then + if managed_path_is_regular_file "$MANAGED_STATE_TARGET"; then current_checksum="$(managed_checksum "$MANAGED_STATE_TARGET")" fi if [[ -n "$current_checksum" && "$current_checksum" == "$MANAGED_STATE_CHECKSUM" ]]; then @@ -158,13 +158,15 @@ command_status() { selfishell_scan_profile_packages "$profile" "$dependency_platform" "$architecture" status_report_package "$profile_platform" fi + if [[ "$profile" == minimal ]] && + { managed_state_exists user-nvim || managed_state_exists mise-config-link; }; then + printf '%s[INFO]%s Previously installed developer configuration is retained and checked below.\n' \ + "$SELFISHELL_COLOR_CYAN" "$SELFISHELL_COLOR_RESET" + fi + + # The selected profile controls future installation, not ownership of paths + # retained from an earlier profile or platform. while IFS= read -r resource; do - if [[ "$profile" != "developer" && ("$resource" == nvim-* || "$resource" == user-nvim) ]]; then - continue - fi - if [[ "$platform" != "macos" && "$resource" == user-ghostty ]]; then - continue - fi status_resource "$resource" done < <(selfishell_managed_resource_names) diff --git a/lib/commands/uninstall.sh b/lib/commands/uninstall.sh index a77af708..32656b23 100644 --- a/lib/commands/uninstall.sh +++ b/lib/commands/uninstall.sh @@ -139,7 +139,6 @@ command_uninstall() { if [[ "$dry_run" == "0" ]]; then rm -f "$SELFISHELL_STATE_DIR/profile" "$SELFISHELL_STATE_DIR/ghostty" rmdir "$SELFISHELL_CONFIG_DIR/ghostty" 2>/dev/null || true - # Remove nvim subdirectories depth-first then the top-level nvim dir. rmdir "$SELFISHELL_CONFIG_DIR/nvim/after/lsp" 2>/dev/null || true rmdir "$SELFISHELL_CONFIG_DIR/nvim/after" 2>/dev/null || true rmdir "$SELFISHELL_CONFIG_DIR/nvim/lua/config" 2>/dev/null || true diff --git a/lib/common.sh b/lib/common.sh index 68f17ba7..ef6fb251 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -58,8 +58,6 @@ cli_error() { printf '%sselfishell:%s %s\n' "$SELFISHELL_COLOR_RED_STDERR" "$SELFISHELL_COLOR_RESET_STDERR" "$*" >&2 } -# Reports a non-fatal condition to stderr. Unlike cli_error, the caller -# is free to continue execution or return success afterward. cli_warn() { printf '%sselfishell: warning:%s %s\n' "$SELFISHELL_COLOR_YELLOW_STDERR" "$SELFISHELL_COLOR_RESET_STDERR" "$*" >&2 } @@ -164,7 +162,6 @@ selfishell_answer_is_yes() { esac } -# Matches a negative prompt answer (n/N/no/NO). See selfishell_answer_is_yes. selfishell_answer_is_no() { case "$1" in n | N | no | NO) return 0 ;; diff --git a/lib/managed.sh b/lib/managed.sh index 14e26308..a3b059d6 100644 --- a/lib/managed.sh +++ b/lib/managed.sh @@ -3,6 +3,11 @@ MANAGED_BLOCK_OVERWRITE_RESOURCES="" MANAGED_BLOCK_SKIP_RESOURCES="" +# A link to an unchanged regular file is still a user-replaced path. +managed_path_is_regular_file() { + [[ -f "$1" && ! -L "$1" ]] +} + managed_checksum() { cksum <"$1" | awk '{print $1 ":" $2}' } @@ -242,11 +247,8 @@ managed_block_content() { "$(managed_block_end "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")" } -# Sets MANAGED_BLOCK_STATUS from marker structure alone and -# MANAGED_BLOCK_CHECKSUM from the live bytes. "intact" means well-formed -# markers and deliberately does not compare against current content, so a body -# that changed across a release is never read as user tampering. Callers -# compare MANAGED_BLOCK_CHECKSUM against their own reference themselves. +# "intact" describes marker structure only. Callers compare MANAGED_BLOCK_CHECKSUM +# with the recorded checksum, not with the current release's block content. managed_inspect_block() { local resource="$1" local target_file="$2" @@ -348,10 +350,8 @@ managed_preflight_zsh_loader() { managed_preflight_block_target user-zshrc "$target_file" "$assume_yes" "$dry_run" } -# Rewrites target_file with the block region (MANAGED_BLOCK_START/LENGTH, set -# by the caller's prior managed_inspect_block) replaced by content_resource's -# content, or removed when it is omitted. Shared by managed_replace_block and -# managed_remove_block. +# Requires a prior managed_inspect_block. Replace its block region with +# content_resource, or remove it when that argument is omitted. managed_splice_block() { local target_file="$1" local content_resource="${2:-}" @@ -367,7 +367,7 @@ managed_splice_block() { return "$SELFISHELL_EXIT_ERROR" } if ((MANAGED_BLOCK_START > 0)); then - dd if="$target_file" bs=1 count="$MANAGED_BLOCK_START" 2>/dev/null >"$temporary_file" || { + head -c "$MANAGED_BLOCK_START" "$target_file" >"$temporary_file" || { rm -f "$temporary_file" return "$SELFISHELL_EXIT_ERROR" } @@ -381,7 +381,7 @@ managed_splice_block() { file_size="$(LC_ALL=C wc -c <"$target_file")" suffix_start=$((MANAGED_BLOCK_START + MANAGED_BLOCK_LENGTH)) if ((suffix_start < file_size)); then - dd if="$target_file" bs=1 skip="$suffix_start" 2>/dev/null >>"$temporary_file" || { + tail -c "+$((suffix_start + 1))" "$target_file" >>"$temporary_file" || { rm -f "$temporary_file" return "$SELFISHELL_EXIT_ERROR" } @@ -560,7 +560,7 @@ managed_install_file() { fi original_backup="$MANAGED_STATE_BACKUP" - if [[ -f "$target_file" ]]; then + if managed_path_is_regular_file "$target_file"; then [[ "$MANAGED_STATE_STATUS" != "active" ]] || previously_active_file=1 current_checksum="$(managed_checksum "$target_file")" if [[ "$current_checksum" != "$MANAGED_STATE_CHECKSUM" && "$current_checksum" != "$source_checksum" ]]; then @@ -743,7 +743,7 @@ managed_uninstall_resource() { fi ;; file) - if [[ -f "$MANAGED_STATE_TARGET" ]]; then + if managed_path_is_regular_file "$MANAGED_STATE_TARGET"; then current_checksum="$(managed_checksum "$MANAGED_STATE_TARGET")" || return if [[ "$current_checksum" != "$MANAGED_STATE_CHECKSUM" ]]; then cli_error "Managed file was modified; preserving it: $MANAGED_STATE_TARGET" @@ -810,7 +810,7 @@ managed_validate_uninstall_resource() { fi ;; file) - if [[ -f "$MANAGED_STATE_TARGET" ]]; then + if managed_path_is_regular_file "$MANAGED_STATE_TARGET"; then current_checksum="$(managed_checksum "$MANAGED_STATE_TARGET")" if [[ "$current_checksum" != "$MANAGED_STATE_CHECKSUM" ]]; then cli_error "Managed file was modified; preserving it: $MANAGED_STATE_TARGET" diff --git a/lib/releases.sh b/lib/releases.sh index a76ba6fe..2f6797f8 100644 --- a/lib/releases.sh +++ b/lib/releases.sh @@ -153,9 +153,7 @@ release_install() { rm -rf "$temporary_dir" return 1 } - # A duplicate SHA256SUMS line, even an identical one, would make $expected - # multi-line and never equal $actual. `sort -u` collapses agreeing - # duplicates and still fails closed on conflicting ones. + # Collapse agreeing checksum entries; conflicting duplicates still fail verification. expected="$(awk -v name="$archive_name" '$2 == name { print $1 }' "$checksum_file" | sort -u)" actual="$(dependency_sha256 "$archive")" if [[ -z "$expected" || "$actual" != "$expected" ]]; then @@ -205,7 +203,5 @@ release_install() { cli_error "Failed to activate Selfishell $version." return 1 } - # command_update owns the one closing result for a completed update, so the - # switch is not announced here. release_prune_inactive } diff --git a/lib/resources.sh b/lib/resources.sh index 6501b3f4..551c2e62 100644 --- a/lib/resources.sh +++ b/lib/resources.sh @@ -38,15 +38,8 @@ block user-ghostty ${XDG_CONFIG_HOME:-$HOME/.config}/ghostty/config.ghostty - EOF } -# Callers consume this through their own process substitution, so a shell loop -# here would read one pipe while writing another. A signal mid-write (SIGCHLD -# from the producer is enough) fails with EINTR on macOS, and Bash 3.2 reports -# rather than retries it: the list silently truncates and `uninstall --restore` -# walks a short set while reporting success. cut retries for itself. -# -# -s drops a delimiter-free row instead of turning it into a name. Only a -# malformed declaration hits this, and it isn't buried: the regression test -# requires these names to match the name column exactly. +# Use cut to avoid Bash 3.2's EINTR truncation with nested process substitutions. +# -s excludes malformed rows without a delimiter. selfishell_managed_resource_names() { selfishell_managed_resources | cut -s -f2 } diff --git a/lib/tool_status.sh b/lib/tool_status.sh index f195d85c..a4327fbc 100644 --- a/lib/tool_status.sh +++ b/lib/tool_status.sh @@ -10,6 +10,9 @@ tool_status_reset_cache() { TOOL_STATUS_BREW_CASKS_READY=0 TOOL_STATUS_APT_PACKAGES="" TOOL_STATUS_APT_PACKAGES_READY=0 + TOOL_STATUS_MISE_VERSIONS="" + TOOL_STATUS_MISE_APPROVED_VERSIONS="" + TOOL_STATUS_MISE_READY=0 } tool_status_apt_version() { @@ -85,17 +88,46 @@ tool_status_executable() { esac } -tool_status_mise_toml_version() { - local mise_toml="$1" tool="$2" - - awk -v tool="$tool" ' - /^\[/ { in_tools = ($0 == "[tools]"); next } - in_tools && $1 == tool { - gsub(/[[:space:]"]/, "", $3) - print $3 - exit - } - ' "$mise_toml" 2>/dev/null +tool_status_mise_version() { + local tool="$1" + local mise_command="" name versions + + TOOL_STATUS_MISE_VERSION="" + TOOL_STATUS_APPROVED="" + if [[ "$TOOL_STATUS_MISE_READY" == 0 ]]; then + # mise.toml owns approved versions; profile records contain only tool names. + TOOL_STATUS_MISE_APPROVED_VERSIONS="$(awk ' + /^\[/ { in_tools = ($0 == "[tools]"); next } + in_tools && $2 == "=" { + gsub(/[[:space:]"]/, "", $3) + print $1, $3 + } + ' "$SELFISHELL_ROOT/config/shared/mise.toml" 2>/dev/null)" || TOOL_STATUS_MISE_APPROVED_VERSIONS="" + if have_command mise; then + mise_command="$(command -v mise)" + elif [[ -x "$HOME/.local/bin/mise" ]]; then + mise_command="$HOME/.local/bin/mise" + fi + if [[ -n "$mise_command" ]]; then + TOOL_STATUS_MISE_VERSIONS="$(MISE_GLOBAL_CONFIG_FILE="${SELFISHELL_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/selfishell}/mise/selfishell.toml" "$mise_command" current 2>/dev/null)" || + TOOL_STATUS_MISE_VERSIONS="" + fi + TOOL_STATUS_MISE_READY=1 + fi + + while read -r name versions; do + if [[ "$name" == "$tool" ]]; then + TOOL_STATUS_APPROVED="$versions" + break + fi + done <<<"$TOOL_STATUS_MISE_APPROVED_VERSIONS" + while read -r name versions; do + if [[ "$name" == "$tool" && -n "$versions" ]]; then + TOOL_STATUS_MISE_VERSION="$versions" + return + fi + done <<<"$TOOL_STATUS_MISE_VERSIONS" + return 1 } tool_status_detect() { @@ -161,24 +193,11 @@ tool_status_detect() { fi ;; mise) - local mise_tool="$package" - local mise_command="" - # config/shared/mise.toml is the sole source of truth for a mise-managed - # tool's approved version; profiles/*.conf only declares the tool - # name, so the approved version can't be parsed out of $package. - TOOL_STATUS_APPROVED="$(tool_status_mise_toml_version "$SELFISHELL_ROOT/config/shared/mise.toml" "$mise_tool")" - if have_command mise; then - mise_command="$(command -v mise)" - elif [[ -x "$HOME/.local/bin/mise" ]]; then - mise_command="$HOME/.local/bin/mise" - fi - if [[ -n "$mise_command" ]]; then - output="$(MISE_GLOBAL_CONFIG_FILE="${SELFISHELL_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/selfishell}/mise/selfishell.toml" "$mise_command" current "$mise_tool" 2>/dev/null)" || output="" - if [[ -n "$output" ]]; then - TOOL_STATUS_INSTALLED="$output" - TOOL_STATUS_SOURCE="mise" - return - fi + tool_status_mise_version "$package" || true + if [[ -n "$TOOL_STATUS_MISE_VERSION" ]]; then + TOOL_STATUS_INSTALLED="$TOOL_STATUS_MISE_VERSION" + TOOL_STATUS_SOURCE="mise" + return fi ;; esac diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index 17d4fcd6..3a7f9c44 100644 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -59,9 +59,7 @@ case "$PROFILE_MODE" in ;; esac -# Argument parsing and mode validation happen above, before this creates -# anything on disk, so --help/a bad --mode/an unknown option can never -# leave a benchmark temp directory behind. +# Validate arguments before creating any temporary files. TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/selfishell-benchmark.XXXXXX")" TEST_HOME="$TEST_ROOT/home" TEST_DATA_HOME="$TEST_HOME/.local/share" @@ -127,8 +125,9 @@ fi case "$PROFILE_MODE" in base) - # compdump atomically replaces its cache through the external mv command. + # Completion uses mv for the dump and touch for its empty audit marker. ln -s /bin/mv "$TEST_HOME/.local/bin/mv" + ln -s /usr/bin/touch "$TEST_HOME/.local/bin/touch" COMMON_PATH="$TEST_HOME/.local/bin" INTERACTIVE_PATH="$ROOT_DIR/bin:$TEST_HOME/.local/bin" ;; diff --git a/scripts/check.sh b/scripts/check.sh index eea5384a..499803cd 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -5,9 +5,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -# Discovered rather than hand-listed so a new lib/scripts/tests file is -# checked automatically; mapfile is intentionally avoided since it's not -# available on Bash 3.2 (macOS's default /bin/bash). +# Discover new files automatically; avoid mapfile for Bash 3.2 compatibility. bash_files=() while IFS= read -r file; do bash_files+=("$file") @@ -29,10 +27,14 @@ done < <( ) printf 'Checking Bash syntax\n' -bash -n "${bash_files[@]}" +for file in "${bash_files[@]}"; do + bash -n "$file" +done printf 'Checking Zsh syntax\n' -zsh -n "${zsh_files[@]}" +for file in "${zsh_files[@]}"; do + zsh -n "$file" +done printf 'Running ShellCheck\n' shellcheck -x "${bash_files[@]}" diff --git a/scripts/macos-configuration-e2e.sh b/scripts/macos-configuration-e2e.sh index 8d857356..8d09d196 100644 --- a/scripts/macos-configuration-e2e.sh +++ b/scripts/macos-configuration-e2e.sh @@ -1,9 +1,7 @@ #!/usr/bin/env bash -# Runs the managed-configuration lifecycle against an isolated HOME on a real -# macOS runner: the Ubuntu-only E2E left BSD touch/stat/sed, Bash 3.2, and -# Ghostty's preflight unverified. --skip-packages throughout, so no Homebrew -# and no network -- release installs use locally built file:// fixtures. +# Exercise the macOS configuration lifecycle in an isolated HOME using local releases +# and --skip-packages; no package installation or network access is needed. set -euo pipefail @@ -23,17 +21,13 @@ fail() { exit 1 } -# `status`'s exit code also reflects missing required packages, which -# --skip-packages guarantees here, so it says nothing useful. A managed -# resource reporting other than [OK] does. +# Check resource markers because status may also fail for uninstalled packages. assert_managed_resources_clean() { local prefix="$1" local context="$2" local status_output - # Captured rather than piped directly into grep: under `pipefail`, - # status's own (expected, package-driven) exit code would otherwise - # poison the pipeline's exit status regardless of what grep finds. + # Capture separately so a package-related status failure cannot trip pipefail. status_output="$("$prefix/bin/selfishell" status 2>&1)" || true printf '%s\n' "$status_output" | grep -Eq '\[CHANGED\]|\[MALFORMED\]|\[PENDING\]' && fail "status reported a changed, malformed, or pending managed resource $context" @@ -55,11 +49,7 @@ publish_fixture() { publish_fixture "$INITIAL_VERSION" publish_fixture "$NEXT_VERSION" -# ----------------------------------------------------------------------- -# Primary lifecycle: clean install, idempotent reinstall, status, update, -# uninstall --restore -- all configuration-only (--skip-packages), all -# against an isolated HOME/XDG sandbox that is never the real runner HOME. -# ----------------------------------------------------------------------- +# Configuration lifecycle run_primary_lifecycle() { local home="$TEST_ROOT/home-primary" local prefix="$home/.local" @@ -72,9 +62,7 @@ run_primary_lifecycle() { export XDG_CACHE_HOME="$home/xdg-cache" mkdir -p "$HOME" "$XDG_CONFIG_HOME" - # No trailing newline and a CRLF-styled line, so the real (not mocked) - # lifecycle proves it preserves both byte-for-byte, matching M8's - # acceptance criteria for the loader block. + # Check byte preservation with CRLF content and no trailing newline. printf 'export SELFISHELL_E2E_MARKER=1\r\nalias ll="ls -la"' >"$HOME/.zshrc" local zshrc_before zshrc_before="$(cat "$HOME/.zshrc")" @@ -104,9 +92,7 @@ run_primary_lifecycle() { [[ "$vimrc_block_count" == 1 ]] || fail "install did not add exactly one vimrc block (found $vimrc_block_count)" [[ -d "$XDG_CONFIG_HOME/selfishell" ]] || fail "managed configuration was not created under XDG_CONFIG_HOME" [[ -d "$XDG_STATE_HOME/selfishell" ]] || fail "managed state was not created under XDG_STATE_HOME" - # The managed *links* (starship.toml, nvim, mise's conf.d entry) live - # under $HOME itself, pointing into the copied $XDG_CONFIG_HOME/selfishell - # tree -- never directly at the source checkout that this script runs from. + # Managed links must target installed configuration, not the source checkout. while IFS= read -r -d '' link; do case "$(readlink "$link")" in "$ROOT_DIR"*) fail "$link links directly into the source checkout instead of the copied managed configuration" ;; @@ -146,9 +132,7 @@ run_primary_lifecycle() { assert_managed_resources_clean "$prefix" "on a clean install" # --- configuration update --- - # --tools-only --skip-packages keeps this configuration-only, same as the - # install step above. The CLI-release path is not part of --tools-only, - # confirmed by pointing it at an unreachable release root. + # An unreachable release root verifies this configuration update stays offline. SELFISHELL_RELEASE_ROOT='file:///network-must-not-be-used' \ "$prefix/bin/selfishell" update --tools-only --skip-packages --yes >/dev/null loader_count="$(grep -Fc '# >>> Selfishell initialize >>>' "$HOME/.zshrc")" @@ -176,11 +160,7 @@ run_primary_lifecycle() { printf 'PASS: primary configuration lifecycle (clean install, idempotent reinstall, status, update, uninstall --restore)\n' } -# ----------------------------------------------------------------------- -# A separate isolated install where Ghostty defaults to enabled (no prior -# state, --yes), exercising its config-path preflight and managed files while -# --skip-packages keeps the cask from ever being installed. -# ----------------------------------------------------------------------- +# Default Ghostty configuration run_ghostty_preflight_check() { local home="$TEST_ROOT/home-ghostty" local prefix="$home/.local" @@ -207,11 +187,7 @@ run_ghostty_preflight_check() { printf 'PASS: Ghostty config-path preflight and managed files (no package installed)\n' } -# ----------------------------------------------------------------------- -# Purge: a separate, isolated bootstrap install, then uninstall --restore -# --purge, verifying the CLI link, release data, cache, and state are all -# removed (package-manager-installed tools are never touched). -# ----------------------------------------------------------------------- +# CLI purge run_purge_lifecycle() { local home="$TEST_ROOT/home-purge" local prefix="$home/.local" @@ -238,11 +214,7 @@ run_purge_lifecycle() { printf 'PASS: purge removes the CLI, releases, cache, and state\n' } -# ----------------------------------------------------------------------- -# Every step above already ran against BSD touch/stat/sed, mktemp, and -# XDG-override handling; this checks the few things the lifecycle passing -# does not otherwise imply. -# ----------------------------------------------------------------------- +# macOS compatibility checks run_macos_portability_checks() { local bash_version diff --git a/scripts/update-dependencies.sh b/scripts/update-dependencies.sh index 9304cc99..3d120713 100755 --- a/scripts/update-dependencies.sh +++ b/scripts/update-dependencies.sh @@ -167,9 +167,7 @@ mise_tool_current_version() { ' "$mise_toml" } -# Rewrites $staged_file's single "$old" occurrence to "$new", failing if it -# isn't found exactly once -- mirrors stage_zsh_plugin_pin's guarantee that -# an ambiguous or absent target is a hard failure, not a silent partial edit. +# Require exactly one matching line before rewriting a staged file. stage_exact_replacement() { local staged_file="$1" old="$2" new="$3" local match_count diff --git a/scripts/verify-published-release.sh b/scripts/verify-published-release.sh index 5d3a4ba2..cf455c0a 100755 --- a/scripts/verify-published-release.sh +++ b/scripts/verify-published-release.sh @@ -79,11 +79,7 @@ gh release download "$tag" --repo "$repository" --dir "$download_dir" fi ) -# docs/RELEASING.md documents every asset as carrying signed provenance, so an -# unverifiable attestation must not still report the release as verified: that -# downgrades to checksum self-consistency (archives match their own SHA256SUMS, -# not what CI built) while exiting 0. Opting out has to be explicit, for a gh -# CLI predating `gh attestation`. +# Require attestations unless explicitly skipped; matching checksums alone do not prove provenance. if gh attestation verify --help >/dev/null 2>&1; then for archive in "$download_dir"/selfishell-"$version"-*.tar.gz; do gh attestation verify "$archive" --repo "$repository" >/dev/null diff --git a/tests/benchmark_test.bash b/tests/benchmark_test.bash index f06f3dbb..00f99880 100755 --- a/tests/benchmark_test.bash +++ b/tests/benchmark_test.bash @@ -8,43 +8,40 @@ source "$ROOT_DIR/tests/test_helper.bash" # Argument parsing and --mode base only. --mode full provisions real tools over # the network, so it is run manually rather than made a suite dependency. -test_benchmark_rejects_unknown_mode() { - local status=0 - local output +assert_benchmark_early_exit() { + local expected_status="$1" expected_message="$2" + local status=0 output leftover + shift 2 - output="$(bash "$ROOT_DIR/scripts/benchmark.sh" --mode bogus 2>&1)" || status=$? + setup_test_home + mkdir -p "$TEST_ROOT/tmp" + output="$(TMPDIR="$TEST_ROOT/tmp" "$@" 2>&1)" || status=$? - [[ "$status" -eq 2 ]] || fail "An unknown --mode should exit 2 (got $status)" - [[ "$output" == *'must be "base" or "full"'* ]] || fail "Unknown --mode did not explain the valid values: $output" + [[ "$status" -eq "$expected_status" ]] || fail "Expected exit $expected_status, got $status: $output" + [[ "$output" == *"$expected_message"* ]] || fail "Missing message '$expected_message': $output" + leftover="$(find "$TEST_ROOT/tmp" -mindepth 1)" + [[ -z "$leftover" ]] || fail "An early-exit path left temporary files behind: $leftover" + teardown_test_home } -test_benchmark_rejects_missing_mode_value() { - local status=0 - local output - - output="$(bash "$ROOT_DIR/scripts/benchmark.sh" --mode 2>&1)" || status=$? +test_benchmark_rejects_unknown_mode() { + assert_benchmark_early_exit 2 'must be "base" or "full"' \ + bash "$ROOT_DIR/scripts/benchmark.sh" --mode bogus +} - [[ "$status" -eq 2 ]] || fail "A --mode with no following value should exit 2 (got $status)" - [[ "$output" == *'--mode requires base or full'* ]] || fail "A missing --mode value did not explain usage: $output" +test_benchmark_rejects_missing_mode_value() { + assert_benchmark_early_exit 2 '--mode requires base or full' \ + bash "$ROOT_DIR/scripts/benchmark.sh" --mode } test_benchmark_rejects_unknown_option() { - local status=0 - local output - - output="$(bash "$ROOT_DIR/scripts/benchmark.sh" --bogus-flag 2>&1)" || status=$? - - [[ "$status" -eq 2 ]] || fail "An unknown option should exit 2 (got $status)" - [[ "$output" == *'Unknown option: --bogus-flag'* ]] || fail "Unknown option did not name the flag: $output" + assert_benchmark_early_exit 2 'Unknown option: --bogus-flag' \ + bash "$ROOT_DIR/scripts/benchmark.sh" --bogus-flag } test_benchmark_help_documents_both_modes() { - local output - - output="$(bash "$ROOT_DIR/scripts/benchmark.sh" --help)" - - [[ "$output" == *'base'* && "$output" == *'full'* ]] || - fail "--help did not document both benchmark modes: $output" + assert_benchmark_early_exit 0 '[--mode base|full]' \ + bash "$ROOT_DIR/scripts/benchmark.sh" --help } test_benchmark_base_mode_runs_without_network() { @@ -64,7 +61,9 @@ test_benchmark_rejects_missing_retained_zsh_module() { setup_test_home checkout="$TEST_ROOT/checkout" - cp -R "$ROOT_DIR" "$checkout" + mkdir -p "$checkout/scripts" + cp "$ROOT_DIR/scripts/benchmark.sh" "$checkout/scripts/" + cp -R "$ROOT_DIR/config" "$checkout/config" mv "$checkout/config/shared/zsh/aliases.zsh" "$TEST_ROOT/aliases.zsh" output="$(SELFISHELL_BENCHMARK_ITERATIONS=1 \ @@ -123,12 +122,8 @@ EOF } test_benchmark_profile_env_var_is_equivalent_to_mode_flag() { - local output - - output="$(SELFISHELL_BENCHMARK_PROFILE=bogus bash "$ROOT_DIR/scripts/benchmark.sh" 2>&1)" || true - - [[ "$output" == *'must be "base" or "full"'* ]] || - fail "SELFISHELL_BENCHMARK_PROFILE was not honored as a --mode equivalent: $output" + assert_benchmark_early_exit 2 'must be "base" or "full"' \ + env SELFISHELL_BENCHMARK_PROFILE=bogus bash "$ROOT_DIR/scripts/benchmark.sh" } test_benchmark_writes_opt_in_zprof_report() { @@ -146,25 +141,4 @@ test_benchmark_writes_opt_in_zprof_report() { teardown_test_home } -# Argument and mode validation must precede the temp directory, so every -# early-exit path above leaves nothing behind. TMPDIR is sandboxed so -# unrelated temp entries can't confuse the check. -test_benchmark_early_exit_paths_leave_no_temp_directory() { - local leftover - - setup_test_home - trap teardown_test_home EXIT - - TMPDIR="$TEST_ROOT" bash "$ROOT_DIR/scripts/benchmark.sh" --mode >/dev/null 2>&1 || true - TMPDIR="$TEST_ROOT" bash "$ROOT_DIR/scripts/benchmark.sh" --mode bogus >/dev/null 2>&1 || true - TMPDIR="$TEST_ROOT" bash "$ROOT_DIR/scripts/benchmark.sh" --bogus-flag >/dev/null 2>&1 || true - TMPDIR="$TEST_ROOT" bash "$ROOT_DIR/scripts/benchmark.sh" --help >/dev/null 2>&1 || true - TMPDIR="$TEST_ROOT" SELFISHELL_BENCHMARK_PROFILE=bogus bash "$ROOT_DIR/scripts/benchmark.sh" >/dev/null 2>&1 || true - - leftover="$(find "$TEST_ROOT" -maxdepth 1 -name 'selfishell-benchmark.*')" - [[ -z "$leftover" ]] || fail "An early-exit path left a benchmark temp directory behind: $leftover" - - teardown_test_home -} - run_discovered_tests '' teardown_test_home diff --git a/tests/cli_test.bash b/tests/cli_test.bash index 3acd9014..5c83550f 100755 --- a/tests/cli_test.bash +++ b/tests/cli_test.bash @@ -355,9 +355,7 @@ test_doctor_reports_dirty_zinit_plugin_checkout() { teardown_test_home } -# SELFISHELL_ROOT is resolved with parameter expansion rather than `dirname`, -# and `${path%/*}` leaves a bare filename unchanged. Every invocation form has -# to keep finding the repository root, including through chained symlinks. +# Root resolution must support bare filenames and chained symlinks. test_cli_resolves_root_from_every_invocation_form() { local expected link_dir diff --git a/tests/common_zsh_test.bash b/tests/common_zsh_test.bash index d6d909f9..ae455719 100644 --- a/tests/common_zsh_test.bash +++ b/tests/common_zsh_test.bash @@ -107,9 +107,7 @@ EOF teardown_test_home } -# An interrupted clone leaves the plugin directory behind without a repository. -# Loading it would make Zinit fail during startup, and the installer preserves -# an existing plugin path, so startup must treat it the same as a missing one. +# Skip incomplete checkouts at startup; install/update repairs them separately. test_shell_startup_skips_an_incomplete_zinit_plugin_checkout() { local output local plugins_dir @@ -150,25 +148,79 @@ EOF teardown_test_home } -# compinit's security audit (compaudit) is the most expensive part of startup -# and is meant to run once a day, not on every shell. Its freshness check needs -# EXTENDED_GLOB to glob at all, and without it every dump reads as stale. +setup_foreign_completion_audit_marker() { + rm -rf "$HOME/.zcompdump.audit" + case "$1" in + file) printf 'personal data\n' >"$HOME/.zcompdump.audit" ;; + symlink | empty-symlink) + printf 'personal data\n' >"$TEST_ROOT/marker-target" + [[ "$1" != empty-symlink ]] || : >"$TEST_ROOT/marker-target" + ln -s "$TEST_ROOT/marker-target" "$HOME/.zcompdump.audit" + ;; + dangling) ln -s "$TEST_ROOT/missing-target" "$HOME/.zcompdump.audit" ;; + directory) + mkdir "$HOME/.zcompdump.audit" + printf 'personal data\n' >"$HOME/.zcompdump.audit/personal" + ;; + esac + touch -t 202001010000 "$TEST_ROOT/marker-reference" + [[ "$1" == dangling ]] || touch -r "$TEST_ROOT/marker-reference" "$HOME/.zcompdump.audit" +} + +assert_foreign_completion_audit_marker_preserved() { + case "$1" in + file) assert_file_content 'personal data' "$HOME/.zcompdump.audit" ;; + symlink | empty-symlink) + assert_symlink_to "$TEST_ROOT/marker-target" "$HOME/.zcompdump.audit" + if [[ "$1" == symlink ]]; then + assert_file_content 'personal data' "$TEST_ROOT/marker-target" + else + assert_file_content '' "$TEST_ROOT/marker-target" + fi + ;; + dangling) + assert_symlink_to "$TEST_ROOT/missing-target" "$HOME/.zcompdump.audit" + [[ ! -e "$TEST_ROOT/missing-target" ]] || fail "Startup created a dangling marker target" + ;; + directory) assert_file_content 'personal data' "$HOME/.zcompdump.audit/personal" ;; + esac + [[ "$1" == dangling || ! "$HOME/.zcompdump.audit" -nt "$TEST_ROOT/marker-reference" ]] || + fail "Startup changed the timestamp of a foreign $1 audit marker" +} + +# Check the presence of audit work, without depending on timings or call counts. test_completion_audits_the_dump_once_a_day() { - local audits_when_fresh audits_when_stale + local audits_when_fresh audits_when_stale audits_after_refresh marker_type setup_test_home - mkdir -p "$HOME/.cache/selfishell" "$HOME/.local/share" + mkdir -p "$HOME/completion-functions" "$HOME/.local/share" "$HOME/bin" + ln -s /bin/mv "$HOME/bin/mv" + ln -s /bin/rm "$HOME/bin/rm" + ln -s /usr/bin/touch "$HOME/bin/touch" + # Copy the actual Zsh functions into a secure fixture directory: an insecure + # host site-functions directory must not turn the clean-cache test into a + # test of the host permissions. + /bin/zsh -f -c ' + for name in compinit compaudit compdump compinstall _git; do + files=(${^fpath}/$name(N)) + command cp "$files[1]" "$1/$name" || exit 1 + done + ' zsh "$HOME/completion-functions" + chmod 0700 "$HOME/completion-functions" count_startup_audits() { XDG_CACHE_HOME="$HOME/.cache" \ XDG_DATA_HOME="$HOME/.local/share" \ ZDOTDIR="$HOME" \ - PATH="/usr/bin:/bin" \ + PATH="$HOME/bin" \ /bin/zsh -f -i -c ' + fpath=("$HOME/completion-functions") + _compdir="" + _selfishell_command_path() { return 1; } zmodload zsh/zprof source "$1" zprof - ' zsh "$ROOT_DIR/config/shared/zsh/common.zsh" 2>/dev/null | grep -c compaudit || true + ' zsh "$ROOT_DIR/config/shared/zsh/completion.zsh" 2>/dev/null | grep -c compaudit || true } count_startup_audits >/dev/null @@ -176,45 +228,91 @@ test_completion_audits_the_dump_once_a_day() { [[ "$audits_when_fresh" -eq 0 ]] || fail "A fresh completion dump was audited again on startup" - touch -t 202001010000 "$HOME/.zcompdump" + touch -t 202001010000 "$HOME/.zcompdump" "$HOME/.zcompdump.audit" audits_when_stale="$(count_startup_audits)" [[ "$audits_when_stale" -gt 0 ]] || fail "A day-old completion dump was not re-audited" + audits_after_refresh="$(count_startup_audits)" + [[ "$audits_after_refresh" -eq 0 ]] || + fail "A completed daily audit was repeated on the next startup" + + for marker_type in file symlink empty-symlink dangling directory; do + setup_foreign_completion_audit_marker "$marker_type" + rm -f "$HOME/.zcompdump" + count_startup_audits >/dev/null + assert_foreign_completion_audit_marker_preserved "$marker_type" + [[ "$(count_startup_audits)" -gt 0 ]] || + fail "A foreign $marker_type marker bypassed the completion audit" + assert_foreign_completion_audit_marker_preserved "$marker_type" + done teardown_test_home } test_insecure_completion_directory_does_not_block_startup() { - local output - local completion_dir - - setup_test_home - completion_dir="$TEST_ROOT/insecure-completions" - mkdir -p "$completion_dir" "$HOME/.local/share" - chmod 0777 "$completion_dir" - touch -t 202001010000 "$HOME/.zcompdump" - - output="$(run_completion_startup_probe "$completion_dir")" - - [[ "$output" == *STARTUP_COMPLETE* ]] || - fail "Shell startup did not complete with an insecure completion directory present: $output" - [[ "$output" == *'insecure completion directories detected'* ]] || - fail "Shell startup did not warn about the insecure completion directory: $output" - teardown_test_home + local output completion_dir scenario + + for scenario in missing noninteractive removed compile-failure expired foreign-file foreign-symlink foreign-empty-symlink foreign-dangling foreign-directory; do + setup_test_home + completion_dir="$TEST_ROOT/insecure-completions" + mkdir -p "$completion_dir" "$HOME/.local/share" + chmod 0777 "$completion_dir" + printf '#compdef selfishell-insecure-probe\n' >"$completion_dir/_selfishell_insecure_probe" + mkdir "$TEST_ROOT/secure-completions" + printf '#compdef selfishell-safe-probe\nprint SAFE_COMPLETION\n' >"$TEST_ROOT/secure-completions/_selfishell_safe_probe" + printf '#compdef selfishell-safe-probe\nprint INSECURE_LOADED\n' >"$completion_dir/_selfishell_safe_probe" + case "$scenario" in + foreign-*) setup_foreign_completion_audit_marker "${scenario#foreign-}" ;; + noninteractive) + run_completion_startup_probe "$completion_dir" +i >/dev/null + # An unchanged file count must not validate an unaudited dump. + touch "$TEST_ROOT/secure-completions/_added_one" "$TEST_ROOT/secure-completions/_added_two" + ;; + removed | compile-failure) + run_completion_startup_probe "$completion_dir" >/dev/null + # A previous clean audit must not survive a new insecure audit. + touch "$HOME/.zcompdump.audit" + rm "$HOME/.zcompdump" + ;; + expired) + run_completion_startup_probe "$completion_dir" >/dev/null + touch -t 202001010000 "$HOME/.zcompdump.audit" + ;; + esac + + output="$(SELFISHELL_TEST_FAIL_COMPILE="$scenario" run_completion_startup_probe "$completion_dir")" + [[ "$output" == *STARTUP_COMPLETE* ]] || + fail "Startup blocked ($scenario): $output" + [[ "$output" == *SAFE_COMPLETION* && "$output" != *INSECURE_REGISTERED* && + "$output" != *INSECURE_LOADED* ]] || + fail "Startup did not restrict completion to the secure directory ($scenario): $output" + [[ "$output" == *'insecure completion directories detected'* ]] || + fail "Startup did not warn about the insecure directory ($scenario): $output" + output="$(run_completion_startup_probe "$completion_dir")" + [[ "$output" == *STARTUP_COMPLETE* && "$output" == *SAFE_COMPLETION* && + "$output" != *INSECURE_REGISTERED* && "$output" != *INSECURE_LOADED* ]] || + fail "Cached startup can autoload from an insecure directory ($scenario): $output" + [[ "$scenario" != foreign-* ]] || assert_foreign_completion_audit_marker_preserved "${scenario#foreign-}" + teardown_test_home + done } -# Selfishell no longer wires its own directory into fpath, so this injects a -# generic one to exercise the same compaudit path: once a day, warn on an -# insecure entry, never block startup. +# Inject an insecure fpath entry to check exclusion without blocking startup. run_completion_startup_probe() { local completion_dir="${1:-}" + local shell_mode="${2:--i}" XDG_CACHE_HOME="$HOME/.cache" \ XDG_DATA_HOME="$HOME/.local/share" \ ZDOTDIR="$HOME" \ PATH="/usr/bin:/bin" \ SELFISHELL_TEST_COMPLETION_DIR="$completion_dir" \ - /bin/zsh -f -i -c ' - [[ -z "$SELFISHELL_TEST_COMPLETION_DIR" ]] || fpath=("$SELFISHELL_TEST_COMPLETION_DIR" $fpath) + /bin/zsh -f "$shell_mode" -c ' + [[ -z "$SELFISHELL_TEST_COMPLETION_DIR" ]] || fpath=("$SELFISHELL_TEST_COMPLETION_DIR" "${SELFISHELL_TEST_COMPLETION_DIR:h}/secure-completions" $fpath) + if [[ "$SELFISHELL_TEST_FAIL_COMPILE" == compile-failure ]]; then + zcompile() { return 1; } + fi source "$1" + (( ! ${+_comps[selfishell-insecure-probe]} )) || print INSECURE_REGISTERED + (( ! ${+_comps[selfishell-safe-probe]} )) || _selfishell_safe_probe print STARTUP_COMPLETE ' zsh "$ROOT_DIR/config/shared/zsh/common.zsh" 2>&1 } @@ -227,16 +325,13 @@ test_secure_completion_directory_does_not_add_warning() { completion_dir="$TEST_ROOT/secure-completions" mkdir -p "$completion_dir" "$HOME/.local/share" chmod 0755 "$completion_dir" - touch -t 202001010000 "$HOME/.zcompdump" + touch -t 202001010000 "$HOME/.zcompdump" "$HOME/.zcompdump.audit" with_dir="$(run_completion_startup_probe "$completion_dir")" [[ "$with_dir" == *STARTUP_COMPLETE* ]] || fail "Shell startup did not complete with a secure completion directory: $with_dir" - # Compared against the same startup without our directory, so a - # pre-existing insecure entry in the host's own $fpath (seen on an Ubuntu - # runner) can't fail this. What matters is that adding ours introduces no - # new warning, not that the host is spotless. - touch -t 202001010000 "$HOME/.zcompdump" + # Compare with baseline startup so host fpath permissions cannot cause a false failure. + touch -t 202001010000 "$HOME/.zcompdump" "$HOME/.zcompdump.audit" without_dir="$(run_completion_startup_probe)" [[ "$without_dir" == *STARTUP_COMPLETE* ]] || fail "Shell startup did not complete without a completion directory: $without_dir" @@ -343,54 +438,6 @@ missing=absent" ]] || fail "Native command lookup did not preserve PATH semantic teardown_test_home } -test_mise_uses_selfishell_config_only_for_developer_profile() { - local fake_bin developer_config minimal_config - - setup_test_home - fake_bin="$TEST_ROOT/bin" - mkdir -p "$fake_bin" "$HOME/.local/state/selfishell" - cat >"$fake_bin/mise" <<'EOF' -#!/bin/sh -if [ "$1" = activate ]; then - printf 'export SELFISHELL_TEST_MISE_ACTIVATED=1\n' -fi -EOF - chmod +x "$fake_bin/mise" - printf 'developer\n' >"$HOME/.local/state/selfishell/profile" - - developer_config="$( - PATH="$fake_bin:/usr/bin:/bin" \ - XDG_CONFIG_HOME="$HOME/.config" \ - ZDOTDIR="" \ - MISE_GLOBAL_CONFIG_FILE="" \ - /bin/zsh -f -c ' - _selfishell_command_path() { command -v "$1"; } - source "$1" - [[ "$SELFISHELL_TEST_MISE_ACTIVATED" == 1 ]] - print -r -- "$MISE_GLOBAL_CONFIG_FILE" - ' zsh "$ROOT_DIR/config/shared/zsh/runtime.zsh" - )" - - printf 'minimal\n' >"$HOME/.local/state/selfishell/profile" - minimal_config="$( - PATH="$fake_bin:/usr/bin:/bin" \ - XDG_CONFIG_HOME="$HOME/.config" \ - ZDOTDIR="" \ - MISE_GLOBAL_CONFIG_FILE="$HOME/personal-mise.toml" \ - /bin/zsh -f -c ' - _selfishell_command_path() { command -v "$1"; } - source "$1" - print -r -- "$MISE_GLOBAL_CONFIG_FILE" - ' zsh "$ROOT_DIR/config/shared/zsh/runtime.zsh" - )" - - [[ -z "$developer_config" ]] || - fail "Developer profile set MISE_GLOBAL_CONFIG_FILE" - [[ "$minimal_config" == "$HOME/personal-mise.toml" ]] || - fail "Minimal profile replaced the user's mise config" - teardown_test_home -} - test_update_notice_reads_installed_version_file() { local fake_root output @@ -449,22 +496,22 @@ test_update_notice_defers_current_version_lookup_until_available_version_exists( [[ -r "$refresh_calls" ]] && break command sleep 0.05 done - [[ ! -e "$current_calls" ]] - [[ -r "$refresh_calls" ]] + [[ ! -e "$current_calls" ]] || exit 1 + [[ -r "$refresh_calls" ]] || exit 1 : >"$cache_dir/available-version" SELFISHELL_UPDATE_CHECK_INTERVAL=9999999999 _selfishell_update_notice - [[ ! -e "$current_calls" ]] + [[ ! -e "$current_calls" ]] || exit 1 print -r -- 1.1.0 >"$cache_dir/available-version" notice="$(SELFISHELL_UPDATE_CHECK_INTERVAL=9999999999 _selfishell_update_notice)" - [[ "$notice" == "[Selfishell] 1.1.0 is available. Run: selfishell update" ]] - [[ "$(wc -l <"$current_calls")" -eq 1 ]] + [[ "$notice" == "[Selfishell] 1.1.0 is available. Run: selfishell update" ]] || exit 1 + [[ "$(wc -l <"$current_calls")" -eq 1 ]] || exit 1 print -r -- 1.0.0 >"$cache_dir/available-version" SELFISHELL_UPDATE_CHECK_INTERVAL=9999999999 _selfishell_update_notice - [[ ! -e "$cache_dir/available-version" ]] - [[ "$(wc -l <"$current_calls")" -eq 2 ]] + [[ ! -e "$cache_dir/available-version" ]] || exit 1 + [[ "$(wc -l <"$current_calls")" -eq 2 ]] || exit 1 ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" "$current_calls" "$refresh_calls" )" @@ -472,393 +519,150 @@ test_update_notice_defers_current_version_lookup_until_available_version_exists( teardown_test_home } -test_update_notice_uses_cache_and_refreshes_in_background_format() { - local fake_bin cache_dir output now - - setup_test_home +setup_update_notice_cli() { fake_bin="$TEST_ROOT/bin" cache_dir="$HOME/.cache/selfishell" - now="$(date +%s)" mkdir -p "$fake_bin" "$cache_dir" - # Positional parameters must expand in the generated mock, not this test. - # shellcheck disable=SC2016 - printf '%s\n' \ - '#!/usr/bin/env bash' \ - 'if [[ "${2:-}" == "--available" ]]; then' \ - ' printf "1.1.0\\n"' \ - 'else' \ - ' printf "selfishell 0.2.0\\n"' \ - 'fi' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - printf '1.1.0\n' >"$cache_dir/available-version" - printf '%s\n' "$now" >"$cache_dir/update-checked-at" - - output="$( - XDG_CACHE_HOME="$HOME/.cache" \ - ZDOTDIR="" \ - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - ! _selfishell_version_is_newer 0.1.0-beta.9 0.1.0-beta.12 - _selfishell_version_is_newer 0.1.0-beta.13 0.1.0-beta.12 - _selfishell_version_is_newer 0.1.0 0.1.0-beta.12 - ! _selfishell_version_is_newer 0.1.0-beta.12 0.1.0 - _selfishell_version_is_newer 0.1.0-beta.1 0.1.0-alpha.9 - _selfishell_version_is_newer 0.1.0-alpha.1 0.1.0-alpha - ! _selfishell_version_is_newer 0.1.0-alpha 0.1.0-alpha.1 - _selfishell_version_is_newer 0.1.0-alpha.beta 0.1.0-alpha.1 - ! _selfishell_version_is_newer 0.1.0-alpha.1 0.1.0-alpha.beta - _selfishell_version_is_newer 0.1.0-rc.1.2 0.1.0-rc.1.1 - ! _selfishell_version_is_newer 0.1.0-alpha.01 0.1.0-alpha.1 - ! _selfishell_version_is_newer 01.1.0 1.0.0 - _selfishell_update_notice - SELFISHELL_UPDATE_NOTICE=0 _selfishell_update_notice - command rm -f "$2/available-version" "$2/update-checked-at" - _selfishell_update_notice_refresh "$2" 12345 - [[ "$(<"$2/available-version")" == 1.1.0 ]] - [[ "$(<"$2/update-checked-at")" == 12345 ]] - command rm -f "$2/available-version" "$2/update-checked-at" - SELFISHELL_UPDATE_CHECK_INTERVAL=0 _selfishell_update_notice - for attempt in {1..40}; do - [[ -r "$2/available-version" ]] && break - command sleep 0.05 - done - [[ "$(<"$2/available-version")" == 1.1.0 ]] - ' zsh "$ROOT_DIR/config/shared/zsh/common.zsh" "$cache_dir" - )" - - [[ "$output" == '[Selfishell] 1.1.0 is available. Run: selfishell update' ]] || - fail "Default update notice did not use cached version metadata" - teardown_test_home -} - -test_update_notice_stale_lock_is_reclaimed_after_ttl() { - local fake_bin cache_dir output - local stale_created_at - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - # Positional parameters must expand in the generated mock, not this test. - # shellcheck disable=SC2016 - printf '%s\n' \ - '#!/usr/bin/env bash' \ - 'if [[ "${2:-}" == "--available" ]]; then' \ - ' printf "2.0.0\\n"' \ - 'else' \ - ' printf "selfishell 0.2.0\\n"' \ - 'fi' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - - # Simulate a lock left behind by a refresh that was killed mid-run (e.g. - # the terminal closed) well past the default TTL. - stale_created_at=$(($(date +%s) - 700)) - printf '99999\n' >"$cache_dir/update-check.lock/pid" - printf '%s\n' "$stale_created_at" >"$cache_dir/update-check.lock/created_at" - - output="$( - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - cat "$2/available-version" 2>/dev/null - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - [[ "$output" == *'LOCK_CLEARED'* ]] || - fail "A stale lock older than the TTL was not reclaimed and cleared: $output" - [[ "$output" == *'2.0.0'* ]] || - fail "Reclaiming a stale lock did not perform the refresh: $output" - teardown_test_home -} - -test_update_notice_fresh_lock_blocks_concurrent_refresh() { - local fake_bin cache_dir output - local fresh_created_at - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - - fresh_created_at="$(date +%s)" - printf '99999\n' >"$cache_dir/update-check.lock/pid" - printf '%s\n' "$fresh_created_at" >"$cache_dir/update-check.lock/created_at" - - output="$( - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - [[ -e "$2/available-version" ]] && print "VERSION_WRITTEN" || print "VERSION_ABSENT" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - [[ "$output" == *'LOCK_LEFT'* ]] || - fail "A fresh, still-held lock was incorrectly reclaimed: $output" - [[ "$output" == *'VERSION_ABSENT'* ]] || - fail "A concurrent refresh ran despite a fresh lock still being held: $output" - teardown_test_home -} - -test_update_notice_stale_empty_lock_directory_is_reclaimed() { - local fake_bin cache_dir output - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" + cat >"$fake_bin/selfishell" <<'EOF' +#!/bin/sh +[ "$1" = version ] || exit 1 +if [ "${2:-}" = --available ]; then + printf '1.1.0\n' +else + printf 'selfishell 0.2.0\n' +fi +EOF chmod +x "$fake_bin/selfishell" - # No pid/created_at: a lock from a version predating the metadata, or a - # writer that died between mkdir and its first write. Only the directory's - # own mtime is left to judge staleness by. - touch -t 202001010000 "$cache_dir/update-check.lock" - - output="$( - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - [[ "$output" == *'LOCK_CLEARED'* ]] || - fail "A stale, metadata-less lock directory was not reclaimed: $output" - teardown_test_home } -test_update_notice_fresh_empty_lock_directory_is_preserved() { - local fake_bin cache_dir output - +test_update_notice_compares_semantic_versions() { setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - - output="$( - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - [[ -e "$2/available-version" ]] && print "VERSION_WRITTEN" || print "VERSION_ABSENT" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - [[ "$output" == *'LOCK_LEFT'* ]] || - fail "A fresh, metadata-less lock directory was incorrectly reclaimed: $output" - [[ "$output" == *'VERSION_ABSENT'* ]] || - fail "A concurrent refresh ran despite a fresh metadata-less lock: $output" + /bin/zsh -f -c ' + source "$1" + while read -r candidate current expected; do + actual=0 + _selfishell_version_is_newer "$candidate" "$current" && actual=1 + [[ "$actual" == "$expected" ]] || { + print -u2 -- "Wrong version comparison: $candidate > $current (expected $expected, got $actual)" + exit 1 + } + done + ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" <<'VERSIONS' || fail "Semantic version comparison failed" +0.1.0-beta.9 0.1.0-beta.12 0 +0.1.0-beta.13 0.1.0-beta.12 1 +0.1.0 0.1.0-beta.12 1 +0.1.0-beta.12 0.1.0 0 +0.1.0-beta.1 0.1.0-alpha.9 1 +0.1.0-alpha.1 0.1.0-alpha 1 +0.1.0-alpha 0.1.0-alpha.1 0 +0.1.0-alpha.beta 0.1.0-alpha.1 1 +0.1.0-alpha.1 0.1.0-alpha.beta 0 +0.1.0-rc.1.2 0.1.0-rc.1.1 1 +0.1.0-alpha.01 0.1.0-alpha.1 0 +01.1.0 1.0.0 0 +1.0.0 1.0.0 0 +2.0.0 1.9.9 1 +1.10.0 1.9.0 1 +1.0.10 1.0.9 1 +VERSIONS teardown_test_home } -test_update_notice_stale_lock_with_only_pid_is_reclaimed() { +test_update_notice_uses_cache_and_refreshes_in_background() { local fake_bin cache_dir output setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - printf '99999\n' >"$cache_dir/update-check.lock/pid" - touch -t 202001010000 "$cache_dir/update-check.lock/pid" "$cache_dir/update-check.lock" - - output="$( - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - [[ "$output" == *'LOCK_CLEARED'* ]] || - fail "A stale lock with only a pid file was not reclaimed: $output" - teardown_test_home -} - -test_update_notice_corrupt_created_at_falls_back_to_directory_mtime() { - local fake_bin cache_dir output label - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - - for label in stale fresh; do - mkdir -p "$cache_dir/update-check.lock" - printf 'not-a-timestamp\n' >"$cache_dir/update-check.lock/created_at" - [[ "$label" == stale ]] && touch -t 202001010000 "$cache_dir/update-check.lock/created_at" "$cache_dir/update-check.lock" - - output="$( - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - if [[ "$label" == stale ]]; then - [[ "$output" == *'LOCK_CLEARED'* ]] || - fail "A corrupt created_at backed by an old directory mtime was not reclaimed: $output" - else - [[ "$output" == *'LOCK_LEFT'* ]] || - fail "A corrupt created_at backed by a fresh directory mtime was incorrectly reclaimed: $output" - fi - rm -rf "$cache_dir/update-check.lock" "$cache_dir/available-version" "$cache_dir/update-checked-at" - done - teardown_test_home -} - -test_update_notice_unreadable_created_at_falls_back_to_directory_mtime() { - local fake_bin cache_dir output - - # Permission bits don't restrict root's own reads, so this scenario can't - # be produced when running as root (e.g. some containers). - [[ "$(id -u)" != 0 ]] || return 0 - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - printf '%s\n' "$(date +%s)" >"$cache_dir/update-check.lock/created_at" - chmod 000 "$cache_dir/update-check.lock/created_at" - touch -t 202001010000 "$cache_dir/update-check.lock" - - output="$( - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - chmod 644 "$cache_dir/update-check.lock/created_at" 2>/dev/null || true - [[ "$output" == *'LOCK_CLEARED'* ]] || - fail "An unreadable created_at backed by an old directory mtime was not reclaimed: $output" - teardown_test_home -} - -test_update_notice_future_created_at_is_preserved() { - local fake_bin cache_dir output future_created_at - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - future_created_at=$(($(date +%s) + 100000)) - printf '%s\n' "$future_created_at" >"$cache_dir/update-check.lock/created_at" + setup_update_notice_cli + printf '1.1.0\n' >"$cache_dir/available-version" + date +%s >"$cache_dir/update-checked-at" output="$( - PATH="$fake_bin:/usr/bin:/bin" \ + XDG_CACHE_HOME="$HOME/.cache" ZDOTDIR="" PATH="$fake_bin:/usr/bin:/bin" \ /bin/zsh -f -c ' + _selfishell_command_path() { command -v "$1"; } source "$1" + _selfishell_update_notice + [[ -z "$(SELFISHELL_UPDATE_NOTICE=0 _selfishell_update_notice)" ]] || exit 1 + command rm -f "$2/available-version" "$2/update-checked-at" _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" + [[ "$(<"$2/available-version")" == 1.1.0 ]] || exit 1 + [[ "$(<"$2/update-checked-at")" == 12345 ]] || exit 1 + command rm -f "$2/available-version" "$2/update-checked-at" + SELFISHELL_UPDATE_CHECK_INTERVAL=0 _selfishell_update_notice + for attempt in {1..40}; do + [[ -r "$2/available-version" && -r "$2/update-checked-at" && ! -e "$2/update-check.lock" ]] && break + command sleep 0.05 + done + [[ -r "$2/available-version" && "$(<"$2/available-version")" == 1.1.0 ]] || exit 1 + [[ -s "$2/update-checked-at" && ! -e "$2/update-check.lock" ]] || exit 1 ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" + )" || fail "Update notice cache or background refresh failed" - [[ "$output" == *'LOCK_LEFT'* ]] || - fail "A lock with a future created_at was incorrectly reclaimed: $output" + [[ "$output" == *'1.1.0'* && "$output" == *'selfishell update'* ]] || + fail "Update notice did not offer the cached version: $output" teardown_test_home } -test_update_notice_lock_ttl_rejects_invalid_values() { - local fake_bin cache_dir output ttl stale_created_at +# Metadata wins when valid; interrupted/older writers fall back to directory +# age. Each case checks both lock ownership and whether a refresh occurred. +test_update_notice_lock_recovery() { + local fake_bin cache_dir metadata age ttl expected now setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - stale_created_at=$(($(date +%s) - 700)) - - for ttl in abc -100 1.5 0 ''; do - mkdir -p "$cache_dir/update-check.lock" - printf '%s\n' "$stale_created_at" >"$cache_dir/update-check.lock/created_at" - - output="$( - SELFISHELL_UPDATE_LOCK_TTL="$ttl" PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - [[ "$output" == *'LOCK_CLEARED'* ]] || - fail "An invalid SELFISHELL_UPDATE_LOCK_TTL='$ttl' did not fall back to the default TTL: $output" + setup_update_notice_cli + now="$(date +%s)" + while read -r metadata age ttl expected; do + [[ "$metadata" != unreadable || "$(id -u)" != 0 ]] || continue rm -rf "$cache_dir/update-check.lock" "$cache_dir/available-version" "$cache_dir/update-checked-at" - done - teardown_test_home -} - -test_update_notice_lock_ttl_zero_does_not_mean_instantly_stale() { - local fake_bin cache_dir output - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - printf '%s\n' "$(($(date +%s) - 2))" >"$cache_dir/update-check.lock/created_at" - - output="$( - SELFISHELL_UPDATE_LOCK_TTL=0 PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - [[ "$output" == *'LOCK_LEFT'* ]] || - fail "SELFISHELL_UPDATE_LOCK_TTL=0 treated a 2-second-old lock as instantly stale instead of falling back to the default: $output" - teardown_test_home -} - -test_update_notice_lock_ttl_honors_valid_custom_value() { - local fake_bin cache_dir output - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir/update-check.lock" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - printf '%s\n' "$(($(date +%s) - 5))" >"$cache_dir/update-check.lock/created_at" + mkdir "$cache_dir/update-check.lock" + case "$metadata" in + timestamp) printf '%s\n' "$((now + age))" >"$cache_dir/update-check.lock/created_at" ;; + pid) printf '99999\n' >"$cache_dir/update-check.lock/pid" ;; + corrupt) printf 'not-a-timestamp\n' >"$cache_dir/update-check.lock/created_at" ;; + zero) printf '0\n' >"$cache_dir/update-check.lock/created_at" ;; + unreadable) + printf '%s\n' "$now" >"$cache_dir/update-check.lock/created_at" + chmod 000 "$cache_dir/update-check.lock/created_at" + ;; + esac + if [[ "$metadata" != timestamp && "$age" == stale ]]; then + touch -t 202001010000 "$cache_dir/update-check.lock" + fi + [[ "$ttl" != empty ]] || ttl='' - output="$( - SELFISHELL_UPDATE_LOCK_TTL=2 PATH="$fake_bin:/usr/bin:/bin" \ + SELFISHELL_UPDATE_LOCK_TTL="$ttl" PATH="$fake_bin:/usr/bin:/bin" \ /bin/zsh -f -c ' source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - [[ "$output" == *'LOCK_CLEARED'* ]] || - fail "A valid custom SELFISHELL_UPDATE_LOCK_TTL was not honored: $output" + _selfishell_update_notice_refresh "$2" 12345 || : + if [[ "$3" == refreshed ]]; then + [[ ! -e "$2/update-check.lock" ]] || exit 1 + [[ -r "$2/available-version" && "$(<"$2/available-version")" == 1.1.0 ]] || exit 1 + [[ -r "$2/update-checked-at" && "$(<"$2/update-checked-at")" == 12345 ]] || exit 1 + else + [[ -d "$2/update-check.lock" && ! -e "$2/available-version" && ! -e "$2/update-checked-at" ]] || exit 1 + fi + ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" "$expected" || + fail "Lock recovery: metadata=$metadata age=$age ttl=$ttl expected=$expected" + done <<'LOCKS' +timestamp -700 600 refreshed +timestamp 0 600 held +absent stale 600 refreshed +absent fresh 600 held +pid stale 600 refreshed +corrupt stale 600 refreshed +corrupt fresh 600 held +zero stale 600 refreshed +zero fresh 600 held +unreadable stale 600 refreshed +timestamp 100000 600 held +timestamp -700 abc refreshed +timestamp -700 -100 refreshed +timestamp -700 1.5 refreshed +timestamp -700 0 refreshed +timestamp -700 empty refreshed +timestamp -2 0 held +timestamp -5 2 refreshed +LOCKS teardown_test_home } @@ -903,42 +707,6 @@ test_update_lock_stale_since_preserves_lock_when_age_cannot_be_determined() { teardown_test_home } -test_update_notice_created_at_zero_falls_back_to_directory_mtime() { - local fake_bin cache_dir output label - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" - printf '#!/usr/bin/env bash\nprintf "2.0.0\\n"\n' >"$fake_bin/selfishell" - chmod +x "$fake_bin/selfishell" - - for label in stale fresh; do - mkdir -p "$cache_dir/update-check.lock" - printf '0\n' >"$cache_dir/update-check.lock/created_at" - [[ "$label" == stale ]] && touch -t 202001010000 "$cache_dir/update-check.lock/created_at" "$cache_dir/update-check.lock" - - output="$( - PATH="$fake_bin:/usr/bin:/bin" \ - /bin/zsh -f -c ' - source "$1" - _selfishell_update_notice_refresh "$2" 12345 - [[ -e "$2/update-check.lock" ]] && print "LOCK_LEFT" || print "LOCK_CLEARED" - ' zsh "$ROOT_DIR/config/shared/zsh/update-notice.zsh" "$cache_dir" - )" - - if [[ "$label" == stale ]]; then - [[ "$output" == *'LOCK_CLEARED'* ]] || - fail "created_at=0 backed by an old directory mtime was not reclaimed: $output" - else - [[ "$output" == *'LOCK_LEFT'* ]] || - fail "created_at=0 backed by a fresh directory mtime was incorrectly reclaimed (0 must not mean instantly stale): $output" - fi - rm -rf "$cache_dir/update-check.lock" "$cache_dir/available-version" "$cache_dir/update-checked-at" - done - teardown_test_home -} - test_update_notice_refresh_cleans_up_temp_files_on_write_failure() { local cache_dir output @@ -1079,34 +847,63 @@ EOF teardown_test_home } -test_shell_tool_cache_does_not_regenerate_when_cache_is_newer_than_binary() { - local fake_bin cache_dir output - - setup_test_home - fake_bin="$TEST_ROOT/bin" - cache_dir="$HOME/.cache/selfishell" - mkdir -p "$fake_bin" "$cache_dir" - cat >"$fake_bin/zoxide" <<'EOF' -#!/usr/bin/env bash -printf 'echo regenerated\n' +# Timestamps alone miss package rollbacks and replacements that preserve mtime. +# Exercise the real cache reader/writer and count generator executions, not time. +test_shell_tool_cache_reuses_unchanged_tools_and_refreshes_replaced_tools() { + local fake_bin tool replacement output expected_args + + for tool in fzf zoxide starship; do + for replacement in preserved-mtime older-mtime symlink; do + setup_test_home + fake_bin="$TEST_ROOT/bin" + mkdir -p "$fake_bin" + expected_args='init zsh' + [[ "$tool" != fzf ]] || expected_args='--zsh' + cat >"$fake_bin/$tool" <<'EOF' +#!/bin/sh +[ "$*" = "$SELFISHELL_TEST_INIT_ARGS" ] || exit 1 +printf 'called\n' >>"$HOME/generations" +printf 'print old\n' EOF - chmod +x "$fake_bin/zoxide" - touch -t 202001010000 "$fake_bin/zoxide" - printf '# already current\n' >"$cache_dir/zoxide-init.zsh" - - output="$( - ZDOTDIR="" PATH="$fake_bin:/usr/bin:/bin" SELFISHELL_COMMON_DIR="$ROOT_DIR/config/shared/zsh" \ - XDG_CONFIG_HOME="$HOME/.config" XDG_CACHE_HOME="$HOME/.cache" \ - /bin/zsh -f -c '_selfishell_command_path() { command -v "$1"; }; source "$1"' \ - zsh "$ROOT_DIR/config/shared/zsh/interactive.zsh" 2>/dev/null - cat "$cache_dir/zoxide-init.zsh" - )" + chmod +x "$fake_bin/$tool" + touch -t 202101010000 "$fake_bin/$tool" + + run_tool_cache_startup() { + ZDOTDIR="" PATH="$fake_bin:/usr/bin:/bin" SELFISHELL_COMMON_DIR="$ROOT_DIR/config/shared/zsh" \ + XDG_CONFIG_HOME="$HOME/.config" XDG_CACHE_HOME="$HOME/.cache" \ + SELFISHELL_TEST_INIT_ARGS="$expected_args" \ + /bin/zsh -f -c '_selfishell_command_path() { command -v "$1"; }; source "$1"' \ + zsh "$ROOT_DIR/config/shared/zsh/interactive.zsh" + } + + output="$(run_tool_cache_startup)" + [[ "$output" == old ]] || fail "$tool did not source its generated initialization: $output" + output="$(run_tool_cache_startup)" + [[ "$output" == old && "$(wc -l <"$HOME/generations")" -eq 1 ]] || + fail "$tool regenerated unchanged initialization ($replacement): $output" + + sed 's/print old/print new/' "$fake_bin/$tool" >"$fake_bin/replacement" + chmod +x "$fake_bin/replacement" + touch -r "$fake_bin/$tool" "$fake_bin/replacement" + if [[ "$replacement" == older-mtime ]]; then + touch -t 202001010000 "$fake_bin/replacement" + fi + if [[ "$replacement" == symlink ]]; then + rm "$fake_bin/$tool" + ln -s "$fake_bin/replacement" "$fake_bin/$tool" + else + mv "$fake_bin/replacement" "$fake_bin/$tool" + fi - [[ "$output" == *'# already current'* ]] || - fail "Cache was regenerated even though it is newer than the tool binary: $output" - [[ "$output" != *'regenerated'* ]] || - fail "The tool was invoked even though its cache is already current: $output" - teardown_test_home + output="$(run_tool_cache_startup)" + [[ "$output" == new && "$(wc -l <"$HOME/generations")" -eq 2 ]] || + fail "$tool did not regenerate after $replacement replacement: $output" + output="$(run_tool_cache_startup)" + [[ "$output" == new && "$(wc -l <"$HOME/generations")" -eq 2 ]] || + fail "$tool regenerated unchanged replacement: $output" + teardown_test_home + done + done } test_shell_tool_cache_write_failure_preserves_existing_cache() { @@ -1467,9 +1264,7 @@ zdharma-continuum/fast-syntax-highlighting config/shared/zsh/interactive.zsh PLUGINS } -# Writes a Zinit stub plus an fzf stub into $TEST_ROOT so interactive.zsh takes -# the branch that configures fzf-tab. The plugin directory itself is left to the -# caller, since its absence is what the guard is supposed to detect. +# Stub Zinit and fzf; callers choose whether the plugin checkout exists. setup_fzf_tab_stubs() { local fake_bin="$TEST_ROOT/bin" local zinit_home="$HOME/.local/share/zinit/zinit.git" @@ -1615,15 +1410,11 @@ test_fzf_tab_git_previews_read_the_repository() { setup_test_home setup_fzf_tab_stubs - # The space is deliberate: the previews quote their candidate, and a path that - # cannot survive one is the failure this catches. repository="$TEST_ROOT/a repository" mkdir -p "$repository" "$HOME/.local/share/zinit/plugins/Aloxaf---fzf-tab/.git" dump_fzf_tab_previews - # An identity plus empty config files, so that neither the developer's - # ~/.gitconfig nor /etc/gitconfig -- commit signing above all -- can reach the - # commits below or the previews that read them. + # Isolate Git identity and configuration, including commit signing. export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null export GIT_AUTHOR_NAME=selfishell GIT_AUTHOR_EMAIL=selfishell@example.invalid export GIT_COMMITTER_NAME=selfishell GIT_COMMITTER_EMAIL=selfishell@example.invalid @@ -1670,10 +1461,7 @@ test_fzf_tab_git_previews_read_the_repository() { teardown_test_home } -# The picker is the one fzf surface that doesn't follow the terminal palette: -# fzf takes accents from the 256-color cube, and fzf-tab blanks -# FZF_DEFAULT_OPTS before invoking it. So the palette reaches fzf-tab as a -# flag, not through the user's variable, which may hold breaking options. +# fzf-tab clears FZF_DEFAULT_OPTS, so it needs the terminal palette flag separately. test_fzf_is_pointed_at_the_terminal_palette() { local output @@ -1701,8 +1489,8 @@ test_fzf_is_pointed_at_the_terminal_palette() { fail "fzf was not pointed at the terminal's own colors: $output" [[ "$output" == *'type='*export* ]] || fail "FZF_DEFAULT_OPTS was not exported, so fzf will not see it: $output" - [[ "$(grep '^cd=' <<<"$output")" == 'cd=--color=16' ]] || - fail "fzf-tab was not given the palette: $output" + [[ "$(grep '^cd=' <<<"$output")" == *--color=16* ]] || + fail "fzf-tab was not given the supported terminal palette option: $output" [[ "$output" == *'kill='*--color=16* ]] || fail "A context with its own fzf-flags lost the palette: $output" teardown_test_home @@ -1737,9 +1525,9 @@ test_fzf_tab_is_not_handed_the_users_fzf_options() { [[ "$output" == *'opts=--with-nth=2.. --bind=ctrl-a:select-all'* ]] || fail "The user's own FZF_DEFAULT_OPTS was overwritten: $output" - # Compared whole rather than searched, so anything that leaked in fails here. - [[ "$(grep '^cd=' <<<"$output")" == 'cd=--color=16' ]] || - fail "fzf-tab was handed something other than the palette: $output" + [[ "$(grep '^cd=' <<<"$output")" != *--with-nth* && + "$(grep '^cd=' <<<"$output")" != *--bind* ]] || + fail "fzf-tab was handed the user's standalone options: $output" [[ "$output" == *'follows=unset'* ]] || fail "fzf-tab was told to read FZF_DEFAULT_OPTS: $output" teardown_test_home diff --git a/tests/fixtures/neovim/editor_workflow.lua b/tests/fixtures/neovim/editor_workflow.lua index 3cba2429..32b9e808 100644 --- a/tests/fixtures/neovim/editor_workflow.lua +++ b/tests/fixtures/neovim/editor_workflow.lua @@ -2,13 +2,8 @@ vim.g.mapleader = " " require("config.options") require("config.keymaps") -assert(vim.o.splitright and vim.o.splitbelow, "split direction is not configured") -assert(vim.o.scrolloff == 4, "scrolloff is not configured") -assert(not vim.o.wrap, "line wrapping is enabled") assert(vim.o.confirm, "confirmation is not enabled") -assert(vim.o.inccommand == "split", "substitution preview is not configured") -assert(not vim.o.showmode, "the mode is printed twice alongside lualine") -assert(vim.o.pumheight == 10, "the completion menu height is unbounded") +assert(vim.o.inccommand ~= "", "substitution preview is disabled") local function assert_map(mode, lhs, rhs) local mapping = vim.fn.maparg(lhs, mode, false, true) @@ -84,41 +79,23 @@ local snacks = assert(plugin_spec("plugins.ui", "folke/snacks.nvim"), "Snacks sp local picker = assert(snacks.opts.picker, "Snacks picker must be configured") assert(picker.ui_select == false, "Snacks must not take over vim.ui.select") assert(picker.sources.files.cmd == "rg", "The files picker must not depend on a personally-installed fd") -assert(picker.sources.files.icons.files.enabled == false, "The files picker should hide the leading file icon") -assert(picker.sources.grep.icons.files.enabled == false, "The grep picker should hide the leading file icon") -assert(picker.sources.buffers.icons.files.enabled == false, "The buffers picker should hide the leading file icon") assert(picker.sources.diagnostics.filter.cwd == false, "Diagnostics must not be limited to the cwd") local tree = assert(plugin_spec("plugins.ui", "nvim-tree/nvim-tree.lua"), "nvim-tree spec is missing") assert(not has_dependency(tree, "nvim-tree/nvim-web-devicons"), "nvim-web-devicons dependency should be removed") assert(type(tree.opts.view.width) == "function", "NvimTree width is not a function") local original_columns = vim.o.columns -vim.o.columns = 60 -assert(tree.opts.view.width() == 20, "width should clamp to the 20-column minimum") -vim.o.columns = 100 -assert(tree.opts.view.width() == 25, "width should scale to 25% of columns") -vim.o.columns = 200 -assert(tree.opts.view.width() == 30, "width should clamp to the 30-column maximum") +local widths = {} +for _, columns in ipairs({ 60, 100, 200 }) do + vim.o.columns = columns + local width = tree.opts.view.width() + assert(width > 0 and width < columns and width == math.floor(width), "NvimTree width must fit the viewport") + widths[#widths + 1] = width +end +assert(widths[1] <= widths[2] and widths[2] <= widths[3] and widths[1] < widths[3], + "NvimTree width must adapt as the viewport grows") vim.o.columns = original_columns assert(type(tree.opts.on_attach) == "function", "NvimTree does not preserve window navigation mappings") -assert(tree.opts.renderer.group_empty, "NvimTree should compact single-child directory chains") -assert(tree.opts.renderer.indent_markers.enable, "NvimTree indent markers should be enabled") -assert(tree.opts.renderer.indent_markers.inline_arrows, "NvimTree arrows should align with indent markers") -assert( - tree.opts.renderer.indent_markers.icons and tree.opts.renderer.indent_markers.icons.edge == " ", - "NvimTree ancestor indent guides should stay sparse" -) -assert(tree.opts.renderer.icons.glyphs.folder.arrow_closed == ">", "NvimTree closed folder arrow must be portable") -assert(tree.opts.renderer.icons.glyphs.folder.arrow_open == "v", "NvimTree open folder arrow must be portable") -assert(not tree.opts.renderer.icons.padding, "NvimTree folder arrows should use the default padding") -assert(tree.opts.renderer.icons.show.file == false, "NvimTree file icons should remain hidden") -assert(tree.opts.renderer.icons.show.folder == false, "NvimTree folder icons should remain hidden") -assert(tree.opts.renderer.icons.show.git == true, "NvimTree git status icons should be enabled") -assert(tree.opts.renderer.icons.git_placement == "right_align", "NvimTree git status icons should be right-aligned") -assert(tree.opts.renderer.icons.glyphs.git.unstaged == "M", "NvimTree unstaged indicator should be M") -assert(tree.opts.renderer.icons.glyphs.git.staged == "S", "NvimTree staged indicator should be S") -assert(tree.opts.renderer.icons.glyphs.git.untracked == "U", "NvimTree untracked indicator should be U") -assert(tree.opts.renderer.icons.glyphs.git.ignored == "", "NvimTree ignored indicator should be hidden") assert( plugin_key("plugins.ui", "nvim-tree/nvim-tree.lua", "E") == "NvimTreeFindFile!", "current-file tree mapping does not update the tree root" @@ -128,18 +105,6 @@ local lualine = assert( plugin_spec("plugins.ui", "nvim-lualine/lualine.nvim"), "lualine spec is missing" ) -local branch = lualine.opts.sections.lualine_c[1] -assert(branch.icon == "", "lualine branch icon should be hidden") -assert( - branch.color.fg == "#5fd700" and branch.color.gui == nil, - "lualine branch should use regular bright green" -) -assert(branch.padding.left == 0 and branch.padding.right == 1, "lualine branch spacing is incorrect") -local filetype = lualine.opts.sections.lualine_x[1] -assert( - filetype[1] == "filetype" and filetype.icons_enabled == false, - "lualine filetype icon should be hidden" -) assert(not has_dependency(lualine, "nvim-tree/nvim-web-devicons"), "nvim-web-devicons dependency should be removed") local bufferline = assert( @@ -147,13 +112,6 @@ local bufferline = assert( "bufferline spec is missing" ) assert(bufferline.event == "VeryLazy", "bufferline is not deferred") -assert(bufferline.opts.options.always_show_bufferline == false, "bufferline should hide for one buffer") -assert(bufferline.opts.options.show_buffer_icons == false, "bufferline buffer icons should be hidden") -assert(bufferline.opts.options.offsets[1].filetype == "NvimTree", "bufferline is not aligned with NvimTree") -assert( - bufferline.opts.highlights.buffer_selected.bold == false, - "selected buffer should rely on color and underline instead of bold" -) assert( not has_dependency(bufferline, "nvim-tree/nvim-web-devicons"), "nvim-web-devicons dependency should be removed" @@ -167,15 +125,6 @@ assert( "missing next-buffer mapping" ) -local scrollbar = assert( - plugin_spec("plugins.ui", "petertriho/nvim-scrollbar"), - "nvim-scrollbar spec is missing" -) -assert(scrollbar.opts.handlers.cursor == false, "scrollbar cursor tracking should stay disabled") -assert(scrollbar.opts.handlers.diagnostic == true, "scrollbar diagnostics should remain enabled") -assert(scrollbar.opts.handlers.handle == true, "scrollbar viewport handle should remain enabled") -assert(scrollbar.opts.marks == nil, "scrollbar should not carry dead cursor-mark configuration") - local cmp = assert(plugin_spec("plugins.completion", "hrsh7th/nvim-cmp"), "nvim-cmp spec is missing") assert(cmp.event == "InsertEnter", "nvim-cmp is not deferred") assert(not has_dependency(cmp, "L3MON4D3/LuaSnip"), "LuaSnip dependency should be removed") diff --git a/tests/fixtures/neovim/pinned_plugin_specs.lua b/tests/fixtures/neovim/pinned_plugin_specs.lua index 8b6b2551..8d3ef418 100644 --- a/tests/fixtures/neovim/pinned_plugin_specs.lua +++ b/tests/fixtures/neovim/pinned_plugin_specs.lua @@ -23,15 +23,8 @@ assert(type(snacks.config) == "function", local indent = snacks.opts.indent assert(indent.enabled == true, "Snacks indent module must be enabled") -assert(indent.indent.enabled == false, "Normal indent rendering must stay disabled") -assert(indent.indent.only_scope == nil, "Disabled normal indent rendering must not carry redundant scope options") -assert(indent.animate.enabled == false, "Scope animation must stay disabled") -assert(indent.chunk.enabled == false, "Chunk rendering must stay disabled") assert(indent.scope.enabled == true, "Scope rendering must be enabled") -assert(indent.scope.char == "│", "Scope marker must use a thin solid line") -assert(indent.scope.underline == false, "Scope start underline must stay disabled") -assert(indent.scope.hl == "SnacksIndentScope", "Scope must use the default Snacks scope highlight") assert(indent.scope.treesitter.enabled == true, "Scope detection must prefer Tree-sitter") assert(type(indent.scope.treesitter.blocks) == "table" and indent.scope.treesitter.blocks.enabled == false, @@ -50,13 +43,4 @@ vim.bo.filetype = "lua" assert(indent.filter(0) == true, "Indent rendering incorrectly excluded an ordinary buffer") assert(indent.scope.filter(0) == true, "Scope detection incorrectly excluded an ordinary buffer") -local picker = snacks.opts.picker -assert(picker, "Snacks picker must be configured") -assert(picker.ui_select == false, "Snacks must not take over vim.ui.select") -assert(picker.sources.files.cmd == "rg", "The files picker must not depend on a personally-installed fd") -assert(picker.sources.files.icons.files.enabled == false, "The files picker should hide the leading file icon") -assert(picker.sources.grep.icons.files.enabled == false, "The grep picker should hide the leading file icon") -assert(picker.sources.buffers.icons.files.enabled == false, "The buffers picker should hide the leading file icon") -assert(picker.sources.diagnostics.filter.cwd == false, "Diagnostics must not be limited to the cwd") - print("pinned plugin specs: OK") diff --git a/tests/fixtures/neovim/treesitter_auto_install.lua b/tests/fixtures/neovim/treesitter_auto_install.lua index ee356829..bd584da3 100644 --- a/tests/fixtures/neovim/treesitter_auto_install.lua +++ b/tests/fixtures/neovim/treesitter_auto_install.lua @@ -1,10 +1,4 @@ --- Exercises config.autocmds' FileType-driven parser auto-install: --- nvim-treesitter 1.0+ dropped ensure_installed/auto_install, so a missing --- parser is installed on the first open of its filetype. --- --- Installed-ness is judged only by get_installed("parsers"), never by --- start() succeeding: start() can fail on a broken query, which is unrelated --- to a missing parser and out of scope here. +-- Parser presence, not start() success, determines whether installation is needed. --- @param name string --- @param opts { start_fails: boolean?, available: string[], installed: string[], install_succeeds: boolean?, filetype: string?, mock_treesitter: boolean? } diff --git a/tests/github_actions_pins_test.bash b/tests/github_actions_pins_test.bash index 08471ba9..9cb87931 100644 --- a/tests/github_actions_pins_test.bash +++ b/tests/github_actions_pins_test.bash @@ -74,11 +74,6 @@ test_release_workflow_scopes_permissions_per_job() { done } -# `gh pr list | grep -q '^0$' && gh pr create` made the step exit non-zero -# whenever a PR was already open: under Actions' default `bash -e`, the failing -# left side of `&&` becomes the exit status. That is the steady state, so every -# scheduled run reported failure. The if/else block is extracted from the -# workflow file and run with a mocked `gh`, so this can't drift from the YAML. extract_lines_between() { local file="$1" local start_pattern="$2" @@ -161,12 +156,7 @@ EOF teardown_test_home } -# On a pull_request the "changes" job checks out the PR's own unreviewed -# copy of scripts/classify-ci-changes.sh, which gates the lifecycle e2e jobs -- -# so a PR could edit it to suppress its own coverage. The classifier is instead -# run as it exists at $BASE_SHA. This builds a throwaway repo with a trusted -# classifier at the base and a tampered one at the head, extracts the real -# run: block from ci.yml, and confirms the trusted output wins. +# Run the workflow with trusted base and tampered head classifiers; the base must win. test_ci_classification_step_uses_base_ref_classifier_not_pr_content() { local workflow="$ROOT_DIR/.github/workflows/ci.yml" local snippet repo base_sha head_sha github_output github_summary status diff --git a/tests/history_test.bash b/tests/history_test.bash index d7a0f4c7..c17f8c58 100644 --- a/tests/history_test.bash +++ b/tests/history_test.bash @@ -16,8 +16,7 @@ test_history_module_uses_persistent_extended_history() { # mismatch itself: anything printed before the marker breaks the exact # comparison below and names what went wrong. [[ "$HISTFILE" == "$HOME/.zsh_history" ]] || print -r -- "HISTFILE=$HISTFILE" - [[ "$HISTSIZE" == 10000 ]] || print -r -- "HISTSIZE=$HISTSIZE" - [[ "$SAVEHIST" == 10000 ]] || print -r -- "SAVEHIST=$SAVEHIST" + (( HISTSIZE > 0 && SAVEHIST > 0 )) || print -r -- "history storage is disabled" for option in EXTENDED_HISTORY INC_APPEND_HISTORY_TIME HIST_IGNORE_SPACE \ HIST_REDUCE_BLANKS; do [[ -o $option ]] || print -r -- "unset: $option" diff --git a/tests/managed_install_test.bash b/tests/managed_install_test.bash index 7e157ffa..87adcd7b 100755 --- a/tests/managed_install_test.bash +++ b/tests/managed_install_test.bash @@ -65,9 +65,7 @@ test_every_neovim_configuration_file_is_managed() { done < <(find "$ROOT_DIR/config/shared/nvim" -type f -print | sort) } -# `uninstall --restore` decides what to put back by walking this list, so a -# short one leaves a user's original file in place of their own and still -# reports success. The names have to be the declarations' name column entire. +# Uninstall must enumerate every declared resource to restore all user backups. test_managed_resource_names_are_the_whole_name_column() { local declared names @@ -121,7 +119,7 @@ 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" - + [[ ! -e "$XDG_CONFIG_HOME/mise/config.toml" ]] || fail "Minimal install created a developer mise config" } test_install_switches_login_shell_to_zsh() { @@ -158,6 +156,10 @@ test_developer_install_includes_neovim_configuration() { fail "Neovim options module was not installed for the developer profile" 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 developer profile" + [[ -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() { @@ -1068,6 +1070,55 @@ test_status_detects_modified_managed_file() { [[ "$status" -eq 1 ]] || fail "Changed managed file should make status fail" } +test_managed_file_replaced_by_same_content_symlink_is_preserved() { + local target="$XDG_CONFIG_HOME/selfishell/vim/vimrc" + local personal="$TEST_ROOT/personal-vimrc" + local state="$SELFISHELL_RESOURCE_STATE_DIR/vimrc.state" + local operation rc + + source "$ROOT_DIR/lib/common.sh" + source "$ROOT_DIR/lib/managed.sh" + source "$ROOT_DIR/lib/commands/status.sh" + managed_install_file vimrc "$ROOT_DIR/config/shared/vimrc" "$target" 0 1 >/dev/null + cp "$state" "$TEST_ROOT/original.state" + mv "$target" "$personal" + ln -s "$personal" "$target" + + SELFISHELL_STATUS_RESOURCE_COUNT=0 + SELFISHELL_STATUS_RESULT=0 + status_resource vimrc >"$TEST_ROOT/status" + ((SELFISHELL_STATUS_RESULT != 0)) || fail "Status accepted a replaced managed file symlink" + + for operation in install preflight uninstall; do + rc=0 + case "$operation" in + install) managed_install_file vimrc "$ROOT_DIR/config/shared/vimrc" "$target" 0 1 >/dev/null 2>&1 || rc=$? ;; + preflight) managed_validate_uninstall_resource vimrc >/dev/null 2>&1 || rc=$? ;; + uninstall) managed_uninstall_resource vimrc 1 0 >/dev/null 2>&1 || rc=$? ;; + esac + ((rc != 0)) || fail "$operation accepted a replaced managed file symlink" + assert_symlink_to "$personal" "$target" + cmp -s "$personal" "$ROOT_DIR/config/shared/vimrc" || fail "$operation changed the personal file" + cmp -s "$state" "$TEST_ROOT/original.state" || fail "$operation changed resource state" + done +} + +test_minimal_profile_keeps_retained_developer_configuration_visible() { + local target="$XDG_CONFIG_HOME/selfishell/nvim/init.lua" + local output rc=0 + + run_selfishell install --profile developer --skip-packages --yes >/dev/null + run_selfishell install --profile minimal --skip-packages --yes >/dev/null + assert_file_content minimal "$SELFISHELL_STATE_DIR/profile" + 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" + + printf '\n-- personal edit\n' >>"$target" + output="$(run_selfishell status 2>&1)" || rc=$? + ((rc != 0)) || fail "Status ignored modified retained developer configuration" + [[ "$output" == *"[CHANGED] $target"* ]] || fail "Status omitted retained Neovim configuration: $output" +} + test_status_uses_current_resource_list() { local output @@ -1297,50 +1348,40 @@ test_install_does_not_depend_on_checkout() { fail "Zsh configuration depended on the removed checkout" } -test_mise_config_global_creation_and_no_state() { - run_selfishell install --profile developer --skip-packages --yes >/dev/null - [[ -f "$XDG_CONFIG_HOME/mise/config.toml" ]] || fail "config.toml was not created on developer install" - [[ ! -f "$XDG_STATE_HOME/selfishell/resources/mise-config-global.state" ]] || fail "mise-config-global state should not exist" -} - -test_mise_config_global_minimal_profile() { - run_selfishell install --profile minimal --skip-packages --yes >/dev/null - [[ ! -e "$XDG_CONFIG_HOME/mise/config.toml" ]] || fail "config.toml should not be created for minimal profile" -} - test_mise_config_global_preserves_existing_types() { - # 일반 파일 + source "$ROOT_DIR/lib/common.sh" + source "$ROOT_DIR/lib/commands/install.sh" + + # Exercise the create-once boundary directly; the developer/minimal + # installation tests above cover command wiring. mkdir -p "$XDG_CONFIG_HOME/mise" printf 'user_owned_data_content_bytes\n' >"$XDG_CONFIG_HOME/mise/config.toml" - run_selfishell install --profile developer --skip-packages --yes >/dev/null + install_mise_global_config 0 >/dev/null assert_file_content 'user_owned_data_content_bytes' "$XDG_CONFIG_HOME/mise/config.toml" - # 일반 symlink rm -f "$XDG_CONFIG_HOME/mise/config.toml" printf 'link_target_content\n' >"$TEST_ROOT/real_config.toml" ln -s "$TEST_ROOT/real_config.toml" "$XDG_CONFIG_HOME/mise/config.toml" - run_selfishell install --profile developer --skip-packages --yes >/dev/null + install_mise_global_config 0 >/dev/null assert_symlink_to "$TEST_ROOT/real_config.toml" "$XDG_CONFIG_HOME/mise/config.toml" + assert_file_content 'link_target_content' "$TEST_ROOT/real_config.toml" - # symlink-to-directory rm -f "$XDG_CONFIG_HOME/mise/config.toml" mkdir -p "$TEST_ROOT/some_dir" ln -s "$TEST_ROOT/some_dir" "$XDG_CONFIG_HOME/mise/config.toml" - run_selfishell install --profile developer --skip-packages --yes >/dev/null + install_mise_global_config 0 >/dev/null assert_symlink_to "$TEST_ROOT/some_dir" "$XDG_CONFIG_HOME/mise/config.toml" - # symlink-to-special (dangling) + # Removing the referent leaves a dangling link, which is still user data. rm -rf "$TEST_ROOT/some_dir" - run_selfishell install --profile developer --skip-packages --yes >/dev/null - [[ -L "$XDG_CONFIG_HOME/mise/config.toml" ]] || fail "dangling symlink was removed" - [[ "$(readlink "$XDG_CONFIG_HOME/mise/config.toml")" == "$TEST_ROOT/some_dir" ]] || fail "dangling symlink target changed" + install_mise_global_config 0 >/dev/null + assert_symlink_to "$TEST_ROOT/some_dir" "$XDG_CONFIG_HOME/mise/config.toml" + [[ ! -e "$SELFISHELL_RESOURCE_STATE_DIR" ]] || fail "Create-once file acquired managed state" } test_mise_config_global_idempotency_and_status() { local tool - # nvim, not neovim: tool_status_executable() maps the mise package name - # "neovim" to its real executable "nvim" (same as "ripgrep" -> "rg" - # below), so status's fallback have_command check looks for that binary. + # Mock executable names, not mise package names. for tool in zsh git curl ca-certificates vim starship fzf zoxide rg jq build-essential mise nvim tree-sitter node python uv gh; do printf '#!/usr/bin/env bash\nexit 0\n' >"$TEST_ROOT/bin/$tool" chmod +x "$TEST_ROOT/bin/$tool" @@ -1348,7 +1389,11 @@ test_mise_config_global_idempotency_and_status() { mkdir -p "$HOME/.local/share/zinit/zinit.git" touch "$HOME/.local/share/zinit/zinit.git/zinit.zsh" + mkdir -p "$XDG_CONFIG_HOME/mise" + printf 'pre-existing user config\n' >"$XDG_CONFIG_HOME/mise/config.toml" run_selfishell install --profile developer --skip-packages --yes >/dev/null + assert_file_content 'pre-existing user config' "$XDG_CONFIG_HOME/mise/config.toml" + cp "$XDG_CONFIG_HOME/selfishell/mise/selfishell.toml" "$TEST_ROOT/defaults.before" printf 'modified by user 123\n' >"$XDG_CONFIG_HOME/mise/config.toml" run_selfishell install --profile developer --skip-packages --yes >/dev/null assert_file_content 'modified by user 123' "$XDG_CONFIG_HOME/mise/config.toml" @@ -1358,23 +1403,16 @@ test_mise_config_global_idempotency_and_status() { status_out="$(run_selfishell status 2>&1)" || status=$? ((status == 0)) || fail "status failed after user modified config.toml (exit code $status)" [[ "$status_out" != *'config.toml'* ]] || fail "user-owned config.toml should not be reported by status" + cmp -s "$TEST_ROOT/defaults.before" "$XDG_CONFIG_HOME/selfishell/mise/selfishell.toml" || + fail "Editing user configuration changed managed defaults" + run_selfishell uninstall --restore --yes >/dev/null + assert_file_content 'modified by user 123' "$XDG_CONFIG_HOME/mise/config.toml" } test_mise_config_global_uninstall_preservation() { run_selfishell install --profile developer --skip-packages --yes >/dev/null run_selfishell uninstall --restore --yes >/dev/null [[ -f "$XDG_CONFIG_HOME/mise/config.toml" ]] || fail "config.toml should remain after uninstall" - - mkdir -p "$XDG_CONFIG_HOME/mise" - printf 'pre_existing_data\n' >"$XDG_CONFIG_HOME/mise/config.toml" - run_selfishell install --profile developer --skip-packages --yes >/dev/null - run_selfishell uninstall --restore --yes >/dev/null - assert_file_content 'pre_existing_data' "$XDG_CONFIG_HOME/mise/config.toml" - - : >"$XDG_CONFIG_HOME/mise/config.toml" - run_selfishell install --profile developer --skip-packages --yes >/dev/null - run_selfishell uninstall --restore --yes >/dev/null - [[ -f "$XDG_CONFIG_HOME/mise/config.toml" ]] || fail "empty config.toml was deleted on uninstall" } test_mise_config_global_dry_run_and_directory_error() { @@ -1432,14 +1470,6 @@ EOF )" || fail "runtime created MISE_GLOBAL_CONFIG_FILE" } -test_mise_global_config_ownership() { - run_selfishell install --profile developer --skip-packages --yes >/dev/null - printf 'node = "24"\n' >>"$XDG_CONFIG_HOME/mise/config.toml" - local selfishell_toml_content - selfishell_toml_content="$(<"$XDG_CONFIG_HOME/selfishell/mise/selfishell.toml")" - [[ "$selfishell_toml_content" != *'node = "24"'* ]] || fail "Selfishell default configuration was mutated by user global config write" -} - # A real `update` reaches packages_install_profile, which must not touch the # network or need root here. Faking apt-get/dpkg satisfies the apt check # without sudo, and pre-creating the direct dependency targets makes @@ -1504,9 +1534,7 @@ EOF setup_fake_zinit } -# Copies the checkout into its own root so a test can change a managed -# resource's *source* file (to simulate a new Selfishell release) without -# mutating the real repository under test. +# Use a private checkout to simulate release changes without modifying the test source. build_release_copy() { local release_root="$1" @@ -1516,6 +1544,39 @@ build_release_copy() { cp "$ROOT_DIR/dependencies.conf" "$release_root/dependencies.conf" } +test_reinstall_preserves_tool_caches_until_generator_configuration_changes() { + local release_root="$TEST_ROOT/release" + local cache_dir="$XDG_CACHE_HOME/selfishell" + local scenario tool + + build_release_copy "$release_root" + bash "$release_root/bin/selfishell" install --profile minimal --skip-packages --yes >/dev/null + mkdir -p "$cache_dir" + for tool in zoxide fzf starship; do + printf '# cached %s init\n' "$tool" >"$cache_dir/$tool-init.zsh" + done + + for scenario in unchanged unrelated dry-run changed; do + case "$scenario" in + unrelated) printf '\nset noshowmode\n' >>"$release_root/config/shared/vimrc" ;; + dry-run) printf '\n# updated generator\n' >>"$release_root/config/shared/zsh/interactive.zsh" ;; + esac + if [[ "$scenario" == dry-run ]]; then + bash "$release_root/bin/selfishell" install --profile minimal --skip-packages --yes --dry-run >/dev/null + else + bash "$release_root/bin/selfishell" install --profile minimal --skip-packages --yes >/dev/null + fi + for tool in zoxide fzf starship; do + if [[ "$scenario" == changed ]]; then + [[ ! -e "$cache_dir/$tool-init.zsh" ]] || fail "$tool cache survived a generator change" + else + [[ -f "$cache_dir/$tool-init.zsh" ]] || fail "$scenario install removed $tool cache" + [[ "$(<"$cache_dir/$tool-init.zsh")" == "# cached $tool init" ]] || fail "$scenario install rewrote $tool cache" + fi + done + done +} + test_managed_file_interactive_overwrite_yes() { run_selfishell install --profile minimal --skip-packages --yes >/dev/null @@ -1745,9 +1806,7 @@ test_managed_file_overwrite_conflict_atomic_copy_failure_preserves_backup_and_st local target_file="$XDG_CONFIG_HOME/selfishell/vim/vimrc" local state_file="$XDG_STATE_HOME/selfishell/resources/vimrc.state" local saved_state="$TEST_ROOT/vimrc.state.before" - # A dedicated directory, not $TEST_ROOT/bin: that one is permanently on - # PATH for the whole test (it holds the fake chsh from setup_managed_home), - # so a fake `cp` planted there would still shadow the real one on retry. + # Keep the failing command off the persistent test PATH so retry uses the real one. local fake_bin="$TEST_ROOT/fakebin" local status=0 @@ -1791,9 +1850,7 @@ EOF test_managed_link_ln_failure_restores_preexisting_regular_file() { local link_path="$XDG_CONFIG_HOME/starship.toml" local state_file="$XDG_STATE_HOME/selfishell/resources/user-starship.state" - # A dedicated directory, not $TEST_ROOT/bin: that one is permanently on - # PATH for the whole test (it holds the fake chsh from setup_managed_home), - # so a fake `ln` planted there would still shadow the real one on retry. + # Keep the failing command off the persistent test PATH so retry uses the real one. local fake_bin="$TEST_ROOT/fakebin" local status=0 @@ -1974,36 +2031,78 @@ test_block_install_failure_cleans_up_temporary_files() { fail "A failed block install must not be recorded as active" } +test_block_splice_preserves_surrounding_bytes_and_permissions() { + local target="$HOME/block-target" + local prefix="$TEST_ROOT/prefix" suffix="$TEST_ROOT/suffix" + local expected="$TEST_ROOT/expected" + + source "$ROOT_DIR/lib/common.sh" + source "$ROOT_DIR/lib/managed.sh" + # Cross copy-buffer boundaries with multibyte text, NULs, CRLF, and no final newline. + awk 'BEGIN { for (i = 0; i < 8192; i++) printf "personal config\r\n" }' >"$prefix" + printf '앞\000뒤\n' >>"$prefix" + cp "$prefix" "$suffix" + printf 'no final newline' >>"$suffix" + { + cat "$prefix" + printf 'old block\n' + cat "$suffix" + } >"$target" + chmod 640 "$target" + MANAGED_BLOCK_START="$(wc -c <"$prefix")" + MANAGED_BLOCK_LENGTH=10 + + managed_splice_block "$target" user-vimrc + { + cat "$prefix" + managed_block_content user-vimrc + cat "$suffix" + } >"$expected" + cmp -s "$expected" "$target" || fail "Block replacement changed surrounding bytes" + MANAGED_BLOCK_LENGTH="$(managed_block_content user-vimrc | wc -c)" + managed_splice_block "$target" + cat "$prefix" "$suffix" >"$expected" + cmp -s "$expected" "$target" || fail "Block removal changed surrounding bytes" + [[ "$(find "$target" -prune -perm 640 -print)" == "$target" ]] || fail "Block splicing changed file permissions" +} + test_block_remove_failure_cleans_up_temporary_files() { local target="$HOME/.zshrc" local state_file="$XDG_STATE_HOME/selfishell/resources/user-zshrc.state" - local before_checksum - local status=0 - local tmp_count + local before_checksum reader status tmp_count printf 'original zshrc\n' >"$target" run_selfishell install --skip-packages --yes >/dev/null + { + printf 'personal prefix\n' + cat "$target" + } >"$TEST_ROOT/before-zshrc" + cp "$TEST_ROOT/before-zshrc" "$target" before_checksum="$(sed -n '7p' "$state_file")" - set +e - bash -c ' - source "$1/lib/common.sh" - source "$1/lib/paths.sh" - selfishell_initialize_paths - source "$1/lib/managed.sh" - dd() { return 1; } - managed_read_state user-zshrc - managed_remove_block user-zshrc "$2" - ' _ "$ROOT_DIR" "$target" >/dev/null 2>"$TEST_ROOT/stderr" - status=$? - set -e - - [[ "$status" -ne 0 ]] || fail "A forced dd failure during block removal should propagate as an error" - tmp_count="$(find "$HOME" -maxdepth 1 -name '.zshrc.tmp.*' | wc -l)" - [[ "$tmp_count" -eq 0 ]] || fail "A failed block removal left a temporary file behind" - grep -Fqx '# >>> Selfishell initialize >>>' "$target" || fail "A failed block removal altered the managed block" - [[ "$(sed -n '7p' "$state_file")" == "$before_checksum" ]] || - fail "A failed block removal must not change resource state" + for reader in dd head tail; do + status=0 + bash -c ' + source "$1/lib/common.sh" + source "$1/lib/paths.sh" + selfishell_initialize_paths + source "$1/lib/managed.sh" + case "$3" in + dd) dd() { return 1; } ;; + head) head() { return 1; } ;; + tail) tail() { return 1; } ;; + esac + managed_read_state user-zshrc + managed_remove_block user-zshrc "$2" + ' _ "$ROOT_DIR" "$target" "$reader" >/dev/null 2>"$TEST_ROOT/stderr" || status=$? + + [[ "$status" -ne 0 ]] || fail "A forced $reader failure during block removal should propagate as an error" + tmp_count="$(find "$HOME" -maxdepth 1 -name '.zshrc.tmp.*' | wc -l)" + [[ "$tmp_count" -eq 0 ]] || fail "A failed block removal left a temporary file behind" + cmp -s "$TEST_ROOT/before-zshrc" "$target" || fail "A failed block removal changed user bytes" + [[ "$(sed -n '7p' "$state_file")" == "$before_checksum" ]] || + fail "A failed block removal must not change resource state" + done } test_block_install_chmod_failure_leaves_no_target_or_state() { diff --git a/tests/neovim_config_test.bash b/tests/neovim_config_test.bash index 081bb793..53892fb9 100644 --- a/tests/neovim_config_test.bash +++ b/tests/neovim_config_test.bash @@ -59,9 +59,7 @@ test_treesitter_auto_installs_missing_parsers_on_filetype() { test_every_neovim_plugin_has_an_approved_revision() { local repository revision declared_plugins configured_plugins diff_output - # lazy.nvim bootstraps itself in config/lazy.lua rather than being declared - # via plugin(...), so it is pinned without a matching Lua declaration and is - # checked separately below. + # config/lazy.lua loads the preinstalled lazy.nvim outside plugin(...) declarations. declared_plugins="$(sed -n 's/.*plugin("\([^"]*\)".*/\1/p' "$ROOT_DIR"/config/shared/nvim/lua/plugins/*.lua | sort -u)" configured_plugins="$(awk '$1 == "nvim-plugin" && $2 != "folke/lazy.nvim" { print $2 }' "$ROOT_DIR/dependencies.conf" | sort -u)" diff --git a/tests/profiles_test.bash b/tests/profiles_test.bash index 83ece8bd..964d98d0 100755 --- a/tests/profiles_test.bash +++ b/tests/profiles_test.bash @@ -70,11 +70,7 @@ test_developer_includes_development_tools() { output="$(run_profile_dry_run developer)" full_output="$(bash "$ROOT_DIR/bin/selfishell" install --profile developer --dry-run)" - # developer.conf declares membership, mise.toml owns exact versions; a name - # in both is different responsibilities, not duplication. Expectations come - # from mise.toml because developer.conf also produces $output, which would - # make this self-referential. Compared as a sorted set since the two files' - # orders differ -- still catching a missing or extra tool either way. + # Compare membership against the independently parsed mise.toml; order is irrelevant. expected_mise_tools="$(awk ' /^\[/ { in_tools = ($0 == "[tools]"); next } in_tools && NF >= 3 { print $1 } diff --git a/tests/tool_status_test.bash b/tests/tool_status_test.bash index 26cef849..0ed585dd 100644 --- a/tests/tool_status_test.bash +++ b/tests/tool_status_test.bash @@ -144,21 +144,37 @@ test_maps_package_name_to_executable() { fail "mise selector was not mapped to its executable" } -# profiles/*.conf declares mise packages by bare tool name only (no -# @version); the approved version must come from config/shared/mise.toml, not be -# parsed out of that bare name. -test_detects_mise_tool_version() { +setup_mise_inventory() { cat >"$TEST_ROOT/bin/mise" <<'EOF' #!/usr/bin/env bash -[[ "$*" == 'current node' ]] || exit 1 +printf '%s\n' "$*" >>"$HOME/mise-calls" +[[ "$*" == current ]] || exit 1 [[ "$MISE_GLOBAL_CONFIG_FILE" == "$SELFISHELL_CONFIG_DIR/mise/selfishell.toml" ]] || exit 1 -printf '24.18.0\n' +cat "$HOME/mise-inventory" +[[ ! -f "$HOME/mise-fail" ]] EOF chmod +x "$TEST_ROOT/bin/mise" export SELFISHELL_CONFIG_DIR export SELFISHELL_ROOT="$TEST_ROOT/selfishell-root" mkdir -p "$SELFISHELL_ROOT/config/shared" - printf '[tools]\nnode = "24.18.0"\n' >"$SELFISHELL_ROOT/config/shared/mise.toml" + cat >"$SELFISHELL_ROOT/config/shared/mise.toml" <<'EOF' +[tools] +node = "24.18.0" +python = "3.13.14" +neovim = "0.12.5" +tree-sitter = "0.27.0" +uv = "0.12.13" +gh = "2.100.0" + +[settings] +node = "ignored" +EOF + printf 'node 24.18.0\npython 3.13.14 3.12.0\n' >"$HOME/mise-inventory" +} + +# Approved mise versions come from mise.toml, not the profile's bare tool names. +test_detects_mise_tool_version() { + setup_mise_inventory tool_status_detect mise node linux amd64 @@ -167,4 +183,70 @@ EOF [[ "$TOOL_STATUS_APPROVED" == 24.18.0 ]] || fail "mise approved version was not read from config/shared/mise.toml" } +test_reuses_mise_inventory_and_approved_versions_until_reset() { + local tool expected + setup_mise_inventory + tool_status_detect mise node linux amd64 + + printf 'python 3.14.0\n' >"$HOME/mise-inventory" + printf '[tools]\npython = "3.14.0"\n' >"$SELFISHELL_ROOT/config/shared/mise.toml" + tool_status_detect mise python linux amd64 + [[ "$TOOL_STATUS_INSTALLED" == '3.13.14 3.12.0' && "$TOOL_STATUS_SOURCE" == mise ]] || + fail "Cached mise inventory lost multiple versions or was reloaded" + [[ "$TOOL_STATUS_APPROVED" == 3.13.14 ]] || fail "Approved mise versions were reloaded before reset" + while read -r tool expected; do + tool_status_detect mise "$tool" linux amd64 + [[ "$TOOL_STATUS_APPROVED" == "$expected" ]] || fail "Wrong approved version for $tool" + done <<'EOF' +neovim 0.12.5 +tree-sitter 0.27.0 +uv 0.12.13 +gh 2.100.0 +EOF + [[ "$(wc -l <"$HOME/mise-calls" | tr -d ' ')" == 1 ]] || fail "mise inventory should be loaded once" + + tool_status_reset_cache + tool_status_detect mise python linux amd64 + [[ "$TOOL_STATUS_INSTALLED" == 3.14.0 && "$TOOL_STATUS_APPROVED" == 3.14.0 ]] || + fail "Reset did not refresh both mise inventories" + [[ "$(wc -l <"$HOME/mise-calls" | tr -d ' ')" == 2 ]] || fail "Reset did not reload mise inventory once" +} + +test_mise_inventory_missing_and_failed_queries_use_executable_fallback() { + setup_mise_inventory + # This test runs in isolation; keep its missing-tool case independent of CI's PATH. + have_command() { + [[ "$1" != gh ]] && command -v "$1" >/dev/null 2>&1 + } + printf '#!/usr/bin/env bash\nexit 0\n' >"$TEST_ROOT/bin/uv" + chmod +x "$TEST_ROOT/bin/uv" + tool_status_detect mise uv linux amd64 + [[ "$TOOL_STATUS_INSTALLED" == detected && "$TOOL_STATUS_SOURCE" == external ]] || + fail "Tool absent from mise inventory did not use executable fallback" + tool_status_detect mise gh linux amd64 + [[ "$TOOL_STATUS_INSTALLED" == missing && "$TOOL_STATUS_SOURCE" == none ]] || + fail "Tool absent from mise inventory was not reported missing" + + touch "$HOME/mise-fail" + printf 'uv 0.12.13\ngh 2.100.0\n' >"$HOME/mise-inventory" + tool_status_reset_cache + tool_status_detect mise uv linux amd64 + [[ "$TOOL_STATUS_INSTALLED" == detected && "$TOOL_STATUS_SOURCE" == external ]] || + fail "Failed mise query did not discard partial output and use executable fallback" + tool_status_detect mise gh linux amd64 + [[ "$TOOL_STATUS_INSTALLED" == missing && "$TOOL_STATUS_SOURCE" == none && "$TOOL_STATUS_APPROVED" == 2.100.0 ]] || + fail "Failed mise query did not preserve missing status and approved version" + [[ "$(wc -l <"$HOME/mise-calls" | tr -d ' ')" == 2 ]] || fail "Failed mise inventory was queried again before reset" +} + +test_mise_inventory_uses_managed_mise_outside_path() { + setup_mise_inventory + mkdir -p "$HOME/.local/bin" + mv "$TEST_ROOT/bin/mise" "$HOME/.local/bin/mise" + + tool_status_detect mise node linux amd64 + [[ "$TOOL_STATUS_INSTALLED" == 24.18.0 && "$TOOL_STATUS_SOURCE" == mise ]] || + fail "Managed mise outside PATH was not used for inventory" +} + run_discovered_tests setup_tool_status_home teardown_tool_status_home diff --git a/tests/updates_test.bash b/tests/updates_test.bash index ef0130cc..7916c25a 100644 --- a/tests/updates_test.bash +++ b/tests/updates_test.bash @@ -420,11 +420,7 @@ test_download_dependency_replaces_directory_target_without_nesting() { export SELFISHELL_DEPENDENCIES_FILE="$TEST_ROOT/dependencies.conf" printf 'download tool 1.0 linux amd64 file://%s %s .local/bin/tool raw\n' "$payload" "$checksum" >"$SELFISHELL_DEPENDENCIES_FILE" - # A target replaced by a directory while its recorded version still matches: - # `mv` renames *into* an existing directory, leaving the approved binary - # unreachable while reporting success. The matching version also makes this - # cover dependency_managed_target_is_valid rejecting a directory-shaped - # target rather than taking the Up to date fast path. + # A directory at the same recorded version must be replaced, not nested into by mv. mkdir -p "$XDG_STATE_HOME/selfishell/dependencies" printf '1.0\n' >"$XDG_STATE_HOME/selfishell/dependencies/tool" mkdir -p "$HOME/.local/bin/tool"