My Bash setup
· 14 min · #development
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
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
}