Skip to content

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 -a lists two names nobody made. The directory holds .env, src and x, and ls -a adds . and ... ls -A, "almost all", is the same list without those two.
  • They are the directory and its parent. test -ef is true when two paths lead to the same file, and src/.. 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 -R puts a copied directory inside all under the source's last name, and the last name of src/. is ., which inside all is all itself. What lands there is what src holds, .hidden included.
  • src/* is not the same. The wildcard left .hidden behind, 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, .txt and src/.hidden: ls and *. Nothing else in the run does. cat .env reads the file by name, and find, 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.
  • *.txt does not match .txt. A wildcard never stands in for a leading dot; the pattern has to spell the dot out. To the shell, .txt is 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/bash 3.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 dotglob lets * 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. hello is right there and executable, and typing its name gives status 127, command not found; command -v does not find it either. ./hello contains 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 named ls left in any directory would run whenever you typed ls there.
  • bash setup.sh runs the file in a new shell; . ./setup.sh runs it in this one. Run by a child bash, the variable and the cd end with the child: from is unset and the shell is still in top. Read by ., both stay. That is why a script meant to change your shell, such as a Python virtualenv's activate, is run with ..
  • With no slash, . searches $PATH first. . vars.sh read ./vars.sh while bin was not on $PATH: bash falls back to the current directory when the search finds nothing. Once bin was first on $PATH, the same command read bin/vars.sh. source is bash's other name for . and searched the same way.
  • In POSIX mode, bash 5.2 does not fall back. bash --posix on Linux stops with file not found, status 1; the Mac's bash 3.2 still reads ./vars.sh. . ./vars.sh names 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.

$ mkdir slash; cp -R src/ slash; ls -A slash
src

$ mkdir dot; cp -R src/. dot; ls -A dot
.hidden
y

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. BSD cp -R src/ slash copies what is in src. GNU cp copies src itself, into slash/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, and setopt GLOB_DOTS all of them.
  • zsh's . and source are two builtins. . looks only in the directories of $path, the array zsh ties to $PATH, so . vars.sh fails with status 127 while vars.sh sits in the current directory. source looks in the current directory first. With bin on $path, the same name gave . the file bin/vars.sh and source the file ./vars.sh. In bash, both names read bin/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/* is src/y, src/.* is src/.hidden, and .* never gave . or ...
  • command -v exits 127 for a name it cannot find, where bash and zsh exit 1. ./hello runs as it does everywhere.
  • source reads the path it is given and does not search $PATH. With bin first on $PATH, source vars.fish still read ./vars.fish.
  • . still works in fish 4.3.2, with status 0 and no warning, but the fish manual for source calls . deprecated and says it will be removed. Write source.
  • fish has no brace ranges. {1..5} stays {1..5}; seq 1 5 inside ( ) gives the numbers. For extensions, path extension returns .gz, dot included, and path change-extension '' archive.tar.gz gives archive.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

See also