My Bash setup

In this post I'll try to explain how I set up my Bash shell and show how to obtain a similar configuration for an ever improving workflow.

Table of contents

  1. The shells
  2. ~/.profile
  3. ~/.bashrc

The shells

The most important thing is the one I learnt last, and that is the difference between the shells, from which derives the need for different configuration files.
The first time the terminal application is launched after boot, or when a new SSH connection is established, Bash will create a login interactive shell; every other instance of Bash after the first one is a non-login interactive shell. Finally, scripts run in non-login non-interactive shells.

Login shell

This sources the /etc/profile file first and I deemed it best not to modify this file in any way. Luckily, it then also looks for one of the following readable files and sources the first one it finds:

  • ~/.bash_profile
  • ~/.bash_login
  • ~/.profile

I can definitely create/modify these without fear of messing anything up. I chose profile for name consistency but, from my understanding, they are all equal. This file will be used to define the environment, that usually means defining PATH and the other global variables; these settings are set only once but they will be inherited by all child shells (non-login ones).
Worth mentioning, although I have never found the need for it, is the ~/.bash_logout file, sourced when a login shell is exited; mine is the default one which simply clears the screen (useful in ttys).

Non-Login shell

This shell inherits the profile configuration from login but it actually only reads the ~/.bashrc file.
Here I can set up all the aliases, define the functions and set the options that I wish to be loaded in each new shell, so that any change in the configuration will immediately take effect when a new terminal is launched.

Non-Interactive shell

Scripts run in non-interactive shells (this can be changed by adding the -i option to the shebang) and they do not read any configuration file. However, some variables can still be inherited from parent shells, if set with the export modifier.


~/.profile

As written above, here I set all the variables that I only need to read once; these have values that I'm sure I won't need to change frequently (or ever).
For example, I set the terminal codes to change the font style and color, useful especially when creating the PS1 prompt.

export RESET="\e[00m"

export ITALIC="\e[03m"
export UNDERLINE="\e[04m"
export BLINK="\e[05m"
export HIGHLIGHT="\e[07m"
export HIDDEN="\e[08m"
export CROSS="\e[09m"

export WHITE="\e[01;20m"
export BLACK="\e[00;30m"
export RED="\e[01;31m"
export GREEN="\e[01;32m"
export YELLOW="\e[01;33m"
export BLUE="\e[01;34m"
export MAGENTA="\e[01;35m"
export CYAN="\e[01;36m"

By using export I'm making sure that these variables will be accessible by all child shells.
Next I define default applications and notable directories, to be used in aliases and scripts:

# Default applications
export BROWSER="firefox"
export EDITOR="nvim"
export PAGER="less"
export TERMINAL="alacritty"
# Directories
export CODE_DIR="$HOME/Desktop"
export SCRIPTS_DIR="$CODE_DIR/scripts"
export DOTFILES_DIR="$CODE_DIR/dotfiles"
export WALLPAPERS_DIR="$HOME/Pictures/wallpapers"

Some other options: the output format of the time command, the prompt to display when invoking sudo privileges and the default options for the fzf utility.

export TIMEFORMAT=$'\nreal %3R\tuser %3U\tsys %3S'
export SUDO_PROMPT="Password: "
export FZF_DEFAULT_OPTS="-i --cycle --scroll-off=2 --keep-right"

Finally, configuring the PATH variable to add my local bin and the one containing my custom scripts, defined above, with all of its subfolders.

[[ -d "$HOME/.local/bin" ]] && PATH="$HOME/.local/bin:$PATH"

if [[ -d "$SCRIPTS_DIR" ]]; then
    for subdir in `find "$SCRIPTS_DIR" -type d -not -path '*.git*'`; do
        PATH="$subdir:$PATH"
    done
fi

Now, login and non-login shells should behave at the exact same manner since they both are interactive. For this reason, at the end of my profile I also read the ~/.bashrc file.


~/.bashrc

Here go all the shell's options, aliases, functions and the more "variable" variables but first, a check is made to stop the sourcing of this file if the current shell is not interactive:

# If not running interactively, do nothing.
case $- in
    *i*) ;;
    *) return ;;
esac

Now, the real configuration can begin.

Options

These are all the shell options I enabled. This guide gives a more detailed overview of all of them.

