diff --git a/Brewfile b/Brewfile index 5418c2e..19bd59d 100644 --- a/Brewfile +++ b/Brewfile @@ -113,7 +113,7 @@ brew "neovim" # emacs-plus over core emacs: native-comp, tree-sitter, and a real macOS GUI, # all on by default in this formula -- do not pass args, they disable bottles. # Config lives at ~/.config/emacs (see: make setup-emacs). -brew "d12frosted/emacs-plus/emacs-plus@30" +brew "d12frosted/emacs-plus/emacs-plus@31" brew "autoconf" brew "automake" brew "libtool" diff --git a/CLAUDE.md b/CLAUDE.md index 6c4254b..745bcb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,12 +135,18 @@ Authoritative source for both is the shipped Zed theme JSON (`~/Library/Applicat ## Emacs -Vanilla Emacs 30 (`emacs-plus@30`) at `~/.config/emacs`, source `home/private_dot_config/emacs/`. Runs as a daemon under `brew services`; `e` and `eg` (in `aliases/dev.zsh`) open terminal and GUI frames. `EDITOR` stays `nvim`. +Vanilla Emacs 31 (`emacs-plus@31`) at `~/.config/emacs`, source `home/private_dot_config/emacs/`. Runs as a daemon under `brew services`; `e` and `eg` (in `aliases/dev.zsh`) open terminal and GUI frames. `EDITOR` stays `nvim`. + +`31.0.91` is a **pretest** off the `emacs-31` branch, not a release -- the tap's `emacs-plus@32` (`32.0.50`) is master, not a newer stable. The tap is untrusted by default under current Homebrew, so a fresh machine needs `brew trust d12frosted/emacs-plus` before the formula will even load. Layout: `early-init.el.tmpl` (pre-frame), `init.el`, `lisp/droo-{defaults,ui,completion,git,lang,lsp}.el`, `themes/synthwave84-soft-theme.el.tmpl`, `banner.txt`. Only the two `.tmpl` files interpolate -- colors live solely in the theme. -Four things that are easy to get wrong: +Six things that are easy to get wrong: +- **Upgrading the formula does not change which Emacs runs.** `emacs-plus`'s `bin/emacs` is a 5-line wrapper that execs the first of `/Applications/Emacs.app`, `~/Applications/Emacs.app`, then its own keg. The `brew services` block runs that wrapper, so with a stale `/Applications/Emacs.app` the `@31` service happily launches Emacs 30 -- `emacs --version` reports the old version from the new keg, which reads like a broken build. Replace the app bundle too (`ditto /Emacs.app /Applications/Emacs.app`), then `brew unlink emacs-plus@ && brew link emacs-plus@`. +- **A major-version upgrade invalidates every `.elc`.** `define-minor-mode` expands differently across versions, so packages compiled by the old Emacs fail at runtime with things like `Symbol's value as variable is void: corfu-mode--set-explicitly`. Fix with `package-recompile-all` (not `byte-recompile-directory`, which has no package load-path in `-Q` and fails most files): + `emacs --batch -l ~/.config/emacs/early-init.el --eval '(progn (require (quote package)) (package-initialize) (package-recompile-all))'` + Tree-sitter grammars survive a library bump and do not need rebuilding; `make emacs-grammars` reports them "already available", which is itself a load test. - **`~/.emacs.d` silently wins.** `startup--xdg-or-homedot` (`startup.el`) returns `~/.emacs.d` whenever that directory merely _exists_ -- it never checks for an `init.el`. If it reappears, the entire XDG config is ignored with no error. `make doctor` fails on this, and `setup-emacs.sh` offers to trash it. - **Runtime state must stay out of the config tree.** `~/.config/emacs` is chezmoi-managed, so anything Emacs writes there becomes `chezmoi verify` drift. `early-init.el` redirects `package-user-dir`, the eln cache, `custom-file`, and grammars to XDG data/cache/state. This is why no `home/.chezmoiignore` was needed -- adding one would newly activate as chezmoi's real ignore file. - **The daemon does not inherit mise.** mise activates from `.zshrc`, which `exec-path-from-shell -l` never sources, so mise-managed servers (`ruff`, `rust-analyzer`) are invisible. `droo-defaults.el` adds `~/.local/share/mise/shims` to `exec-path` explicitly. diff --git a/home/private_dot_claude/hooks/executable_block-rm-rf.sh b/home/private_dot_claude/hooks/executable_block-rm-rf.sh index 0e59983..de5863e 100644 --- a/home/private_dot_claude/hooks/executable_block-rm-rf.sh +++ b/home/private_dot_claude/hooks/executable_block-rm-rf.sh @@ -1,23 +1,89 @@ #!/usr/bin/env bash -# Block rm -rf commands and suggest trash instead +# Block recursive-force rm and suggest trash instead. +# +# Flags are attributed to the rm they actually belong to. An earlier version +# matched the bare string "rm" against the whole command, so it fired on any +# word ending in rm -- terraform, confirm, perform, form -- and scanned for +# -r/-f across unrelated commands, blocking things like "rm a.txt && grep -r -f". set -euo pipefail command=$(jq -r '.tool_input.command // empty') +[[ -n "$command" ]] || exit 0 -# Match rm with both -r and -f flags in any form: -# - rm -rf, rm -fr (combined) -# - rm -r -f, rm -f -r (separate) -# - rm --recursive --force, rm --force --recursive (long form) -# - xargs rm -rf (piped) -if [[ "$command" =~ rm[[:space:]]+-[^[:space:]]*r[^[:space:]]*f ]] || - [[ "$command" =~ rm[[:space:]]+-[^[:space:]]*f[^[:space:]]*r ]] || - [[ "$command" =~ rm[[:space:]].*-r.*-f ]] || - [[ "$command" =~ rm[[:space:]].*-f.*-r ]] || - [[ "$command" =~ rm[[:space:]].*--recursive.*--force ]] || - [[ "$command" =~ rm[[:space:]].*--force.*--recursive ]] || - [[ "$command" =~ xargs[[:space:]].*rm ]]; then - echo "Blocked: rm -rf is destructive. Use 'trash' instead (brew install macos-trash)." >&2 - exit 2 -fi +# Words that may precede the real command without changing what it is. +is_wrapper() { + case "$1" in + sudo | doas | env | time | command | builtin | exec) return 0 ;; + nohup | nice | ionice | stdbuf) return 0 ;; + *=*) return 0 ;; # VAR=value prefix + *) return 1 ;; + esac +} + +# True when the segment runs rm with both a recursive and a force flag, or +# reaches rm through xargs -- there the file list is generated elsewhere, so the +# blast radius is not visible here. +segment_is_dangerous() { + local seg="$1" + local -a tok=() + local i=0 n via_xargs=0 has_r=0 has_f=0 arg + + # Quotes are dropped so `bash -c "rm -rf /"` is inspected, not waved through. + seg="${seg//\"/}" + seg="${seg//\'/}" + + read -r -a tok <<<"$seg" || true + n=${#tok[@]} + + while [[ $i -lt $n ]]; do + if is_wrapper "${tok[i]}"; then + i=$((i + 1)) + elif [[ ${tok[i]} == xargs ]]; then + via_xargs=1 + i=$((i + 1)) + while [[ $i -lt $n && ${tok[i]} == -* ]]; do i=$((i + 1)); done + elif [[ ${tok[i]} =~ ^(bash|sh|zsh|dash|ksh)$ ]]; then + i=$((i + 1)) + if [[ $i -lt $n && ${tok[i]} == -c ]]; then i=$((i + 1)); fi + else + break + fi + done + + [[ $i -lt $n ]] || return 1 + # Strip any leading path so /bin/rm is still rm. + [[ ${tok[i]##*/} == rm ]] || return 1 + i=$((i + 1)) + + [[ $via_xargs -eq 1 ]] && return 0 + + for ((; i < n; i++)); do + arg="${tok[i]}" + # Everything after -- is a filename, even if it is shaped like a flag. + [[ $arg == -- ]] && break + case "$arg" in + --recursive) has_r=1 ;; + --force) has_f=1 ;; + --*) ;; + -*) + if [[ $arg == *[rR]* ]]; then has_r=1; fi + if [[ $arg == *f* ]]; then has_f=1; fi + ;; + esac + done + + [[ $has_r -eq 1 && $has_f -eq 1 ]] +} + +# Split on shell separators so each segment holds at most one command. +separators=';&|(){}`' +while IFS= read -r segment; do + [[ -n "${segment//[[:space:]]/}" ]] || continue + if segment_is_dangerous "$segment"; then + printf 'Blocked: %s\n' "${segment#"${segment%%[![:space:]]*}"}" >&2 + printf "rm -rf is destructive. Use 'trash' instead (brew install macos-trash).\n" >&2 + exit 2 + fi +done < <(printf '%s\n' "$command" | tr "$separators" '\n') exit 0 diff --git a/home/private_dot_config/emacs/init.el b/home/private_dot_config/emacs/init.el index e24b22e..fdcf39a 100644 --- a/home/private_dot_config/emacs/init.el +++ b/home/private_dot_config/emacs/init.el @@ -4,7 +4,7 @@ ;;; Commentary: -;; Vanilla Emacs 30 with package.el and the built-in use-package. Runtime state +;; Vanilla Emacs 31 with package.el and the built-in use-package. Runtime state ;; lives under XDG directories (see early-init.el); this tree stays read-only as ;; far as Emacs is concerned. ;; diff --git a/home/private_dot_config/emacs/lisp/droo-ui.el b/home/private_dot_config/emacs/lisp/droo-ui.el index d586450..98fb71d 100644 --- a/home/private_dot_config/emacs/lisp/droo-ui.el +++ b/home/private_dot_config/emacs/lisp/droo-ui.el @@ -86,7 +86,9 @@ Monaspace installed still gets a usable Emacs rather than a broken one." (use-package dashboard :init - (setq dashboard-banner-logo-title "GNU Emacs 30 -- Synthwave84 Soft" + ;; Derived, not hardcoded -- the literal drifted behind the installed Emacs. + (setq dashboard-banner-logo-title + (format "GNU Emacs %d -- Synthwave84 Soft" emacs-major-version) dashboard-startup-banner (expand-file-name "banner.txt" user-emacs-directory) dashboard-center-content t dashboard-vertically-center-content t diff --git a/scripts/setup/setup-emacs.sh b/scripts/setup/setup-emacs.sh index 2be987b..eafb1b4 100755 --- a/scripts/setup/setup-emacs.sh +++ b/scripts/setup/setup-emacs.sh @@ -7,7 +7,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DOTFILES_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" EMACS_CONFIG_DIR="$HOME/.config/emacs" EMACS_DOTDIR="$HOME/.emacs.d" -BREW_FORMULA="emacs-plus@30" +BREW_FORMULA="emacs-plus@31" # Source centralized logging # shellcheck source=../utils/logging.sh