Migrating to Fish from Bash

Overview

Switching to the Fish shell when you have a bunch of existing Bash scripts can be quite a challenge due incompatibilities with functions and the inability to source scripts from another language. There is at least one plugin which attempts to solve some of these problems, but I have not looked at it. Instead I used the suggested trick below. However, this alone is not enough. It needs a lot more work to make it usable. This replaces the fish shell with a bash shell that runs your script. When the script is done, it replaces the bash shell with a fish shell, leaving you back in fish.

exec bash -c "bash_script.sh; exec fish"

The major problems are that (1) you can’t run bash functions in fish, and (2) you can’t source a bash script into fish, which means any variables set in the bash script will not be available in your fish shell. If you don’t have existing scripts or you only have a small number of them, and they are easy to change, the solution to these two problems is easy: don’t use functions and don’t set variables through sourcing. Since this wasn’t an option for me, I solved the problem by writing all the bash functions to individual scripts, creating wrappers around the functions, and defining a function to export variables that has the same syntax in both fish and bash. Before I go any further, let me say that I strongly recommend, if you have any other option, against doing it this way for the following reasons.

  • Because each script and function now calls exec fish at the end, you can no longer combine multiple scripts in a single command or in another script. Anything that happens after the first bash script will be lost.
  • This took me a few days to get working, and it was extremely difficult to debug due to the extra layer introduced by the wrappers.
  • It requires some ugly changes to config.fish.
  • Each time you exec fish, it runs through the initialization, including config.fish, which may have some undesirable consequences as I’ll explain.

Making Fish Feel More Like Bash

I first want to get into the various other differences between fish and bash that I’ve discovered. Then I’ll get to the wrappers, I promise. Fish doesn’t use history the same way as bash. If you want to reproduce something like bash history, you can do the following. Because fish is pretty good at figuring out what you want to type, there is somewhat less need for history. The fish_command_not_found lets you intercept unbound commands. Then you can examine the command to see if it’s a bash history command (i.e. !nnn), select that number from history, and run it. Otherwise, run the default handler.

if status --is-interactive
    alias !!     'eval_hist "$history[1]"'
    # I'm not using search because the numbering won't be consistent with !nnn
    alias histgrep "history --reverse | nl | grep $arvg[1]"
    alias hist     "history --reverse | nl"

    # Putting an extra character in front stops it from being added to history
    abbr --add -g !!       ' !!'
    abbr --add -g histgrep ' histgrep'
    abbr --add -g hist     ' hist'

    # Roughly equivalent to !nnn in bash.
    function fish_command_not_found
        if [ (count $argv) = 1 ]; and test ! -z "(string match -r '![[:digit:]]+' "$argv[1]")"
            set -l num (string sub -s 2 $argv[1])
            set -l cmd (history --reverse | nl | grep (printf "^[[:space:]]*%s[[:space:]]*" "$num") | cut -f 2)
            if test -z (string match -r '!' "$cmd")
                builtin history delete --exact --case-sensitive "!$num"
                eval_hist "$cmd"
            end
        else
            __fish_default_command_not_found_handler $argv
        end
    end

    # This has nothing to do with history.
    # It's a nice Emacs way to quit out of completions.
    bind \cg 'cancel'
end

set -lx add_hist 'printf \"%s cmd: %s\n  when: %s\n\" \"-\" \"\$last_cmd\" \$(date "+%s") >> ~/.local/share/fish/fish_history'

eval "function eval_hist; set -l last_cmd \"\$argv\"; eval \"\$last_cmd; $add_hist\"; history --merge; end"

If you are using the prompt command trick to preserve history across multiple terminals, you’ll probably want to do the same in fish. Initially I went with this.

functions --copy fish_prompt _fish_prompt
function fish_prompt
    history merge
    _fish_prompt
end

But pretty soon I realized it was merging history in every terminal after every command immediately. This is really annoying. I don’t want to enter the up key to get the previous thing I typed and instead get something I typed in another terminal. And the solution, instead, is to do absolutely nothing! Fish already saves each command you type in each terminal to a history file. However, I soon discovered that sometimes it was still merging in history from other terminals. This was due to running exec fish after a bash command. Running the initialization pulls in the merged history file. This is one of the undesirable consequences I was speaking about.

One of my bash scripts does something like the following in order to re-display the prompt after a background script completes. If I don’t do this, I inevitably end up staring at the terminal, wondering what’s taking so long when the script finished three minutes ago.

# bash function
{
    bind '"\e[0n": "\n"'
    (
        printf "Running command in background\n"
        command
        # can-bash-write-to-its-own-input-stream
        printf '\e[5n'
    ) & disown;
} 2>/dev/null

Unfortunately, this binding is lost when you exec fish. You will need the following in config.fish. I don’t think there is any way to create this binding just temporarily.

bind \e'[0n' "echo;fish_prompt"

Reusing Bash Functions and Scripts

The first step is to write each bash function to a function in its own script by the same name. You can do this with type or declare -f. The function and script name should have some extra characters pre-pended or appended because there will be a wrapper with the same name around the script. Every bash script/function will need a fish wrapper. Note that we’re wrapping both existing scripts and scripts we created from functions.