# Disable Ctrl+S and Ctrl+Q (they would freeze/unfreeze the terminal).
stty -ixon
# Set VI mode (pressing ESC key enables NORMAL mode).
set -o vi
# Check and update LINES and COLUMNS after each command.
shopt -s checkwinsize
# Allow usage of comments in the interactive shell. (enabled by default)
shopt -s interactive_comments
# Autocorrect spelling errors when using cd command.
shopt -s cdspell
# Automatically expand directory globs and fix typos whilst completing.
# Note, this works in conjuction with the cdspell option listed above.
shopt -s direxpand dirspell
# Enable the ** globstar recursive pattern in directory expansions.
shopt -s globstar
# Print background jobs that are still running before exiting.
shopt -s checkjobs

History

These are the options related to the shell's history:

# Append to the history file instead of overwriting.
shopt -s histappend
# Save multi-line commands in one history entry.
shopt -s cmdhist
# Set bash history to ignore duplicates and commands starting with spaces.
export HISTCONTROL=ignoreboth
# Number of entries to save in the history file.
HISTFILESIZE=10000
HISTSIZE=10000
# Move history file to cache folder.
export HISTFILE=$HOME/.cache/.bash_history
# Commands to ignore when saving history.
# ? means any command of just one character.
export HISTIGNORE="?:clear:pwd:ls:ll:la:rm*"

Completion

Sourcing these files enables TAB-Completion both for Bash and Git commands.

if ! shopt -oq posix; then
    # Bash Completion
    if [[ -f /usr/share/bash-completion/bash_completion ]]; then
        source /usr/share/bash-completion/bash_completion
    elif [[ -f /etc/bash_completion ]]; then
        source /etc/bash_completion
    fi
    # Git Completion
    if [[ -f /usr/share/bash-completion/completions/git ]]; then
        source /usr/share/bash-completion/completions/git
        export GIT_COMPLETION_SHOW_ALL=1
    fi
fi

Prompt

This is what my prompt normally looks like:

user ~/Desktop/scripts $

And this is that same prompt displaying all the optional info:

1m25s 1 user@home-pc ../foo/bar/viz/bin (main +%) $

The first chunk, in yellow, shows the execution time of the last command; it is only displayed when it exceeds a certain threshold.
Then, in red, the error code of the last command, in case it failed.
In green there's the username and — only if the current shell is accessed via SSH — the hostname; I don't need to always see it if I'm working on my machine, I find it to be a waste of space.
In blue there's the current path, truncated at a depth of 4.
In magenta is displayed some information about the git repository I am currently in.
Finally, the prompt symbol; $ for regular users, # for root.


As you can see it is not that fancy: no colored background, no icons or unicode characters but it does the job without distracting me, and it does not occupy too much space.
This is its declaration; note how it uses colors already defined elsewhere (in my case they are in ~/.profile, as shown above).

PS1="\[$YELLOW\]"'${cmd_time}'             # execution time
PS1+="\[$RED\]\`error_return\`"            # error code
PS1+="\[$GREEN\]\u"                        # username
[[ -n $SSH_CONNECTION ]] && PS1+="@\h"     # hostname
PS1+=" \[$BLUE\]\w"                        # path
PS1+="\[$MAGENTA\]"'$(__git_ps1 " (%s)")'  # git info
PS1+="\[$RESET\] \\$ "                     # prompt symbol

Going backwards, to get the git info to show up, these lines have to be added to the bashrc file:

source /etc/bash_completion.d/git-prompt
# Separator between branch name and all the other indicators.
export GIT_PS1_STATESEPARATOR=' '
# Show unstaged (*) and staged (+) changes.
export GIT_PS1_SHOWDIRTYSTATE=1
# Show if there are any untracked files (%).
export GIT_PS1_SHOWUNTRACKEDFILES=1
# Show if something is stashed ($).
export GIT_PS1_SHOWSTASHSTATE=1
# Hide git prompt if current subdirectory is ignored by repository.
export GIT_PS1_HIDE_IF_PWD_IGNORED=1
# Show differences between HEAD and its upstream.
# "<" behind, ">" ahead, "<>" diverged, "=" no difference.
export GIT_PS1_SHOWUPSTREAM="auto"

To truncate the path at a depth of 4 (or any other N):

PROMPT_DIRTRIM=4

To display the error code of the last command I invoke a function called error_return. This has to be declared somewhere in the bashrc or sourced from any other file. The code for it is pretty straight forward:

function error_return {
    retval=$?        # exit status
    case $retval in
        0|130|146) ;; # show nothing if success, Ctrl+C or Ctrl+Z
        *) echo "$retval " ;; # in any other case, print error code
    esac
}

Now, displaying the execution time of the command is the trickiest part.
It needs these two functions to save timestamp of start and end, calculate the difference and format the output string to be displayed.

