fish-shell icon indicating copy to clipboard operation
fish-shell copied to clipboard

`fish_add_path` reorders $PATH in subshell without `--move`

Open injust opened this issue 4 months ago • 4 comments

fish_add_path docs says:

If a directory is already included, it is not added again and stays in the same place unless the --move switch is given.

In my dotfiles, I add 2 user paths:

fish_add_path -g ~/code/scripts ~/.local/bin

I also use direnv, which prepends /Users/jsu/code/foo/.venv/bin to $PATH (not $fish_user_paths!). Note that after this prepend, $PATH no longer begins with $fish_user_paths. This is the result:

set in global scope, exported, a path variable with 14 elements
[1] /Users/jsu/code/foo/.venv/bin
[2] /Users/jsu/code/scripts
[3] /Users/jsu/.local/bin
[4] [system paths here]
originally inherited as |[subset of system paths here]|

When I start a subshell (e.g. fish --private), fish inherits the existing $PATH and then sources my dotfiles again. Despite the 2 user paths already existing in $PATH, they are moved to the front. This is the result:

set in global scope, exported, a path variable with 14 elements
[1] /Users/jsu/code/scripts
[2] /Users/jsu/.local/bin
[3] /Users/jsu/code/foo/.venv/bin
[4] [same system paths here]
originally inherited as |/Users/jsu/code/foo/.venv/bin:/Users/jsu/code/scripts:/Users/jsu/.local/bin:[same system paths here]|

fish: 4.0.2 OS: macOS Sequoia 15.6.1 Terminal: Ghostty 1.1.3

injust avatar Aug 26 '25 14:08 injust

can't reproduce, please give a full but minimal reproducer (without direnv and without subshell if possible)

$ fish_add_path -ag /
set -g fish_user_paths /
$ set -S PATH
$PATH: set in global scope, exported, a path variable with n elements
$PATH[1]: |/|
...
$ set PATH foo $PATH
$ set -S PATH
$PATH: set in global scope, exported, a path variable with n+1 elements
$PATH[1]: |foo|
$PATH[2]: |/|
...
$ fish_add_path -ag /
Skipping already included path: /
No paths to add, not setting anything.
$ set -S PATH
$PATH: set in global scope, exported, a path variable with n+1 elements
$PATH[1]: |foo|
$PATH[2]: |/|
...

krobelus avatar Aug 27 '25 06:08 krobelus

What happens here is that fish_add_path --global uses a global $fish_user_paths, and only checks for duplicates in that. The $fish_user_paths code then moves the path up front.

Tbh the "--global" mode is probably the least useful, I'd recommend using "--path" instead.

Obvious improvements are to change fish_add_path to always consider both $fish_user_paths and $PATH when checking for duplicates. Alternatives are to change the $fish_user_paths code to not move paths, to remove "fish_add_path --global" or make it use $PATH directly, but both of those have backwards-compatibility implications.

faho avatar Sep 02 '25 08:09 faho

I'd recommend using "--path" instead.

I considered using --path as a workaround, but after playing with fish_add_path, I quite like the idea of "user vs system" paths. In my dotfiles, it felt reasonable to have direnv and Homebrew stuff as "system paths", vs the handful of directories with scripts I touch everyday as "user paths".

Although a slight footgun when using fish_add_path both with+without --path: To ensure $fish_user_paths is at the beginning of $PATH, the last fish_add_path command in my dotfiles needed to be one without --path.

Obvious improvements are to change fish_add_path to always consider both $fish_user_paths and $PATH when checking for duplicates.

+1

injust avatar Sep 02 '25 09:09 injust

This causes some weirdness when starting a subshell while a Python venv is activated, e.g. to use screen.

In the subshell, all my tooling reports that the venv is activated:

  • direnv shows that the venv is activated
  • uv pip list lists the packages inside the venv

But because $PATH is reordered, actually running Python will use whatever is prioritized in the $PATH, which in my case is ~/.local/bin/ because it's in my fish_user_paths.

injust avatar Sep 30 '25 02:09 injust