opscape

Bash scripting guide · part 2 of 32

Running scripts

The four ways to start a script (./script, bash script, PATH, source), what each one changes, and the errors you get when one goes wrong.

A shell script is a text file of commands. There are four common ways to run one, and they differ in two things that matter: which program interprets the file, and whether it runs in a new process or in your current shell.

How Interpreter Process
./deploy.sh the shebang line new child process
bash deploy.sh bash, named by you new child process
deploy.sh (found in PATH) the shebang line new child process
source deploy.sh or . deploy.sh your current shell the current shell

Running a file by its path

To run a script as a program it needs the execute permission bit, and you have to name it by a path. A bare name like deploy.sh is looked up only in the directories listed in PATH, never in the current directory (unlike Windows cmd.exe).

A new file is not executable

hello.sh
#!/usr/bin/env bash
echo "hello from $0"
./hello.sh; echo "exit status: $?"
ls -l hello.sh
output
/bin/bash: line 1: ./hello.sh: Permission denied
exit status: 126
-rw-r--r-- 1 dev dev 41 Oct 31 20:22 hello.sh

Files are created without the execute bit, so the kernel refuses with Permission denied and bash returns status 126, meaning "found but could not execute". ls -l shows -rw-r--r--: read and write, no x.

Make it executable and run it

hello.sh
#!/usr/bin/env bash
echo "hello from $0"
chmod +x hello.sh
./hello.sh
hello.sh; echo "exit status: $?"
output
hello from ./hello.sh
/bin/bash: line 3: hello.sh: command not found
exit status: 127

With chmod +x, ./hello.sh works and $0 holds the path it was called by. Without the ./, bash searches PATH, does not find it and reports command not found with status 127. The ./ is how you say "this file, here".

Naming the interpreter yourself

bash hello.sh starts bash and tells it to read the file. No execute bit is needed, because the kernel is executing /usr/bin/bash, not your file, and the shebang is just a comment. That makes it handy for a quick run of a script you have not chmoded, and a trap when the interpreter you name is not the one the author intended.

The interpreter you name wins over the shebang

which.sh
#!/bin/bash
if [[ -n $BASH_VERSION ]]; then echo "bash $BASH_VERSION"; else echo "not bash"; fi
bash which.sh
sh which.sh
output
bash 5.2.37(1)-release
which.sh: 2: [[: not found
not bash

bash which.sh runs fine without the execute bit. sh which.sh runs the same file under dash, which does not understand [[: it prints [[: not found and falls into the else branch. Run scripts the way their shebang says, or simply as ./script.

Two useful flags while developing: bash -n script parses the file without running anything, catching syntax errors, and bash -x script prints each command after expansion before it runs (see Debugging bash scripts).

Check syntax without running anything

broken.sh
#!/usr/bin/env bash
echo "starting"
for f in *.txt; do
  echo "$f"
echo "done"
bash -n broken.sh
explain
outputexit 2
broken.sh: line 6: syntax error: unexpected end of file

Bash reaches the end of the file still inside the for loop and reports unexpected end of file with status 2. The line number points at the end, not at the loop missing its done, so read upward from there. starting was never printed: -n parses but does not execute. Useful as a pre-commit check.

Trace each command as it runs

bash -x scripts/greet.sh Ana
explain
output
+ echo 'Hello, Ana!'
Hello, Ana!

Every command is echoed to stderr with a + prefix after variables are expanded, so you see that ${1:-world} became Ana. Note that bash -x ran a #!/bin/sh script under bash; for a faithful trace of a POSIX script use sh -x.

Running scripts from PATH

To call your own scripts by name from anywhere, put them in a directory that is in PATH. ~/.local/bin and ~/bin are the usual personal choices; Debian's default ~/.profile adds both to PATH when they exist at login. Drop the .sh extension if you like: the shebang decides the interpreter, not the name.

Install a script into a PATH directory

mkdir -p ~/.local/bin
cp scripts/greet.sh ~/.local/bin/greet
export PATH="$HOME/.local/bin:$PATH"
greet
type greet
output
Hello, world!
greet is hashed (/home/dev/.local/bin/greet)

Once the directory is in PATH, greet works like any installed command. type shows which file bash resolved, and that it is now hashed: bash remembers the location so it does not search PATH again. If you later move the script, hash -r forgets stale paths.

A child process cannot change your shell

./script and bash script run in a separate process. Whatever the script does to its own state, such as cd, setting variables, or changing options, disappears when it exits. That is a feature: a script cannot accidentally wreck your interactive shell. It also explains the common surprise that a script containing cd /some/dir leaves you where you were.

A script's cd does not move you

goto-logs.sh
#!/usr/bin/env bash
cd logs
LAST_DIR=$PWD
echo "inside the script: $PWD"
chmod +x goto-logs.sh
./goto-logs.sh
echo "after it: $PWD, LAST_DIR='$LAST_DIR'"
source goto-logs.sh
echo "after source: $PWD, LAST_DIR='$LAST_DIR'"
output
inside the script: /home/dev/demo/logs
after it: /home/dev/demo, LAST_DIR=''
inside the script: /home/dev/demo/logs
after source: /home/dev/demo/logs, LAST_DIR='/home/dev/demo/logs'

Run as a program, the script's cd and variable vanish with its process. With source, the same lines run in the current shell, so the directory change and LAST_DIR stick.

That is what source (or its POSIX spelling .) is for: loading functions, variables and settings into the current shell, as in source ~/.bashrc or a project's source venv/bin/activate. A file meant to be sourced does not need the execute bit. Avoid exit in it: exit would close the shell that sourced it. Use return instead, which works at the top level of a sourced file.

Exit status and $0

A script's exit status is the status of exit N if it calls it, otherwise that of the last command it ran. Callers, if, && and CI systems all read that number, so end with an explicit exit when the last command's status could mislead (see Exit codes).

The last command decides the exit status

check.sh
#!/usr/bin/env bash
grep -q "TODO" notes.txt && echo "notes have a TODO"
grep -q "TODO" todo.txt && echo "todo.txt has a TODO"
bash check.sh; echo "exit status: $?"
explain
output
notes have a TODO
exit status: 1

The script's work went fine, but its last line was a grep that found nothing, so the whole script exits 1. A trailing cond && action is a classic way to make a script report failure by accident; end with exit 0 or use an if.

Inside a script, $0 is the path it was invoked by, and ${BASH_SOURCE[0]} is the file currently being read, which stays correct even when the file is sourced. The usual way to find files that live next to the script:

Find the directory the script lives in

tools/where.sh
#!/usr/bin/env bash
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
echo "\$0 is $0"
echo "script dir is $here"
chmod +x tools/where.sh
tools/where.sh
cd /tmp && ~/demo/tools/where.sh
output
$0 is tools/where.sh
script dir is /home/dev/demo/tools
$0 is /home/dev/demo/tools/where.sh
script dir is /home/dev/demo/tools

$0 changes with how you call the script, but here is always the absolute directory containing it, so the script can open "$here/config" no matter what the caller's current directory is.

Related