codedsleep.
All writing

Switching to fish: what actually changes

#linux#shell#fish

The video above is DistroTube’s tour of fish, and it covers the feature list better than another feature list would. What follows is the part that only shows up after you have actually made fish your login shell: what improves, what breaks, and what you end up doing about it.

Everything here was checked against fish 4.6.0.

Two features do most of the work

Autosuggestions and syntax highlighting. Everything else fish offers is nice; these two are why people stop going back.

As you type, fish greys out the rest of the most recent matching command from your history. Right arrow accepts it. In practice you stop retyping long commands and stop reaching for Ctrl-R to find them.

The highlighting matters more than it sounds. A command that does not exist is red before you press enter. So is an unclosed quote, and a path that is not there. Most of my shell mistakes used to be typos I only found out about after running them — that category is mostly gone.

Neither needs configuring. They are on by default.

Installing it without breaking your login

Install fish, then make it your shell:

# Fedora
sudo dnf install fish
# Debian/Ubuntu
sudo apt install fish

The step people get wrong is the next one. chsh refuses any shell that is not listed in /etc/shells, and the error is not obvious about why:

grep fish /etc/shells || echo /usr/bin/fish | sudo tee -a /etc/shells
chsh -s /usr/bin/fish

Log out and back in — chsh does not affect the session you run it from.

If that makes you nervous, don’t set it as your login shell at all. Put exec fish at the end of your terminal emulator’s startup command instead. You get fish interactively, your login shell stays POSIX, and nothing that reads $SHELL gets confused.

The incompatibility, and the one-line fix

Fish is not POSIX and does not try to be. Every install script on the internet assumes otherwise. Anything with export FOO=bar, [[ ... ]], $?, or a C-style for loop fails, usually with an error that does not explain itself.

There is exactly one coping strategy worth learning:

bash -c 'for f in *.md; do echo "${f%.md}"; done'

Keep fish for the interactive session, shell out to bash for anything you copied from a README. Do not try to port it. This also applies to scripts you write: a script with #!/bin/bash at the top runs under bash no matter what your interactive shell is, so there is no reason to rewrite your existing ones.

Worth knowing: fish has quietly absorbed most of the syntax people expect. &&, || and ! all work, and $(command) works alongside fish’s own (command). The old advice to write and and or is out of date.

command -q rg && echo "ripgrep is installed"
echo "there are $(count $PATH) entries in PATH"

Config that earns its place

Fish reads ~/.config/fish/config.fish. There is no .bashrc/.profile split to think about.

set -gx EDITOR nvim
set -gx PAGER less

fish_add_path ~/.local/bin

set -gx is fish’s export: -g global, -x exported. fish_add_path is the one worth knowing — it appends to $PATH only if the entry is not already there, so re-sourcing your config does not give you a $PATH with six copies of the same directory.

$PATH being a real list rather than a colon-delimited string is a small thing that keeps paying off:

count $PATH        # 16
echo $PATH[1]      # /home/zzz/.bun/bin
echo $PATH[-1]     # last entry

Use abbreviations, not aliases

This is the one piece of fish-specific advice I would give someone on day one.

abbr -a gs  --position command "git status"
abbr -a gco --position command "git checkout"
abbr -a bd  --position command "bun run dev"

An abbreviation expands in place when you hit space or enter. You type gs, the line becomes git status, and that is what runs and what lands in your history.

An alias hides the real command. Six months later you are reading shell history trying to work out what gs meant at the time, or pasting it into a machine that does not have your config. Abbreviations have neither problem — the expansion is always visible before you commit to it, and history stays honest.

--position command stops the abbreviation firing when the word appears as an argument rather than as the command.

Functions instead of scripts

Fish autoloads any function saved as ~/.config/fish/functions/NAME.fish, which makes small utilities cheap enough that you actually write them:

function mkcd --description "mkdir -p and cd into it"
    mkdir -p $argv[1] && cd $argv[1]
end

Define it at the prompt to try it, then funcsave mkcd to write it to the right file. It is available in every new shell with no sourcing and no $PATH entry.

--description is not decoration — it is what shows up next to the function in completions.

Completions from man pages

fish_update_completions

That parses the man pages on your system and generates completions from them. Every tool you have installed becomes tab-completable, including the ones nobody has written a completion script for. Run it once after installing fish, and again after a big package update.

The string builtin

Fish ships a real string manipulation builtin, which removes most of the reasons to reach for sed or parameter expansion:

for f in notes.md todo.md
    echo (string replace -r '\.md$' '' $f)
end
# notes
# todo

string split, string match -r, string join, string trim, string repeat — all documented under string --help, all faster to get right than the sed equivalent.

What I still use bash for

Anything that has to run on a machine I do not control, anything with a shebang, and anything copied from documentation. Fish is an interactive shell. Treating it as a scripting language you write portable code in is how people end up frustrated and switch back.

Keep that boundary and the rough edges mostly disappear.