Skip to content

hook, shell: cd-tracking and shell-not-found robustness fixes - #3002

Open
alurm wants to merge 10 commits into
cachix:mainfrom
alurm:pr-hook-robustness
Open

hook, shell: cd-tracking and shell-not-found robustness fixes#3002
alurm wants to merge 10 commits into
cachix:mainfrom
alurm:pr-hook-robustness

Conversation

@alurm

@alurm alurm commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Depends on #3001.

Five more fixes found while further testing the hook:

  • Symlinked project paths (e.g. macOS's /tmp -> /private/tmp) could spuriously trip cd-out detection, since $PWD preserves the symlink a user navigated through while DEVENV_ROOT is canonicalized. Both sides are now resolved via builtins before comparing.
  • Automatic activation could spawn the wrong shell dialect's environment when $SHELL didn't match the shell the hook was actually loaded into. Each hook script now tells devenv shell explicitly which dialect to spawn.
  • Clarified the "shell not found" error message. Before: running devenv shell --shell zsh when zsh isn't installed anywhere (not in devenv.nix packages, not on PATH) printed devenv: add zsh to your devenv.nix packages, or set SHELL to an absolute path — the second half named no shell, so it read like pointing $SHELL at any installed shell (e.g. /bin/bash) would fix it; it now says set SHELL to zsh's absolute path, naming the specific binary that's actually required.
  • cd - silently failed to return you to the previous directory right after a devenv shell first activates, across all four dialects, each for a different underlying reason (bash/zsh discard OLDPWD unconditionally, fish tracks $dirprev instead which doesn't cross a process boundary). Found along the way: devenv shell's non-TUI interactive path used an older script generator that predated the earlier fixes in this series, so bash alone was missing all of them — the hook-dir leak fix, cd-out detection, and symlink resolution — now unified with zsh/fish/nu's path. Separately (fish only): reactivating a project by typing cd - itself, rather than a fresh cd <name>, also broke cd - inside the newly-spawned shell — fish's own cd - moves the directory-history entry the hook needs from $dirprev to $dirnext, and the hook was only ever looking at $dirprev.
  • Re-entering the same project right after leaving it silently failed to reactivate (bash/zsh only), because following the user out on cd-out never invalidated the hook's "did the directory change" cache.

Assisted by Claude Sonnet 5.

@sandydoo sandydoo added bug Something isn't working shell activation Automatic shell activation scripts labels Jul 14, 2026
alurm added 10 commits July 16, 2026 14:32
_DEVENV_HOOK_DIR marks the one shell devenv hook's activation function
spawned, so it knows to `exit` on cd-out (763dd26). But it's a plain
process environment variable, so it wasn't scoped to that one shell —
any descendant process forked from it afterward (a new tmux/zellij
pane, a manually started nested shell) inherited it too. If that
descendant re-sources the hook script, as any ordinary interactive rc
file would, it wrongly concludes it's also hook-spawned, and calls
`exit` on cd-out with nothing set up to catch it — closing the
pane/session outright.

Capture the marker into a non-exported variable as soon as each hook
script reads it, then erase the exported copy, so it can't propagate
past the one process it was meant for.

Reproduced against a real tmux + patched devenv binary before and
after to confirm the fix; added regression tests for bash/zsh/fish/nu
that fail without the fix (spawn a shim `devenv shell` from a child
process) and pass with it.

Fixes cachix#2861.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Cd-out detection for a hook-spawned shell lived only in the outer
hook.fish/posix.sh/nu script, and depended on that same script being
re-sourced a second time inside the spawned shell via the user's own
rc file. That's not guaranteed: a hook-spawned `fish -i` is
non-login, and a common fish idiom gates rc content behind `status
is-login` — which never re-arms the hook there. Confirmed live: with
such a config, `cd ..` out of an active devenv shell did nothing at
all; the environment just stayed active.

Move cd-out detection into devenv-shell's own generated init file for
each dialect (bash/zsh/fish/nu), gated on the same _DEVENV_HOOK_DIR
marker. This runs unconditionally as part of `devenv shell` itself,
independent of whatever the user's rc does, so it's not dependent on
that shell's own rc doing the right thing.

For bash, zsh, and nu, consume the exported marker before sourcing
user configuration so it still cannot leak, but defer registering the
prompt/PWD callback until afterward; fish's -C integration already
runs after config.fish. Preserve any existing user callbacks while
installing devenv's own, with regressions that deliberately replace
each shell's callback configuration and verify both sides still run.

Manual/nested `devenv shell` invocations are unaffected: the new code
only activates when _DEVENV_HOOK_DIR is set, which is only ever true
for the hook's own spawn call, never a manual one. Verified live,
including a hook-spawned shell with a manually-nested devenv shell
inside it.

This leaves a duplicate copy of the exit-on-cd-out logic in the outer
hook scripts (harmless — whichever copy runs first wins, the other
becomes a no-op) that could be removed as a follow-up now that this
copy is unconditionally reliable.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Fish defers acting on an activation decision to the next prompt
(8eff3cd, to dodge zoxide's recursion guard): _devenv_hook decides
on a PWD change, _devenv_hook_activate spawns on the following
prompt. Nothing re-checked, at spawn time, whether something else —
direnv loading `.envrc`'s `use devenv`, or a manually entered devenv
shell — had activated the same directory in between. Reproduced
directly: export DEVENV_ROOT between the decision and the deferred
prompt, and _devenv_hook_activate still spawns a redundant nested
devenv shell on top.

Recheck DEVENV_ROOT immediately before spawning and bail if it's
already set.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
)

_devenv_hook_activate follows the user out of a project with
`builtin cd`, not `cd` (9486594, to dodge "zoxide: infinite loop
detected" when the user overrides `cd`, e.g. `zoxide init --cmd=cd`).
`builtin cd` bypasses fish's own `cd` *function* entirely though, and
that function is where fish records directory history for `cd -`/
`prevd`/`nextd` (`$dirprev`) — it isn't a PWD-change hook. So a
subsequent `cd -` silently skipped over the project directory instead
of returning to it.

Root cause confirmed with a minimal, zero-devenv-code repro: a
function-internal `builtin cd` never updates $dirprev, while the
same function using plain `cd` does — and using a wrapped `cd` (to
simulate zoxide) makes it visible that `builtin cd` correctly avoids
calling it.

Add _devenv_builtin_cd_with_history, which replicates fish's own `cd`
function's $dirprev/$dirnext bookkeeping around `builtin cd`, without
calling `cd` (and thus without invoking any override). Confirmed live
that it neither re-triggers a user's `cd` wrapper nor regresses the
"infinite loop" fix.

Fixes cachix#2853.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Exiting a hook-spawned devenv shell always dropped back into the
outer shell that spawned it — even when that outer shell never had a
prompt of its own. A fresh terminal opened straight into a trusted
project (e.g. a new Zed/tmux tab) auto-activates on the shell's very
first prompt (_devenv_hook_init / the first-ever PROMPT_COMMAND
firing), before the user ever sees or uses that outer shell. Exiting
the inner shell there just leaves an empty vehicle shell behind,
requiring a second exit/Ctrl-D to actually close the terminal.

Distinguish that case from an activation that happens after the user
already interacted with the outer shell (a later `cd` into the
project). Both signals needed already exist: whether the inner shell
exited outright vs. cd'd out (exit-dir file present or not), and
whether this is the shell's first-ever activation
(_devenv_hook_init/first PROMPT_COMMAND firing) vs. a later one.
Combining them: a plain exit from a first-activation shell now
propagates, closing the outer shell too; a later activation never
propagates, since the user may want that shell back.

nu is not touched: env_change.PWD does not fire for the starting
directory when nu starts already inside a trusted project (verified
directly), so there is no zero-interaction activation path to key off
there in the first place — nothing regresses, but nu still needs two.

Verified live end-to-end in tmux: a fresh pane rooted in a trusted
project closes entirely on one Ctrl-D; a pane where the outer shell
was used first and then cd'd into the project survives one Ctrl-D as
before, only the inner shell exiting.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Found while manually verifying the exit-propagation feature under
/tmp: macOS symlinks /tmp -> /private/tmp (and similarly elsewhere).
DEVENV_ROOT is canonicalized by devenv's Rust side
(fs::canonicalize), while $PWD preserves whatever symlinked path the
user actually cd'd through — every cd-out check in the codebase
compares them as raw strings, so a project living under such a path
had the hook-spawned shell immediately (and wrongly) conclude the
user had left the project the moment it started, writing an exit-dir
file and exiting with nothing having actually changed. This affected
both the outer hook scripts (hook.fish/posix.sh/nu) and the
dialect-embedded copies added earlier.

Resolve both sides before comparing, using builtins only (no
`realpath` dependency, keeping with devenv hook's "no external tools
required" design): fish's `path resolve`, a portable `cd -P`/`pwd`
subshell shared by bash and zsh (extracted into one function,
`exit_on_cd_out_snippet`, since the two dialects' copies were
byte-for-byte identical), and nu's `path expand`. Falls back to the
raw value if resolution fails (e.g. the directory was removed out
from under the shell).

The existing test suite didn't catch this: it sets DEVENV_ROOT
directly to the same tempfile path used for `cd`, so both sides were
always identical strings — never exercising the mismatch. Added
dedicated regression tests that canonicalize the temp directory
themselves (skipping gracefully on platforms where it isn't
symlinked, e.g. most Linux CI) and set DEVENV_ROOT to the canonical
form while `cd`-ing through the symlinked one, for all four dialects.

Verified live end-to-end with the real binary in tmux (a project
under /tmp activates and stays active instead of silently bouncing
back to a plain prompt) and confirmed real cd-out still works
correctly afterward.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Without this, devenv falls back to $SHELL (the login shell), which is
frequently stale and can disagree with the shell the hook was actually
loaded into (e.g. a fish user whose $SHELL is still /bin/bash) —
silently activating the wrong dialect's environment. Each hook script
now passes its own known dialect explicitly.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
"set SHELL to an absolute path" read as if any valid $SHELL would
work, when it specifically means "point SHELL at this dialect's own
binary" — misleading when $SHELL is already a different, valid shell.

Also fixed, independent of the wording: `resolve_shell_path` returned
the bare, unresolved shell name (e.g. "nu") instead of signaling
failure when a dialect binary couldn't be found, and that name flowed
straight into `apply_shell_env`, which unconditionally overwrote
SHELL on the intermediate bash process with it — before the Nix
environment's own enterShell hook even runs, since env sourcing
happens earlier in the generated rcfile than this check. Any
shellHook that branches on $SHELL would have seen a bogus,
nonexistent path instead of the user's real shell. Fixed by having
`resolve_shell_path` return `Option<String>`, and only overriding
SHELL in the child environment when resolution actually succeeded;
otherwise the caller's real SHELL passes through untouched.

An earlier version of this commit also added a $SHELL fallback (spawn
$SHELL, with a warning, when the requested dialect's own binary can't
be found) for hook-triggered spawns. Removed after live-testing
turned up a real gap in it: if $SHELL was fish, fish's own
vendor_conf.d-autoloaded hook.fish would still read the leftover
_DEVENV_HOOK_DIR marker and wrongly treat the fallback shell as fully
hook-spawned (enabling cd-out-exit detection) despite the warning's
claim of "without dialect-specific integration" — a half-integrated
state that depends on happenstance (whether $SHELL's own hook loading
reaches for that marker) rather than anything deliberately designed.
Concluded a plain, clearly-worded hard error is more predictable than
a fallback that silently substitutes a different, only-sometimes-
still-hooked shell for what was actually requested.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
`cd /; cd <trusted-project>; cd -` silently failed to return anywhere
right after activation. Root cause differs per dialect:

- bash/zsh discard an inherited OLDPWD unconditionally, even across
  their own `exec` — confirmed directly against plain `bash`/`zsh`/
  `sh`, unrelated to devenv. Re-derived by actually cd-ing to the
  previous directory and back once the real shell has started, which
  is the only way to make OLDPWD meaningful to them.
- fish's `cd -` doesn't consult OLDPWD at all; it tracks `$dirprev`, a
  shell variable that can't cross a process boundary. Seeded directly,
  matching the MAX_DIR_HIST bookkeeping `_devenv_builtin_cd_with_history`
  already replicates from fish's own `cd` function.
- nu is the only one of the four where a plain freshly-exported OLDPWD
  just works.

Each hook script now passes the previous directory through via
`_DEVENV_PREV_PWD`; each dialect's own init file consumes it.

Along the way, found a separate, more significant gap while trying to
verify the bash fix at all: `devenv shell`'s non-TUI interactive path
special-cased bash through `bash_init_script`, an older, much simpler
generator that predates the hook-dir/cd-out/symlink fixes earlier in
this stack — none of them ever ran for bash outside the TUI (e.g.
`--no-tui`, non-interactive terminals, CI). zsh/fish/nu were never
affected; they already went through the shared `dialect.rcfile_content()`
path here. Bash now does too, closing that gap.

Also found (via live-testing the exact repro of re-entering a project
via `cd -` itself, rather than a fresh `cd <name>`): fish's own `cd -`
pops the entry `_devenv_hook_activate` reads from `$dirprev` and moves
it to `$dirnext` instead, flipping `$__fish_cd_direction` to `next` —
so reactivating by typing `cd -` left nothing in `$dirprev` for the
next activation to capture, and `cd -` inside the newly-spawned shell
was lost. `_devenv_hook_activate` now checks `$__fish_cd_direction` to
read whichever of `$dirprev`/`$dirnext` actually holds the entry.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Found while manually verifying the pr-hook-fixes bundle end-to-end:
cd into a trusted project, cd out, cd right back in — the second
entry silently failed to reactivate (bash and zsh; fish and nu use
event-driven PWD hooks with no equivalent cache, so they're
unaffected). Pre-existing bug, unrelated to anything else in this
stack.

`_devenv_hook` short-circuits when `$_DEVENV_HOOK_PWD` still equals
`$PWD`, to avoid re-running `hook-should-activate` on every prompt
when nothing has changed. Following the user out on cd-out (`cd
"$target_dir"`) never updated that cache, so it stayed at the old
project directory. Re-entering that same project later coincidentally
matched it again, wrongly short-circuiting the check.

First fix attempt (set the cache to the new location after following
out) broke the existing sibling-activation test: it made the
immediate follow-up check treat the new directory as already-known
too, skipping activation there instead. Clearing the cache instead of
setting it handles both cases: the next call always re-evaluates,
whether that's the immediate sibling check or a much later re-entry.

Verified live via tmux with the real binary (bash and zsh) and with a
new regression test that fails without the fix and passes with it.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
@alurm
alurm force-pushed the pr-hook-robustness branch from 33f851b to e23b2ba Compare July 16, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working shell activation Automatic shell activation scripts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants