dirname() in Bash: Get the Directory Part of a Path
dirname is the other half of basename(): given a path, return everything except the last component. Scripts use it to find their own directory, to create the parent of an output file, or to group files by folder. PHP has dirname(), Unix has the dirname command, and Bash can do it with ${path%/*}. But you must deal with the cases where that expansion gives the wrong answer.
Those cases are the main reason to write a function. The bare expansion gets "file with no slash", "/vmlinuz" and trailing slashes wrong, and so did my 2009 version in several places. The rewrite below matches GNU coreutils on every path I tried.
The function
# dirname() -- print PATH with its last component removed
# Usage: dirname path
dirname() {
[[ ${1-} == -- ]] && shift
(( $# == 1 )) || return 2
local dir=$1
# trailing slashes do not count: "a/b/" is the same as "a/b"
while [[ $dir == */ ]]; do dir=${dir%/}; done
if [[ -z $dir ]]; then
# "" has no directory part; "/", "//", "///" are the root
if [[ -n $1 ]]; then printf '/\n'; else printf '.\n'; fi
return 0
fi
# no slash left: the file is in the current directory
if [[ $dir != */* ]]; then
printf '.\n'
return 0
fi
dir=${dir%/*}
# "a//b" -> "a", "/usr" -> "/"
while [[ $dir == */ ]]; do dir=${dir%/}; done
printf '%s\n' "${dir:-/}"
}How it works
The steps follow the POSIX description of dirname:
- Remove trailing slashes.
/usr/bin/names the directorybin, so its parent is/usr, not/usr/bin. - If nothing is left, the input was either empty (answer
.) or made only of slashes (answer/). - If there is no slash left, the path is a bare name in the current directory, so the answer is
.. - Otherwise remove the last component with
${dir%/*}, the shortest suffix starting with a slash. - Remove any slashes now at the end, so
a//bgivesa. If that empties the string, the parent was the root:${dir:-/}prints/.
The leading -- check matters more here than anywhere else. The common idiom for a script's own directory is $(cd -- "$(dirname -- "$0")" && pwd). Without that line, the function sees two arguments, returns 2 and prints nothing. cd -- "" then succeeds without moving, and the "script directory" silently becomes whatever directory you ran the script from. I tried it with the -- line removed, and got /tmp instead of /usr/share/doc/bash in the example below.
Usage
$ dirname /usr/bin/sort
/usr/bin
$ dirname /usr/bin/
/usr
$ dirname sort
.
$ dirname /
/
$ dirname //
/
$ dirname "my dir/my file"
my dir
$ dirname; echo $?
2And the idiom mentioned above, with a fixed path used in place of $0:
$ script=/usr/share/doc/bash/README
$ cd /tmp
$ dir=$(cd -- "$(dirname -- "$script")" && pwd); echo "$dir"
/usr/share/doc/bashIn a real script use "${BASH_SOURCE[0]}" rather than $0. It still points at the right file when the script is sourced.
Compared with coreutils dirname
Here is the function against /usr/bin/dirname (GNU coreutils 8.30) and against the 2009 version. Brackets show the exact output:
| Path | Function | coreutils | 2009 |
|---|---|---|---|
/usr/bin/sort | [/usr/bin] | [/usr/bin] | [/usr/bin] |
/usr/bin/ | [/usr] | [/usr] | [/usr/bin] |
/usr/bin// | [/usr] | [/usr] | [/usr/bin/] |
/usr | [/] | [/] | [/] |
/ | [/] | [/] | [/] |
// | [/] | [/] | [/] |
/// | [/] | [/] | [//] |
sort | [.] | [.] | [.] |
'' (empty) | [.] | [.] | [.] |
../sort | [..] | [..] | [..] |
a//b | [a] | [a] | [a/] |
a/b/ | [a] | [a] | [a/b] |
/usr//bin | [/usr] | [/usr] | [/usr/] |
'/srv/[ab]' | [/srv] | [/srv] | [/srv/[ab] |
'/srv/a*b/c' | [/srv/a*b] | [/srv/a*b] | [/srv/a*b] |
///usr | [/] | [/] | [//] |
The function and coreutils agree on all 21 paths in my test (the table leaves out ., .., ./sort, //usr and a path with spaces, which also agree). POSIX allows a system to treat a leading // specially and return //. GNU returns /, and so does the function.
What was wrong with the 2009 version
dirname_2009() {
local dir="${1%${1##*/}}";
[ "${dir:=./}" != "/" ] && dir="${dir%?}";
echo "$dir";
}It removed the last component by deleting ${1##*/} from the end, then cut one more character off for the slash. That breaks in three ways:
- Trailing slashes. For
/usr/bin/the "last component" is empty, so nothing was removed and the result was/usr/bin, the path itself. - Repeated slashes. Only one character was removed, so
a//bgavea/and///gave//. - Unquoted inner pattern.
${1%${1##*/}}uses the file name as a glob. A name like[ab]is a bracket expression that does not match the literal text "[ab]", so nothing was removed. The%?step then cut the last character off the file name:/srv/[ab]became/srv/[ab.
The simple cases (/usr/bin/sort, sort, /) were right, which is why the bugs went unnoticed for so long.
Pitfalls
The function shadows the real command
Functions take priority over commands on PATH, so once this is defined every dirname in the script, including in sourced files, runs the function. GNU dirname accepts several paths at once and a -z option. The function does not:
$ type -t dirname
function
$ dirname /etc/hosts /var/log/messages; echo $?
2
$ command dirname /etc/hosts /var/log/messages
/etc
/var/logUse command dirname to run the binary, or give your function a different name if the script sources code you do not control.
The bare expansion
Inside a loop over thousands of files, the cost of the function's subshell adds up. Measured on one machine, 1000 calls took 0.788 s with coreutils, 0.249 s with the function and 0.002 s with ${p%/*}. The expansion is fine when you know the path contains a slash that is not the first character. But know the cases where it fails:
$ f=/etc/ssh/sshd_config
$ printf '%s\n' "${f%/*}"
/etc/ssh
$ f=sshd_config; printf '[%s]\n' "${f%/*}"
[sshd_config]
$ f=/vmlinuz; printf '[%s]\n' "${f%/*}"
[]A bare name comes back unchanged instead of ., and a file in the root comes back empty instead of /. Paths from find /some/dir are safe. Paths typed by a user are not.
Other details
dirname works on the string only. It does not resolve .. or symlinks and does not check that anything exists. Use cd ... && pwd or realpath for that. The function runs in Bash 3.2 and is safe under set -u. PHP's version, which also has a levels argument, is documented on the dirname() manual page.
History of this snippet
The April 2009 version removed the file name with an unquoted pattern, removed one character for the slash, and ignored trailing and repeated slashes. The rewrite follows the POSIX steps and was compared against coreutils output for every path above.
Related functions
- basename(): the last component of a path
- emptydir(): check whether a directory is empty
- Reading a file line by line: process lists of paths safely
- trap and cleanup: remove temporary directories on exit