cd to a directory containing a file

Copying a path to a file and accidentally cd-ing to it is annoying, so I wanted to automatically cd to the directory containing the file.

This is possible with the Function Command Extension Trick:

# enable cd to directory containing file
cd() {
    local parent
    {
      [[ ! -d "$1" ]] && [[ -e "$1" ]] &&
        parent="$(dirname "$1")" &&
        [[ "$parent" != . ]] && [[ -d "$parent" ]] &&
        builtin cd "$parent" 2>/dev/null;
    } ||
    builtin cd "$@"
}

Let's break the command down:

  • { ... }
    • This creates a group command. The exit status of the group is the exit status of the last command inside.
    • All the commands in this group are joined with &&, so if a check fails, the rest of the commands are skipped.
      • Because all the commands in the group are joined with &&, the grouping the commands with { ... } is unnecessary, but I used it for readability.
  • [[ ! -d "$1" ]] && [[ -e "$1" ]]
    • If the target is not a directory and if the target exists
      • This matches both regular files like /etc/issue and special files like /dev/sda
  • parent="$(dirname "$1")"
    • Get the path of the directory that contains the target file
  • [[ "$parent" != . ]]
    • If the containing directory of the target file is not the current directory
      • This avoids accidentally cd-ing to a file in the current directory when you expect it to be a folder
  • [[ -d "$parent" ]]
    • If the containing directory of the target file is an actual directory
      • This prevents cd-ing to a path that does not exist
  • builtin cd "$parent" 2>/dev/null
    • cd to the containing directory of the target file
      • builtin cd calls the cd provided by the shell instead of recursively calling this cd function
  • || builtin cd "$@"
    • If the group command above fails, then the cd target is not a file, so fall back to the regular cd builtin.

After implementing the first version of this function, I found myself frequently taking advantage of the ability to lazily copy full file paths with a double-click from command line output instead of having to meticulously create a selection from one precise character to another.

Extra features

With cd now being a function, the marginal cost of adding more features decreased, so I added some:

# enable cd to directory containing file; cd :/ to visit git root
# cd ..../ becomes ../../../ - every . after the first 2 goes up another directory
cd() {
    local top parent
    { [[ "$1" = ":/" ]] && top="$(command git rev-parse --show-cdup)." && builtin cd "$top"; } || \
    { [[ ! -d "$1" ]] && [[ -e "$1" ]] && parent="$(dirname "$1")" && [[ "$parent" != . ]] && [[ -d "$parent" ]] && builtin cd "$parent" 2>/dev/null; } || \
    { [[ "$1" = '...'* ]] && command -v perl &>/dev/null && builtin cd "$(printf %s "$1" | perl -pe 's/\/(.*$)|(\.)(?=\.\.)/$2$2\/$1/g')"; } || \
    builtin cd "$@"
}
  • I used to frequently cd to the root of a git repository with permutations of cd ../../..
    • Now, I just type cd :/
  • I have some local aliases for preparing a specific shell environment while also setting an iTerm tab color.
    • The default behavior of a bare cd command is to go to the home directory, so I made cd without arguments also strip the tab color

mkcd

It's just what it sounds like: mkdir and cd

mkcd() {
    mkdir -p "$1" && builtin cd "$1"
}

mvcd

It's also just what it sounds like: mv a file and cd to the destination

mvcd() {
    (( $# > 1 )) && [[ -d "${@: -1}" ]] && mv "$@" && builtin cd "${@: -1}"
}

Breakdown:

  • (( $# > 1 ))
    • If there is more than 1 argument (mv requires 2 arguments)
  • [[ -d "${@: -1}" ]]
    • If the last argument is a directory
  • mv "$@"
    • Call mv with all the arguments
  • builtin cd "${@: -1}"
    • cd to the last argument
  • Note: shellcheck complains about "${@: -1}", but it is portable between both bash and zsh.