diff --git a/CHANGELOG.md b/CHANGELOG.md index 0163f66..7588e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.1] - 2026-05-03 + +### Fixed +- `_install_fzf`: skipped symlink reconciliation when `~/.fzf` already + existed, so re-runs over partial state left `~/.local/bin/fzf` missing + and an older system fzf could win PATH lookup — breaking modern + `~/.fzf.zsh` integration with "unknown option: --zsh" on every shell + startup. Now always reconciles the symlink. +- `update.sh` fzf section: same self-heal — reconciles the symlink after + every update, fixing existing broken installs from older dotfiles + versions without requiring a full re-install. + +### Added +- `test.sh`: regression guard verifying `~/.local/bin/fzf` symlink target — + fails CI if the symlink reconciliation is ever removed from the installer. + ## [1.4.0] - 2026-04-13 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 8448a24..6ee3940 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -277,10 +277,8 @@ warns and bails rather than creating a link inside it. ## fzf integration fzf is installed via **git clone** to `~/.fzf/` (not apt, not a binary release). -The installer generates `~/.fzf.zsh` which adds `~/.fzf/bin` to PATH and registers -`Ctrl+T`/`Ctrl+R`/`Alt+C` bindings. - -`~/.fzf.zsh` is sourced **explicitly** in `.zshrc`: +The installer generates `~/.fzf.zsh` which contains `source <(fzf --zsh)` (modern +fzf integration, fzf ≥ 0.48) and is sourced **explicitly** in `.zshrc`: ```bash [[ -f ~/.fzf.zsh ]] && source ~/.fzf.zsh ``` @@ -288,6 +286,14 @@ The oh-my-zsh `fzf` plugin was removed — this explicit source line is the **on thing that activates fzf shell integration. Do not remove it. Do not re-add the oh-my-zsh `fzf` plugin without removing this line first. +**Load-bearing invariant**: `~/.local/bin/fzf` must symlink to `~/.fzf/bin/fzf`. +Because `~/.fzf.zsh` calls `fzf --zsh` at shell startup, an older system fzf at +`/usr/local/bin/fzf` or `/usr/bin/fzf` shadowing the managed install will print +`unknown option: --zsh` on every login. `_install_fzf` in `modules/base.sh` and +the fzf section of `update.sh` both **always reconcile this symlink** (idempotent +`ln -sf` after the existence check), even when the clone is already present — +so re-runs over partial state self-heal. `test.sh` carries a regression guard. + --- ## Global flags diff --git a/VERSION b/VERSION index 88c5fb8..347f583 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.4.1 diff --git a/modules/base.sh b/modules/base.sh index c47cd18..e1d083c 100755 --- a/modules/base.sh +++ b/modules/base.sh @@ -408,20 +408,21 @@ _install_jq() { } # fzf: install via git clone so shell integration (~/.fzf.zsh) is generated -# automatically by the installer. This matches how update.sh manages fzf and -# what .zshrc expects (`source ~/.fzf.zsh`). -# A ~/.local/bin/fzf symlink is created so fzf is on PATH without sourcing -# ~/.fzf.zsh (important for install.sh and test.sh which don't source it). +# automatically by the installer. The ~/.local/bin/fzf symlink is what wins +# PATH lookup over any older system fzf at /usr/local/bin or /usr/bin — and +# ~/.fzf.zsh's `source <(fzf --zsh)` (modern fzf integration style) needs the +# new fzf to win, otherwise shell startup errors with "unknown option: --zsh". +# Always reconcile the symlink, even when the clone already exists, so re-runs +# over partial state self-heal. _install_fzf() { - if [ -d ~/.fzf ]; then - log_ok "fzf already installed — skipping" - return + if [ ! -d ~/.fzf ]; then + log_step "fzf (git clone)" + log_info "fzf: installing latest → ~/.fzf/" + git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf --quiet + ~/.fzf/install --no-update-rc --key-bindings --completion + else + log_ok "fzf already installed — skipping clone" fi - log_step "fzf (git clone)" - log_info "fzf: installing latest → ~/.fzf/ (shell integration via ~/.fzf.zsh, binary symlinked to ~/.local/bin/fzf)" - git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf --quiet - ~/.fzf/install --no-update-rc --key-bindings --completion mkdir -p ~/.local/bin ln -sf ~/.fzf/bin/fzf ~/.local/bin/fzf - log_ok "fzf installed → ~/.fzf (symlinked to ~/.local/bin/fzf)" } diff --git a/test.sh b/test.sh index 34109ad..9eab42c 100644 --- a/test.sh +++ b/test.sh @@ -135,6 +135,13 @@ if [ -f ~/.fzf.zsh ]; then check_run "~/.fzf.zsh sources without error" zsh -c "source ~/.fzf.zsh" fi +# Regression guard: ~/.fzf cloned without the ~/.local/bin/fzf symlink lets an +# older system fzf win PATH; modern ~/.fzf.zsh then fails with "unknown option: +# --zsh" on every shell startup. +if [ -d ~/.fzf ] && [ "$(readlink ~/.local/bin/fzf 2>/dev/null)" != "$HOME/.fzf/bin/fzf" ]; then + _fail "~/.local/bin/fzf missing or wrong target (got: $(readlink ~/.local/bin/fzf 2>/dev/null || echo none))" +fi + # ── 5. fzf functional ───────────────────────────────────────────────────────── _hdr "fzf functional" diff --git a/update.sh b/update.sh index 6fa98fc..b7f8375 100755 --- a/update.sh +++ b/update.sh @@ -388,6 +388,10 @@ if _should_run fzf; then else log_warn "fzf: git pull failed — skipping" fi + # Self-heal the symlink in case an older dotfiles version (where + # _install_fzf returned early when ~/.fzf existed) left it missing. + mkdir -p ~/.local/bin + ln -sf ~/.fzf/bin/fzf ~/.local/bin/fzf fi elif has fzf; then log_warn "fzf: not managed as a git clone at ~/.fzf — skipping (update manually)"