优雅地启动 fish shell
背景
大约八年前,我从同学那儿了解到了 fish shell。在这之后,我使用的所有非临时 Linux 环境都装上了 fish 作为交互 shell 使用。fish 智能的自动补全和漂亮的高亮让我无法离开,但是 fish 有一个不那么方便的特性:不太兼容 Bash。
现代 Linux(尤其是我常用的 Debian/Ubuntu/Arch 等发行版)相当依赖 Bash。他们都预装并使用 Bash 作为默认 shell,在系统运行过程中(比如 login shell 初始化等等)都会执行很多 Bash 脚本。绝大部分的 Linux 指令教程只会提供 Bash 的版本,绝大部分软件的安装脚本和运行脚本等等也都会使用 Bash 脚本。
在这个 Bash 为王的世界,想要优雅的使用 fish 作为默认 shell 需要好好动一些心思。
需求
对于使用的方法和配置的结果,我有这么几个需求。
首先,对系统原始配置的改动要尽量少。虽然 Linux 有无与伦比的开放性,但是我还是倾向于尽量使用原生工具,并且尽量使用默认配置(fish 是少数的特例)。原因有二,一来不需要记忆这么多的定制项,要重装或者新配置环境的时候都可以尽快上手;二来有时候在用一些没有定制化的环境(比如在一个容器里临时 debug)的时候,也不会出现习惯了高级工具或者配置,由奢入俭难的问题。
其次,要能尽量兼容 Bash 的存在。需要 Bash 的时候,fish 不会挡道。
最后,环境变量等配置,需要尽量无感平移到 fish 中。和第一点其实类似,我不想为了 fish 再写一套新的初始化脚本,到时候 Bash fish 两边同步维护也会非常麻烦。
方法
万能的 ArchWiki 其实已经给出了很好的解答:https://wiki.archlinux.org/title/Fish#System_integration。主要有三种方法:直接更改默认shell,改终端模拟器配置,改.bashrc
。
更改默认 shell
最直接的方法,就是直接把用户的默认 login shell 改成 fish。这也是我在18年的这篇笔记里记录的方法:
1chsh -s /usr/bin/fish
当时的我还没有那么深入使用 Linux,对这一操作的后果并不了解(实际上倒也没那么大问题 lol)。直接更改 login shell 主要影响的就是环境变量的配置,即(包括但不限于)/etc/profile
~/.bash_profile
/etc/bash.bashrc
~/.bashrc
这几个文件不会自动在登录的时候被 source,里面包含的一些默认的环境变量配置需要再单独写 fish 的配置去设置。
执行 Bash 脚本之类的任务倒是不会影响,因为都有 shabang 行指定解释器。在 fish 里临时进 bash 跑些东西也是很方便。
更改终端配置
一个有些不太直接的方法,就是在终端模拟器(gnome-terminal,wezterm,etc.)或者终端复用器(tmux,zellij,etc.)或者 SSH 连接设置里设置使用 fish。终端模拟器和复用器拉起的 fish 应该都能正确拿到 login shell 的环境变量,但是 SSH 启动的就是 login shell,肯定还得另外配置。我不喜欢这种方法的点主要在于不够无感,需要手动在所有访问 shell 的地方进行设置。
更改 .bashrc
这是我目前一直采用的方案,相对折中,配置也很方便。
既然我们想要 fish 继承 login shell(也就是 Bash)的环境变量,又想自动进入 fish,那我们只需要找一个方法让 Bash 在初始化的最后调用 fish。碰巧 Bash 会在交互模式下加载 ~/.bashrc
这个文件,那只需在这个配置文件的最后一行加上:
1exec fish
就大功告成啦!这样配置非常简洁好记,不影响 login shell 的加载,fish 也能完全继承 Bash 里的环境变量,也无所谓使用什么终端或是 SSH 访问。exec
执行的指令也会直接替换掉当前的进程,于是 fish 的进程会直接挂在终端下面,而不是挂在一个 Bash 进程下面。在过去的好几年里,我的个人机器上都用着这个配置。
这一行简单的配置能满足我 99.9% 情况下的需求,但是有一个小小的缺陷:偶尔一次需要进入 Bash 执行命令的时候,会有一些麻烦。ArchWiki 提到可以用 bash --norc
来绕过 .bashrc
的执行,但是这同时会导致一堆配置没有被加载。我一般会临时把 .bashrc
里的这行 exec fish
注掉,开一个新的窗口操作,完事了再给改回去。属实麻烦。
这两天重看 ArchWiki 的时候,发现这几年间的更新,维护者们提供了一个复杂得多,但是更完善的命令:
1if [[ $(ps --no-header --pid=$PPID --format=comm) != "fish" && -z ${BASH_EXECUTION_STRING} && ${SHLVL} == 1 ]]
2then
3 shopt -q login_shell && LOGIN_OPTION='--login' || LOGIN_OPTION=''
4 exec fish $LOGIN_OPTION
5fi
以上命令来自:https://wiki.archlinux.org/title/Fish#Modify_.bashrc_to_drop_into_fish
这一串东西主要完成了三件事情:
-
[[ $(ps --no-header --pid=$PPID --format=comm) != "fish" && ${SHLVL} == 1 ]]
这两个判断条件分别是“当前 shell 是否不是 fish”以及“当前 shell 层级是否在第一层”,意思是只有在不是 fish,以及第一层的条件下,才会进入 fish。这样一来,只有最开始的那一层 Bash 才会进入 fish,后面在 fish 里继续执行bash
都会留在 Bash 里,解决了我刚提到的痛点。 -
[[ -z ${BASH_EXECUTION_STRING} ]]
用来判断当前是否是bash -c
执行命令的情况,避免这时候进入 fish。不过 Bash 在非交互模式下并不会调用.bashrc
,而且至少 Debian/Ubuntu 默认自带的.bashrc
文件最开头就判断了是否为交互模式,非交互模式直接跳过。所以这个条件更准确的来说是用来避免bash -c -i
这种强制交互模式执行指令的情况下进入 fish。 -
shopt -q login_shell && LOGIN_OPTION='--login' || LOGIN_OPTION=''
用来获取当前是否是 login shell。实话说我不太清楚 fish 默认在 login 和 non-login shell 情况下表现有什么区别,文档里并没有对此进行说明。倒是 config.fish 里可以使用if status is-login
来指定一些 login shell 情况下执行的配置,我怀疑现在这可能就是唯一的区别,即只是一个状态标记的区别。fish 的文档里确实有提到无论何种状态,fish 都会读取 config.fish,不像 Bash 会区分不同的状态读不同的配置文件。用
if status
去区分交互/非交互或者 login/no-login 状态就是文档里推荐的做法,这样功能上就和 Bash 统一上了,所以我认为我的猜测应该八九不离十。我的config.fish
里基本没有什么内容,所以这个部分对我意义也不大。
总结下来,这样一大串补丁确实解决了单独 exec fish
带来的一些问题,但是是否值得专程跑过来复制一通值得商榷。我会在日常稳定使用的机器上用完整版的配置,但是如果需要快速配置一下 fish,单光一行 exec fish
已经足够很好的完成任务了。
(很好奇再过 5 年,ArchWiki 上这串代码块会不会成长为一个怪兽;)
~/.local/bin
的困扰
问题
在 .bashrc
中 exec fish
还带来了另一个副作用。
~/.local/bin
是一个我这几年越发常用的路径,XDG 规范推荐使用这个路径存放用户自己的可执行文件。我会把一些自定义的可执行文件(直接下载的软件,脚本,等等)存放或是软链接到这个路径下,这样就可以直接执行了,管理起来也很方便。
但是前提是,这个路径被正确添加到了 $PATH
里。在最近的 Debian/Ubuntu 系统里,默认的 ~/.profile
文件(/etc/skel/.profile
)内容是这样的:
1# ~/.profile: executed by the command interpreter for login shells.
2# This file is not read by bash(1), if ~/.bash_profile or ~/.bash_login
3# exists.
4# see /usr/share/doc/bash/examples/startup-files for examples.
5# the files are located in the bash-doc package.
6
7# the default umask is set in /etc/profile; for setting the umask
8# for ssh logins, install and configure the libpam-umask package.
9#umask 022
10
11# if running bash
12if [ -n "$BASH_VERSION" ]; then
13 # include .bashrc if it exists
14 if [ -f "$HOME/.bashrc" ]; then
15 . "$HOME/.bashrc"
16 fi
17fi
18
19# set PATH so it includes user's private bin if it exists
20if [ -d "$HOME/bin" ] ; then
21 PATH="$HOME/bin:$PATH"
22fi
23
24# set PATH so it includes user's private bin if it exists
25if [ -d "$HOME/.local/bin" ] ; then
26 PATH="$HOME/.local/bin:$PATH"
27fi
非常简洁明了,login shell(Bash)会在启动时加载这个文件,首先加载 .bashrc
,然后分别判断 ~/bin
和 ~/.local/bin
是否存在,存在的话加入 $PATH
。
GUI 模式下,X.org 会在非交互模式下加载用户的 login shell(抱歉我还没用上 Wayland)。于是加载 .bashrc
的时候,上来就会碰上这几行:(摘自 /etc/skel/.bashrc
的开头)
1# If not running interactively, don't do anything
2case $- in
3 *i*) ;;
4 *) return;;
5esac
于是 . "$HOME/.bashrc"
直接返回,继续执行完 .profile
的剩下两块代码。这么一来,桌面版系统只要存在这两个 bin 路径,重新登录后他们就会自动出现在环境变量里。Nice!
但如果是 SSH 连接呢?当使用 SSH 远程登录的时候,SSH 会使用一个交互式的 login shell。坏了!这样一来,.profile
在调用 .bashrc
的时候,就会执行到最后几行进入 fish,.profile
的最后两块代码永远也不会被执行了。于是在 SSH 连接的时候,~/.local/bin
不会被正确的加入环境变量。
所以问题就是,如何在 login shell 下正确添加 ~/.local/bin
到环境变量。
解决方法
一种方案是直接修改 .profile
,把 .bashrc
的块移动到最下面。我并不太乐意采用这个方案,因为好像是个有点大的改动,并且担心这个顺序变动在将来会造成环境变量优先级错乱。
另一种方法是在 fish.config
里判断 login shell 的时候执行 .profile
。这好像是个办法,但其实不可行:在 .bashrc
执行的过程中我就已经需要这个环境变量了,比如引用这个路径下的 uv
生成 Bash 自动补全。到启动 fish 以后再引入变量已经来不及了。
所以最后一个折中方案,就是直接把 .profile
的后两块复制一份进 .bashrc
,在加载 fish 之前手动添加一下环境变量(我只用 ~/.local/bin
,所以只复制这部分)。这解决了 SSH 情况下没有环境变量的问题,但是会导致 GUI 环境的 shell 环境变量里,~/.local/bin
出现了两次。倒也不是什么大问题,只是我觉得有些别扭,于是可以再多加一个字符串判定:[[ "$PATH" != *"$HOME/.local/bin"* ]]
,或者也可以写成 [[ ! "$PATH" =~ "$HOME/.local/bin" ]]
。于是最终版的 .bashrc
额外配置如下:
1... # 原本的 .bashrc 配置
2
3# set PATH so it includes user's private bin if it exists
4if [[ -d "$HOME/.local/bin" && "$PATH" != *"$HOME/.local/bin"* ]] ; then
5 PATH="$HOME/.local/bin:$PATH"
6fi
7
8... # 依赖 .local/bin 的一些配置
9
10# enter fish
11if [[ $(ps --no-header --pid=$PPID --format=comm) != "fish" && -z ${BASH_EXECUTION_STRING} && ${SHLVL} == 1 ]]
12then
13 shopt -q login_shell && LOGIN_OPTION='--login' || LOGIN_OPTION=''
14 exec fish $LOGIN_OPTION
15fi
#tech notes
3568 words