Scripts: bash or zsh

With macOS Catalina, Apple made zsh the default shell. Pundits saw this as a sign that the bash will eventually be dropped from macOS, which of course is not good news for bash coders.

The zsh is very, but not entirely, compatible with bash. If you restrict yourself to a common subset of both shells, namely Bourne shell capabilities, and add some glue code for a few needed differences, it is possible to run the identical script with either bash or zsh.

But what to use as the shebang now. #!/usr/bin/env bash or #!/usr/bin/env zsh ?

Return to #!/bin/sh

I’d like to give preference to the bash and use the zsh as a fallback, unless I know that the zsh will give me much better performance. So this is the (default) decision matrix:

bash installed zsh installed shell environment
no no fail
no yes zsh
no as sh zsh
yes no bash
yes yes bash
yes as sh zsh
as sh no bash
as sh yes bash

The solution I came up with, is the following code taken from mulle-boot:

#! /bin/sh

# Prelude to be placed at top of each script. Rerun this script either in
# bash or zsh, if not already running in either (which can happen!)
# Allows script to run on systems that either have bash (linux) or
# zsh (macOS) only
if [ "$1" != --no-auto-shell ]
   if [ -z "${BASH_VERSION}" -a -z "${ZSH_VERSION}" ]
      exe_shell="`command -v "bash" `"
      exe_shell="${exe_shell:-`command -v "zsh" `}"


      # Quote incoming arguments for shell expansion
      for arg in "$@"
         # True Bourne sh doesn't know ${a//b/c} and <<<
         case "${arg}" in
               # Use cat instead of echo to avoid possible echo -n
               # problems. Escape single quotes in string.
               arg="`cat <<EOF | sed -e s/\'/\'\\\"\'\\\"\'/g
         if [ -z "${args}" ]
            args="${args} '${arg}'"

      # bash/zsh will use arg after -c <arg> as $0, convenient!

      exec "${exe_shell:-bash}" -c ". ${script} --no-auto-shell ${args}" "${script}"
   shift    # get rid of --no-auto-shell

Let’s say the script is called foo and was invoked with foo "a b" "'c d'", just to make it hard.

First up it’s checked that the first parameter is --no-auto-shell. It’s expected that no-one will pass in --no-auto-shell as a useful parameter to the actual script.

In case the currently executing shell is already the bash or zsh (checked with the version tests), then the script just bypasses everything and the script continues. This happens in real life on the first run. And it always should happen on the second run.

If we are actually in the Bourne shell, we now let either the bash or the zsh re-evaluate the whole script. The --no-auto-shell gets prepended to the arguments to signify that a “boot” has happened. It is also necessary to quote protect the arguments from evaluation by the shell. That is as tricky as is shown in sh.

Now the script runs in a platform independent manner either in zsh or bash.


Technically the --no-auto-shell shouldn’t be necessary as either of the two shell version values should be defined on the second run. But in case they aren’t, we aren’t looping endlessly.