Migrating to ZSH from Bash

Recent news that upcoming macOS 10.15 will use zsh as its default shell nudged me once again to look at it. I thought about giving ZSH a chance few times in the past, but was always intimidated by the prospect of migrating an already convenient bash setup to it, also dominating use of oh-my-zsh across my friends and coworkers gave an impression that zsh is impossible to use without such monstrous extensions.

Finally I decided to give it a try and see whether it is possible to make a low-effort zsh setup that would be not that much different from my bash setup, giving me a better chance to stick with it. Another important aspect is that I want such setup to be easy to understand and not to use external projects like oh-my-zsh.

Starting point: bash setup

Here's how my bash is set up and what I wanted to keep the same:

bash

Iterating over new setup

Starting with empty .zshrc file my main priorities were getting familiar key bindings to move around the input line and more useful prompt.

While the internet was full of tutorials on how to set bindings key-by-key, it was far more easier to achieve what I wanted: man zshroadmap (and man zshzle) provided the correct way to do this:

bindkey -e

Having familiar key bindings in place, prompt was the next step. Here, man zshmisc (specifically its EXPANSION OF PROMPT SEQUENCES section) was of big help.

In my bash prompt final component of the current directory is shown, followed by ¶ char, so initial step was to get it was this:

PROMPT='%1~ ¶ '

Pleasingly, zsh color abilities are much nicer to deal with than in bash, where one have to jiggle raw ascii escape sequences, so prettified version of the prompt was not that ugly:

PROMPT='%B%F{white}%1~%f%b %F{green}¶%f '

Here %B and %b work as bold opening and close "tags" (think of it like html's <b> and </b> respectively), and %F{color} and %f as opening and close "tags" for colors.

Next one was displaying non-zero exit code of previously executed command. Here CONDITIONAL SUBSTRINGS IN PROMPTS section of manual page was helpful, specifically ternary expression %(x.true-text.false-text). Applied to exit status of the last command it looks like %(?.zero-exit-code-text.non-zero-exit-code-text). The return status of the last command is expanded in prompt with %? placeholder, and since zero exit code shouldn't be reported at all (empty string), final expression takes the form %(?..%? ). With some colors sprinkled on top, prompt becomes this:

PROMPT='%B%F{red}%(?..%? )%f%F{white}%1~%f%b %F{green}¶%f '

This is almost all I need, with exception of reporting git repository branch. Here I checked /Library/Developer/CommandLineTools/usr/share/git-core/ directory, and though it had git-prompt.sh file I used in bash, there were no corresponding file for zsh. Prepared that I'd need to come up with some scripting myself, I checked internets once again to see whether there's any alternative than resort to frameworks like oh-my-zsh, and one keyword popped up: vcs_info. It appears that recent versions of zsh are shipped with built-in framework to work with most VCSes!

Detailed information on this framework can be found in man zshcontrib, after reading its examples and following through descriptions of default formatting directives, I came up with the following setup mimicking what I had for bash:

autoload -Uz vcs_info
zstyle ':vcs_info:*' enable git
zstyle ':vcs_info:*' formats ' %F{cyan}(%b)%f'
zstyle ':vcs_info:*' actionformats ' %F{cyan}(%b|%a)%f'

This configures vcs_info subsystem to only work with git repositories and skip logic for all other VCSes, and apply desired formatting. The next step was plumbing this to prompt.

Manpage example showed that vcs_info puts details into variable that can be put as ${vcs_info_msg_0_} into PROMPT, but expansions of such variables should be explicitly activated:

setopt PROMPT_SUBST
PROMPT='%B%F{red}%(?..%? )%f%F{white}%1~%f%b${vcs_info_msg_0_} %F{green}¶%f '

The last step tying this all together was automativally executing vcs_info whenever needed. This was achieved by plugging it into precmd shell hook:

precmd () { vcs_info }

That did the trick, now my prompt behaved exacly the same way as in bash.

Another quality-of-life change was having autocompletions. This was done as recommended by manpage:

autoload -Uz compinit
compinit

Other things like setting variables, some helper functions and CDPATH feature to quickly cd into subdirs of predefined directories worked exactly the same as in .bashrc.

macOS Terminal app integration

macOS Terminal app has an option to make new windows/tabs open with the same working directory as the current window/tab. This feature only works if shell notifies Terminal app on its current working directory:

terminal app settings

Though it states that it needs a "file:" URL with properly percent-encoded path, it seems that the following shell command works fine for this:

printf '\e]7;%s\a' $PWD

Now to make zsh issue this command each time directory changes, it can be put into chpwd hook:

chpwd () { printf '\e]7;%s\a' $PWD }

There's one small caveat with chpwd hook though: if new window/tab started with non-default directory and then we start another tab from it without changing directory first, that newly started tab will get default directory, as the previous tab will never run chpwd hook as it didn't change directory. This can be resolved by running command from above not within chpwd hook, but from precmd hook, which is executed before each prompt. Combined with already existing vcs_info it becomes:

precmd () { vcs_info; printf '\e]7;%s\a' $PWD; }

Resulting zsh setup

After above changes I get what I wanted: zsh setup which has all features from my bash setup, feels familiar enough to eliminate friction and allows me to stick with it and learn zsh on my own pace.

zsh session

Final .zshrc file became this:

# Correctly display UTF-8 with combining characters.
if [ "$TERM_PROGRAM" = "Apple_Terminal" ]; then
    setopt combiningchars
fi

disable log

export CLICOLOR=1
export GREP_OPTIONS='--color=auto'
export LESS='-iFXc'

export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"

PAGER=less
export EDITOR=vim
export VISUAL=$EDITOR

bindkey -e
autoload -Uz compinit
compinit

HISTFILE=$HOME/.zsh_history
HISTSIZE=1000
SAVEHIST=3000

setopt AUTO_CONTINUE
setopt HIST_IGNORE_DUPS
setopt APPEND_HISTORY

CDPATH=.:$HOME:$HOME/Repositories

autoload -Uz vcs_info
zstyle ':vcs_info:*' enable git
zstyle ':vcs_info:*' formats ' %F{cyan}(%b)%f'
zstyle ':vcs_info:*' actionformats ' %F{cyan}(%b|%a)%f'
precmd () { vcs_info; printf '\e]7;%s\a' $PWD; }
setopt PROMPT_SUBST
PROMPT='%B%F{red}%(?..%? )%f%F{white}%1~%f%b${vcs_info_msg_0_} %F{green}¶%f '