What a dot means to the shell¶
Level: 101 · you type cd .. and ./build.sh without thinking, and have a .env or two
One line: The shell reads a dot by where it stands. In a path, . and .. are entries every directory lists. As the first word of a command, . is a builtin that runs a file inside the current shell. At the start of a name, a dot hides that name from ls and from *, and from nothing else.
cd .., ./build.sh, . venv/bin/activate, .env, cp -R src/. dest: five dots, and the shell reads them three ways. None of them is decided by the dot itself, only by what is around it. The scripts below take each position in turn, on Linux and on a Mac, then in zsh and fish.
Measured¶
In a path, . and .. are entries¶
Verified output of dot_entries_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.
$ ls -a
.
..
.env
src
x
$ ls -A
.env
src
x
$ [ src/.. -ef . ] && echo "src/.. is this directory"
src/.. is this directory
$ (cd /; cd ..; pwd)
/
$ mkdir all; cp -R src/. all; ls -A all
.hidden
y
$ mkdir some; cp -R src/* some; ls -A some
y
ls -alists two names nobody made. The directory holds.env,srcandx, andls -aadds.and...ls -A, "almost all", is the same list without those two.- They are the directory and its parent.
test -efis true when two paths lead to the same file, andsrc/..leads back to the directory the script stands in. The root has no parent, so its..leads to itself:cd /; cd ..is still at/. - So a path can end in
..cp -Rputs a copied directory insideallunder the source's last name, and the last name ofsrc/.is., which insideallisallitself. What lands there is whatsrcholds,.hiddenincluded. src/*is not the same. The wildcard left.hiddenbehind, which is the next position.
At the start of a name, a dot hides it from ls and *¶
Verified output of dot_hidden_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.
$ ls
notes.txt
src
x
$ echo *
notes.txt src x
$ echo *.txt
notes.txt
$ echo .*
.env .txt
$ shopt globskipdots; echo "status $?"
globskipdots on
status 0
$ cat .env
token=abc
$ find . -type f | sort
./.env
./.txt
./notes.txt
./src/.hidden
./src/y
./x
$ shopt -s dotglob; echo * src/*; shopt -u dotglob
.env .txt notes.txt src x src/.hidden src/y
Verified output of dot_hidden_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.
$ ls
notes.txt
src
x
$ echo *
notes.txt src x
$ echo *.txt
notes.txt
$ echo .*
. .. .env .txt
$ shopt globskipdots; echo "status $?"
dot_hidden_sh.sh: line 9: shopt: globskipdots: invalid shell option name
status 1
$ cat .env
token=abc
$ find . -type f | sort
./.env
./.txt
./notes.txt
./src/.hidden
./src/y
./x
$ shopt -s dotglob; echo * src/*; shopt -u dotglob
.env .txt notes.txt src x src/.hidden src/y
- Two things skip
.env,.txtandsrc/.hidden:lsand*. Nothing else in the run does.cat .envreads the file by name, andfind, which walks the directory itself, lists all six files. A leading dot keeps a name out of the way; it does not protect the file. *.txtdoes not match.txt. A wildcard never stands in for a leading dot; the pattern has to spell the dot out. To the shell,.txtis a hidden name, not a name with an extension..*is where the two bashes differ. bash 5.2 on Linux expands it to.env .txt./bin/bash3.2 on a Mac also matches.and.., because they too are names that start with a dot. bash 5.2 has an option for this,globskipdots, and it is on in a fresh shell; bash 3.2 does not know the option. Under bash 3.2 a command given.*is also given.., the parent directory, and a recursive one walks into it.shopt -s dotgloblets*match hidden names, in both bashes, and still not.and...
As the first word, . runs a file in this shell¶
The script's directory is top, holding an executable hello, a setup.sh that sets from and changes into sub, a vars.sh that sets from=./vars.sh, and a bin/vars.sh that sets from=bin/vars.sh.
Verified output of dot_builtin_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.
$ hello 2>/dev/null; echo "status $?"
status 127
$ command -v hello || echo "not on PATH, status $?"
not on PATH, status 1
$ ./hello
hello from ./hello
$ bash setup.sh; echo "from=${from-unset}, in ${PWD##*/}"
from=unset, in top
$ . ./setup.sh; echo "from=$from, in ${PWD##*/}"; cd ..
from=setup.sh, in sub
$ . vars.sh; echo "from=$from"
from=./vars.sh
$ bash --posix -c '. vars.sh; echo "from=$from"'; echo "status $?"
bash: line 1: .: vars.sh: file not found
status 1
$ PATH="$PWD/bin:$PATH"; . vars.sh; echo "from=$from"
from=bin/vars.sh
$ . ./vars.sh; echo "from=$from"
from=./vars.sh
$ source vars.sh; echo "from=$from"
from=bin/vars.sh
Verified output of dot_builtin_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.
$ hello 2>/dev/null; echo "status $?"
status 127
$ command -v hello || echo "not on PATH, status $?"
not on PATH, status 1
$ ./hello
hello from ./hello
$ bash setup.sh; echo "from=${from-unset}, in ${PWD##*/}"
from=unset, in top
$ . ./setup.sh; echo "from=$from, in ${PWD##*/}"; cd ..
from=setup.sh, in sub
$ . vars.sh; echo "from=$from"
from=./vars.sh
$ bash --posix -c '. vars.sh; echo "from=$from"'; echo "status $?"
from=./vars.sh
status 0
$ PATH="$PWD/bin:$PATH"; . vars.sh; echo "from=$from"
from=bin/vars.sh
$ . ./vars.sh; echo "from=$from"
from=./vars.sh
$ source vars.sh; echo "from=$from"
from=bin/vars.sh
- A bare name is searched for in
$PATH, and the current directory is not in it.hellois right there and executable, and typing its name gives status 127, command not found;command -vdoes not find it either../hellocontains a slash, so the shell does not search at all and runs that file. The current directory is left out on purpose: otherwise a program namedlsleft in any directory would run whenever you typedlsthere. bash setup.shruns the file in a new shell;. ./setup.shruns it in this one. Run by a child bash, the variable and thecdend with the child:fromis unset and the shell is still intop. Read by., both stay. That is why a script meant to change your shell, such as a Python virtualenv'sactivate, is run with..- With no slash,
.searches$PATHfirst.. vars.shread./vars.shwhilebinwas not on$PATH: bash falls back to the current directory when the search finds nothing. Oncebinwas first on$PATH, the same command readbin/vars.sh.sourceis bash's other name for.and searched the same way. - In POSIX mode, bash 5.2 does not fall back.
bash --posixon Linux stops withfile not found, status 1; the Mac's bash 3.2 still reads./vars.sh.. ./vars.shnames one file in every shell and every mode.
Anywhere else, a dot is a character¶
Verified output of dot_literal_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.
$ f=archive.tar.gz; echo "${f%.*} ${f%%.*} ${f##*.}"
archive.tar archive gz
$ echo {1..5}
1 2 3 4 5
$ echo {01..03}
01 02 03
Verified output of dot_literal_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.
$ f=archive.tar.gz; echo "${f%.*} ${f%%.*} ${f##*.}"
archive.tar archive gz
$ echo {1..5}
1 2 3 4 5
$ echo {01..03}
1 2 3
In ${f%.*} the dot is literal text and * is the wildcard: % cuts the shortest match from the end, %% the longest, and ## the longest from the start. In {1..5} the two dots belong to the braces: the shell writes out every number before the command runs. bash 5.2 keeps the zeros of {01..03}, and bash 3.2 drops them.
On a Mac¶
Three differences have already shown up above, all of them bash 3.2's: .* matches . and .., bash --posix still reads a file from the current directory, and {01..03} loses its zeros. One more is cp's, and it is the reason to write src/.:
Verified output of dot_cp_slash_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.
Verified output of dot_cp_slash_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.
$ mkdir slash; cp -R src/ slash; ls -A slash
.hidden
y
$ mkdir dot; cp -R src/. dot; ls -A dot
.hidden
y
- A trailing slash means different things to the two
cps. BSDcp -R src/ slashcopies what is insrc. GNUcpcopiessrcitself, intoslash/src.src/.means the contents to both. - Not measured here: Finder also hides names that start with a dot, and ⌘⇧. toggles them. A Mac writing to a FAT or exFAT drive stores extended attributes in
._files beside the originals, hidden from the Mac by the same leading dot and visible to anything that ignores the convention. No script in this library can reach Finder or an exFAT drive, so neither sentence is checked by CI.
In zsh and fish¶
zsh agrees with bash about ./ and about . running a file in the current shell. It disagrees about where . looks:
Verified output of dot_shell_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.
$ echo *
notes.txt src x
$ echo .*
.env
$ echo *(D)
.env notes.txt src x
$ setopt GLOB_DOTS; echo *; unsetopt GLOB_DOTS
.env notes.txt src x
$ cd run
$ command -v hello || echo "not on PATH, status $?"
not on PATH, status 1
$ ./hello
hello from ./hello
$ . vars.sh; echo "status $?"
(eval):.:1: no such file or directory: vars.sh
status 127
$ source vars.sh; echo "from=$from"
from=./vars.sh
$ path=($PWD/bin $path); . vars.sh; echo "from=$from"
from=bin/vars.sh
$ source vars.sh; echo "from=$from"
from=./vars.sh
$ f=archive.tar.gz; echo "${f%.*} ${f%%.*} ${f##*.}"; echo "${f:r} ${f:e}"
archive.tar archive gz
archive.tar gz
$ echo {01..03}
01 02 03
.*never matches.and..in zsh, on either machine.*(D)lets one glob match hidden names, andsetopt GLOB_DOTSall of them.- zsh's
.andsourceare two builtins..looks only in the directories of$path, the array zsh ties to$PATH, so. vars.shfails with status 127 whilevars.shsits in the current directory.sourcelooks in the current directory first. Withbinon$path, the same name gave.the filebin/vars.shandsourcethe file./vars.sh. In bash, both names readbin/vars.sh. ${f:r}and${f:e}are zsh's modifiers for the name without its last extension, and the extension.{01..03}keeps its zeros.
fish matches a hidden name only when the pattern itself starts with a dot, and its source reads a file without searching anywhere:
Verified output of dot_shell_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.
$ echo *
notes.txt src x
$ echo .*
.env
$ echo src/*
src/y
$ echo src/.*
src/.hidden
$ cd run
$ command -v hello; or echo "not on PATH, status $status"
not on PATH, status 127
$ ./hello
hello from ./hello
$ fish --no-config vars.fish; set -q from; or echo "from is unset"
from is unset
$ source vars.fish; echo "from=$from"
from=./vars.fish
$ set -e from; set PATH $PWD/bin $PATH; source vars.fish; echo "from=$from"
from=./vars.fish
$ set -e from; . vars.fish; echo "status $status, from=$from"
status 0, from=./vars.fish
$ echo {1..5}
{1..5}
$ echo (seq 1 5)
1 2 3 4 5
$ path extension archive.tar.gz
.gz
$ path change-extension '' archive.tar.gz
archive.tar
- A wildcard skips hidden names, and a pattern that starts with a dot finds them.
src/*issrc/y,src/.*issrc/.hidden, and.*never gave.or... command -vexits 127 for a name it cannot find, where bash and zsh exit 1../helloruns as it does everywhere.sourcereads the path it is given and does not search$PATH. Withbinfirst on$PATH,source vars.fishstill read./vars.fish..still works in fish 4.3.2, with status 0 and no warning, but the fish manual forsourcecalls.deprecated and says it will be removed. Writesource.- fish has no brace ranges.
{1..5}stays{1..5};seq 1 5inside( )gives the numbers. For extensions,path extensionreturns.gz, dot included, andpath change-extension '' archive.tar.gzgivesarchive.tar.
| bash | zsh | fish | |
|---|---|---|---|
let * match hidden names |
shopt -s dotglob |
*(D), or setopt GLOB_DOTS |
write the dot: .* |
does .* match . and ..? |
bash 5.2 no, bash 3.2 yes | no | no |
| run a file in this shell | . ./f or source ./f |
. ./f or source ./f |
source ./f |
. f with no slash |
$PATH, then the current directory, except bash --posix 5.2 |
$path only; source f tries the current directory first |
source f reads ./f, never $PATH |
run prog from this directory |
./prog |
./prog |
./prog |
| cut off the last extension | ${f%.*} |
${f%.*} or ${f:r} |
path change-extension '' $f |
| the numbers 1 to 5 | {1..5} |
{1..5} |
(seq 1 5) |
If you are coming from another library¶
- Encodings. The first two bytes ↗ starts from the same
./: the slash skips the$PATHsearch, and then the kernel reads the file's first bytes,#!, to decide what runs it. - Rust.
fd,rg,bat: what the Rust rewrites actually bought ↗ measures two search tools that skip hidden names by default, aslsand*do, and the flags that bring them back.
See also¶
- The first letter of
ls -lis the type — what kind of file a name is; this page is about what its spelling does - Which startup file runs — the hidden files each shell reads into itself when it starts
- The full stop and its look-alikes ↗ — U+002E itself, and the characters that look like it and are not a dot to any shell
- Bash Reference Manual: Bourne Shell Builtins ↗ —
.and its search of$PATH - Bash Reference Manual: Filename Expansion ↗ — the leading dot,
dotglobandglobskipdots - zsh: Shell Builtin Commands ↗ —
.andsource, side by side - fish: source ↗
- fish: path ↗