function create_wrapper
    eval "function $argv[1]; history --merge; exec env last_cmd=\"\$history[1]\" bash -c \". \\\"\$SHELL_PATH/wrappers.sh\\\"; trap ':' SIGINT; do_script \\\"$argv[1]\\\" \$(printf '\"%s\" ' \$argv); eval '$add_hist'; exec env USE_WRAPPER=1 fish\"; end"
end

for fname in $(ls "$SCRIPTS_PATH")
    create_wrapper "$fname"
end

The SCRIPTS_PATH is just whatever directory you have placed your scripts in. There will be a few scripts needed to make this work, so I made a SHELL_PATH. This creates a fish function for each bash function and uses the trick mentioned at the beginning to invoke the function in bash and then change back to fish. As soon as we switch to bash, we need to create function wrappers there as well. This is the purpose of wrappers.sh. The reason for this is that this is neither a login shell nor an interactive shell, so none of the functions will yet exist. And recall that the script names for the corresponding functions were intentionally named slightly differently to avoid shadowing the function that the script calls. The difference between the bash wrappers and the fish wrappers is that the bash wrappers only wrap scripts that were generated from functions, whereas the fish wrappers wrap all scripts and functions. Also note that it’s not sufficient to create a wrapper for only the function you are calling because that function may call other scripts or functions.

The next command intercepts SIGINT, and does nothing. The reason for this is that when you hit Ctrl-C while in bash, it crashes the fish shell. I don’t understand why this happens, but this fixes the problem. Then we call a function which runs the function or script. This function needs to be passed the name of the function/script to run as well as all the arguments. Finally, we replace the bash shell with fish and pass an environment variable, USE_WRAPPER, indicating that the shell was called from a wrapper function. I discovered the need for this flag when my environment variables kept getting over-written. Unlike bash, fish always runs the init script, config.fish. My init script sets up all my environment variables, and they were getting clobbered each time the wrapper was run. Some scripts modify environment variables; that may be their entire purpose. So resetting them is definitely not the desired behavior.

The wrappers for bash follows. Note that it excludes scripts since we only need wrappers for functions. Functions-as-scripts are sourced so that they behave like functions with respect to setting environment variables. The bash version of the wrapper also intercepts SIGINT. I believe the reason for this was that hitting Ctrl-C was causing the interrupted script to drop into the bash shell instead of fish.

# wrappers.sh
function create_wrapper { eval "function ${1} { trap ':' SIGINT; run_script \"${1}\" "\$@"; }; export -f ${1}"; }

function do_script {
    local script="$1"
    shift
    if [[ "$script" =~ ".sh" ]]; then
        "${SCRIPTS_PATH}/${script}" "$@"
    else
        . "${SCRIPTS_PATH}/${script}" "$@"
    fi
}

for fname in $(ls "$SCRIPTS_PATH" -I '*.sh'); do
    create_wrapper "$fname"
done

export -f do_script
unset -f create_wrapper

Fish prints a greeting. We don’t want to see this every time we run a bash function or script. The following needs to go somewhere in the init file.

function fish_greeting; end

There is still one step left. Since there is now a script shadowing every function, you will still need to source the wrapper script from .bashrc for functions to work properly when using bash. And you will occasionally need to work in bash to fix scripts.

. "$SHELL_PATH"/wrappers.sh

Common Environment Variables and Aliases

Even though fish and bash have incompatible syntax, it is possible to source a single, common script into either environment as long as you limit the script to using only syntax common to both fish and bash. This is helpful for defining environment variables and aliases in your init files. By defining a function for setenv and alias in bash, you can make it have the same syntax as fish. Put the following in .bashrc.

# bashrc
function setenv { export "$1=$2"; }
function _alias { unalias alias; alias "$1=$2"; alias alias=_alias; }
alias alias=_alias

# source bash_env if USE_WRAPPER is not set
. env

unalias alias
unset -f _alias -f setenv

Then add the script or scripts with environment variables and aliases. I believe newer versions of fish have syntactic sugar for making some of this bash syntax work in fish.

# env; can source a script with these
setenv SCRIPTS_PATH "/home/user/scripts"
alias cp            "cp -i -p"

Setting Paths from Bash

There is still one last problem, well one that I know how to fix. You can’t use the exec fish trick in your config.fish. I don’t understand why, but it breaks the X11 login. Logging in from terminal works just fine. What you can do instead is use the universal environment variable mechanism. Run a bash script that sources all the paths you want. Then run a fish shell that permanently sets all the paths in fish. This was tricky to get right. I had some path variables that were getting changed in bash and then were used to modify the path. When calling fish from bash, the bash path gets imported into fish, but I was having issues with paths being reordered and/or duplicated. I finally settled on the following.

if status --is-login
    if not string match -r -q 'universal' "$(set --show fish_user_paths)"
        set -Ux fish_pager_color_selected_background --background='#990000'
        set -Ux fish_pager_color_background --background='#000040'
        bash -c ". /opt/rh/additional-software/enable; exec fish -c 'set -U fish_user_paths \$PATH; set -Ux ANOTHERPATH \$ANOTHERPATH;'"
    end
    # This needs to happen after setting fish_user_paths to PATH in bash above.
    # Otherwise, if SOME_OTHER_PATH is in fish_user_paths, path will not be
    # correct after modifying PATH in a bash script.
    set -g PATH "$SOME_OTHER_PATH" $PATH
end

You probably noticed I changed the pager color. This is because the default background color is the same color as the terminal.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top