# Get time of execution start.
function cmd_timer_start {
    cmd_start=${timer:=$SECONDS}
}

function cmd_timer_stop {
    # Get time of execution end.
    cmd_time=$(( SECONDS - cmd_start ))
    # Get minutes and seconds of running time.
    local min=$((cmd_time / 60))
    local sec=$((cmd_time % 60))
    # Format output string based on the values of minutes and seconds.
    if [[ $min -gt 0 ]]; then
        if [[ $sec -gt 0 ]]; then
            cmd_time="${min}m${sec}s "  # '1m25s '
        else
            cmd_time="${min}m "         # '2m '
    else
        # Only show output if command took more than 5 seconds to run.
        if [[ $sec -ge 5 ]]; then
            cmd_time="${sec}s "         # '34s '
        else
            unset cmd_time              # empty
    fi
    unset timer
}

Then, these lines have to be added to the file to let Bash know it has to run these two commands:

# Start the timer at the beginning of every command.
trap 'cmd_timer_start' DEBUG

# Stop the timer at the end of every command, before printing the prompt.
if [[ -z $PROMPT_COMMAND ]]; then
    PROMPT_COMMAND="cmd_timer_stop"
elif [[ $PROMPT_COMMAND != *cmd_timer_stop ]]; then
    PROMPT_COMMAND="$PROMPT_COMMAND; cmd_timer_stop"
fi

Aliases (~/.bash_aliases)

I usually keep these in a separate file and source it in my bashrc, just to keep things tidier.

if [[ -f $HOME/.bash_aliases ]]
    then source $HOME/.bash_aliases
fi

Over the years I have collected a hundred different aliases to ease and speed up my work; some are of my invention, others (most) are taken from various sources online (blog posts, YouTube videos, tutorials and guides, etc).
What follows is a list of some of the aliases I use/am proud of the most.

alias ..='cd ..'
alias ls='ls --group-directories-first --color=auto -q'
alias la='ls -Av'       # all files
alias ll='ls -Aohv'     # all files, columns
alias lt='ll -tr'       # sort by modification time
alias lsize='ll -Sr'    # sort by size

alias mkdir='mkdir -p'
alias cp='cp -r'
alias mv='mv -iv'
alias du='du -h'
alias df='df -Th'
alias grep='grep --color=auto -i --exclude-dir=.git'
alias pgrep='pgrep -i'
alias hg='history | grep'
alias diff='git diff --no-index --color-words'
alias please='sudo `history -p !!`' # repeat last command with sudo

alias reload='exec bash -l' # reload shell to apply config changes
alias profile='$EDITOR $HOME/.profile'
alias bashrc='$EDITOR $HOME/.bashrc'
alias aliases='$EDITOR $HOME/.bash_aliases'
alias functions='$EDITOR $HOME/.bash_functions'

# typos
alias mkdri='mkdir'
alias mdkir='mkdir'
alias dc='cd'
alias moer='more'
alias exti='exit'

Functions (~/.bash_functions)

The same considerations made above for the aliases also apply here: separate file, not all mine.

Here are some of them.

 # Extract any compressed file.
function extract {
    archive="$1"
    if [[ -f "$archive" ]] ; then
        case "$archive" in
            *.tar.gz)  tar xvzf "$archive" > /dev/null  ;;
            *.tar.xz)  tar -xf "$archive" > /dev/null   ;;
            *.rar)     rar x "$archive" > /dev/null     ;;
            *.gz)      gunzip "$archive" > /dev/null    ;;
            *.tar)     tar xvf "$archive" > /dev/null   ;;
            *.tgz)     tar xvzf "$archive" > /dev/null  ;;
            *.zip)     unzip "$archive" > /dev/null     ;;
            *.7z)      7z x "$archive" > /dev/null      ;;
            *)         echo "Don't know how to extract '$archive'." ;;
        esac
    else
        echo "Archive '$archive' not found."
    fi
}

# Create .TAR.GZ from given directory.
function maketar { tar cvzf "${1%%/}.tar.gz" "${1%%/}/" }

# Create ZIP of a file or folder.
function makezip { zip -r "${1%%/}.zip" "$1" }

# Find the given string in the files of the current directory.
function findstring { grep -i --color=auto -rnT -B1 -A1 './' -e "$1" }

# Open given file or directory with the default application.
function open {
    # If no argument given, open current folder in file explorer.
    [[ -n $1 ]] && element="$1" || element=.
    xdg-open "$element" &>/dev/null & disown
}