I generally keep my development projects under a common directory like ~/dev/
or ~/projects/
. Here is bash shell script code which I use to quickly jump into and between projects on the command line. It includes auto completion, which is a pretty important part of its usefulness. You can put in your ~/.bashrc
file and adapt the PROJECTS_PATH
variable to suit your own needs.
# Project cd
PROJECTS_PATH=~/dev
# Main command function
pcd() {
local args=() op=cd opt OPTIND
# Option parsing ergonomics: allow options anywhere in command line
while [ $# -gt 0 ]; do
while getopts 'p' opt; do
[ $opt = p ] && op=pushd || return 1
done
shift $((OPTIND-1)) && OPTIND=1
[ $# -gt 0 ] && args+=("$1") && shift
done
local path="$PROJECTS_PATH/${args[0]}/${args[1]}"
if [ "${args[0]}" = .. ]; then
local gitroot=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -d "$gitroot" ]; then
path="$gitroot/${args[1]}"
fi
fi
if [ -d "$path" ]; then
$op "$path"
[ $op = cd ] && pwd
fi
}
# Completion function for pcd
_pcdcomp() {
[ "$1" != pcd ] && return 1
COMPREPLY=()
# Current word being completed
local word=${COMP_WORDS[$COMP_CWORD]}
# IFS must be set to a single newline so compgen suggestions with spaces work
local IFS=$'\n' pdir_idx= sdir_idx= i comp_opt=$(compgen -W '-p' -- "$word")
# Scan command line state
for ((i=1; i<${#COMP_WORDS[*]}; i++)); do
if [ "${COMP_WORDS[$i]:0:1}" != - ]; then
[ -z "$pdir_idx" ] && pdir_idx=$i && continue
[ -z "$sdir_idx" ] && sdir_idx=$i
elif [ "${COMP_WORDS[$i]}" = '-p' -a $i -ne $COMP_CWORD ]; then
comp_opt=
fi
done
# By default, all completions are suffixed with a space, so cursor jumps to
# next command argument when a completion is selected uniquely, except for
# the project subdir argument. We handle this manually, since adjusting the
# 'nospace' option dynamically with compopt has proven to be unreliable.
local add_space_to_completions=1
# Provide completions according to command line state
if [ $COMP_CWORD = ${pdir_idx:--1} ]; then
# State: project argument
if [ "${word:0:1}" = . ]; then
COMPREPLY=('..')
else
COMPREPLY=($(cd "$PROJECTS_PATH" && compgen -X \*.git -d -- "$word"))
fi
if [ "$comp_opt" ]; then
COMPREPLY+=("$comp_opt")
fi
elif [ $COMP_CWORD = ${sdir_idx:--1} ]; then
# State: project subdir argument
local project_root="$PROJECTS_PATH"/"${COMP_WORDS[$pdir_idx]}" git_root
if [ "${COMP_WORDS[$pdir_idx]}" = .. ]; then
git_root=$(git rev-parse --show-toplevel 2>/dev/null) && project_root=$git_root
fi
COMPREPLY=($(cd "$project_root" 2>/dev/null && compgen -X \*.git -S/ -d -- "$word"))
if [ ${#COMPREPLY[*]} -gt 0 ]; then
# Avoid space after subdir argument, to allow for drilling while completing
add_space_to_completions=
elif [ -z "$word" ]; then
# No available subdirs for selected project and empty current arg, offer '.' and options
COMPREPLY=('.')
if [ "$comp_opt" ]; then
COMPREPLY+=("$comp_opt")
fi
fi
elif [ "$comp_opt" ]; then
# State: end of regular args or other
COMPREPLY+=("$comp_opt")
fi
# Post process, do shell safe name quoting and possibly add space to each completion:
for ((i=0; i<${#COMPREPLY[*]}; i++)); do
COMPREPLY[$i]=$(printf "%q${add_space_to_completions:+ }" "${COMPREPLY[$i]}")
done
}
# Bind completion function to command:
complete -o nospace -F _pcdcomp pcd
You can also find the code on Github: https://github.com/oyvindstegard/pcd
How to use
After loading the code, type pcd
and hit TAB to see completion of all project directories. Hit ENTER to jump to a selected project. It will also complete into a project sub-directory as optional second argument. To jump up to a project root directory you can use pcd ..
– this works if it is a git repository. You can combine it with a second arg to drill into another directory tree of the same project. Lastly, you can use the option -p
to use pushd
instead cd
when changing directory.

Notes on implementation of a completion function
As you may have noticed, the code for the programmable completion is a lot more complex than the actual command. In my experience, getting ergonomically pleasing and sufficiently intelligent command line completion tend to become more finicky than what I envision initially. The command line, argument types and cursor position combined constitute several intermediate and final states to handle. Typically, the current word to complete will depend on both preceding and succeeding command line context.
Things to consider
- Adding command options in addition to regular arguments complicates matters. You will have more state to handle, and you shouldn’t provide completion for the same option twice, unless that is valid for your command.
- You need to parse the entire command line state every time your completion function is invoked, so you have good enough contextual information about what completions to provide at the cursor. Don’t offer to complete something which would produce an invalid command.
- You really need to learn exactly how the shell behaves with regard to the completion variables, the completion related built-in commands and options.
- Avoid slow/heavy commands in your completion function, because user experience will suffer greatly when pressing TAB causes the shell to hang for a long time without any feedback. Completion data which is expensive to compute or fetch should be cached.
- When debugging a completion function, you don’t really want to output anything directly to the terminal, as it will visually interfere with the command your are testing and printed completions, causing a jarring experience. Instead, what I recommend is to append whatever debug logging you have to a dedicated file and
tail
that file in another terminal window while testing.