Linux & Bash · Part I · Module 15 · Writing and Running Bash Scripts
What a Script Inherits, and What It Cannot Change
Modules 7 and 10 established how a child process works. This chapter applies it to the script you just wrote, which is where it stops being theory — because the most common bug in a new script is a relative path that was relative to somewhere else.
Intermediate17 min readLinuxPart IFundamentalsScripting
Part I · Module 15 · Chapter 15.3 · What a Script Inherits
Chapter 7.4 and Chapter 10.1 proved the rules. This chapter is them, applied to your own file.
1. A Script Is a Child Process
$ echo "parent PID=$$ cwd=$(pwd)"
parent PID=72879 cwd=/tmp/m15v
$ ./inherit.sh
PID=72930 PPID=72879
cwd=/tmp/m15v
exported=[yes] local=[unset]
$0=[./inherit.sh]
changed to /tmp
$ pwd
/tmp/m15vFour facts in one run:
- A different PID, with
PPIDmatching the shell that started it — Chapter 7.1's parentage. - The working directory was inherited — the script started where the caller was standing.
- An exported variable arrived; a plain one did not — Chapter 10.1.
- The script's
cddid not escape. The caller is still where it was.
2. The Inventory
Inherited — a copy arrives:
| Thing | Note |
|---|---|
| Exported variables | Only exported ones — 10.1 |
| The working directory | Wherever the caller was |
| The three streams | So a redirect on the caller's line covers the script — 12.1 |
The umask | 5.6 |
| Resource limits | Open files, memory |
| Signal dispositions | Ignored signals stay ignored — 7.6 |
| The controlling terminal | Which is why output appears — and why 7.8 matters |
Not inherited — the script gets its own, or nothing:
| Thing | Note |
|---|---|
| Non-exported shell variables | The caller's ordinary variables are invisible |
| Shell functions and aliases | Unless exported with export -f — 10.5 |
| Shell options | A script sets its own set and shopt — 10.6 |
| The shell's history | Not a thing a script has |
Returned — exactly one thing:
| Thing | Note |
|---|---|
| The exit status | One integer, 0 to 255 — 15.6 |
3. The Bug Everybody Writes First
$ cat proj/rel.sh
#!/bin/bash
cat data/f.txt
$ cd proj && ./rel.sh
the data
$ cd .. && ./proj/rel.sh
cat: data/f.txt: No such file or directoryThe same script, run two ways, one of which fails.
data/f.txt is a relative path, and a relative path resolves from the working directory (Chapter 2.2) — which the script inherited from the caller. Run from inside proj, that is proj. Run from outside, it is wherever you were, where no data directory exists.
Nothing about the script changed. The caller's directory did.
4. Making a Script Independent of Where It Was Run
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)That line gives you the absolute directory the script itself lives in, regardless of where it was invoked from.
$ cd proj && ./abs.sh
the data
$ cd .. && ./proj/abs.sh
the data
$ cd / && /tmp/m15v/proj/abs.sh
the dataThree different working directories, one correct answer.
Reading it from the inside out, using Chapter 8.4:
| Piece | Does |
|---|---|
${BASH_SOURCE[0]} | The script's own path, as invoked |
dirname … | Its directory, possibly relative |
cd … && pwd | Turn that into an absolute path |
$( … ) | Capture it — in a subshell, so the cd does not affect the script |
That last point is Chapter 11.4 being used deliberately: the cd happens inside the substitution's subshell and the script's own directory is untouched.
Then every path is built from it:
#!/bin/bash
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
source "$script_dir/lib/common.sh"
filelist="$script_dir/filelist.f"
outdir="$script_dir/runs"The alternative: refuse to guess
project_root="${PROJECT_ROOT:?set PROJECT_ROOT before running this}"Rather than working out where it is, the script requires the caller to say. Chapter 8.2's :? form names the variable in the error.
Both are legitimate. script_dir suits a script living inside the tree it operates on; an explicit variable suits one that operates on a tree chosen at run time.
What is not legitimate is a bare relative path. It is a bet that the caller is standing somewhere particular, and nothing records what that place was.
5. What the Script Cannot Do
Three things people try, all of which fail for the same reason.
It cannot change your directory.
#!/bin/bash
cd /home/engineer/project # changes the script's copy and nothing elseIt cannot set a variable in your shell.
#!/bin/bash
export TOOL_HOME=/opt/tool # exports into the script's own environmentIt cannot define a function or alias for you.
All three need the script to be sourced (Chapter 10.4), which runs it in your shell with no child process at all.
6. Being Deliberate About the Environment
A script inherits whatever the caller had, which is a different thing in a terminal, in cron, and in a build system. Three habits make that stop mattering.
Require what you need.
: "${PROJECT_ROOT:?set PROJECT_ROOT}"
: "${LM_LICENSE_FILE:?the licence server is not configured}"Set what you depend on.
export PATH="/opt/tool/bin:/usr/local/bin:/usr/bin:/bin"
export LC_ALL=C # so sort order is reproducible — Chapter 13.3That PATH line is the standard fix for a cron job that cannot find a tool (Chapter 10.3 — a script reads no startup file).
Source the environment rather than assuming it.
. /opt/tools/vcs/2024.09/setup.shExplicit, versioned, and visible to the next reader — rather than depending on whatever the caller happened to have sourced.
7. Streams Are Inherited Too
$ ./outer.sh > everything.log 2>&1Chapter 12.1 §7 showed this: the redirect applies to the script and everything it starts, because each child receives a copy of the stream table.
Two consequences for your own scripts:
Do not redirect inside the script when the caller might want to.
make > build.log 2>&1 # forces a destination on the caller
make # lets the caller decideA script that writes its own log file cannot be used in a pipeline and cannot have its output captured. Unless logging is the script's purpose, let the output go where the caller pointed it.
Put diagnostics on the error stream. Chapter 12.1 and Chapter 12.3 both made the point; in a script it is the difference between a warning the caller sees and one that disappears into their log.
echo "$result" # the answer — stream two
echo "warning: using defaults" >&2 # a diagnostic — stream three8. Putting It Together
#!/usr/bin/env bash
#
# run_regression.sh — run every test in the list and summarise.
#
# Works from any directory. Requires PROJECT_ROOT only if the tree is
# elsewhere; otherwise it uses its own location.
set -euo pipefail
# Where am I? — independent of where the caller was standing.
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# What must come from outside, and what I will default.
project="${PROJECT_ROOT:-$script_dir}"
testlist="${1:-$script_dir/tests.csv}"
# What I depend on, set explicitly rather than inherited.
export LC_ALL=C
[ -r "$testlist" ] || { echo "cannot read $testlist" >&2; exit 2; }
command -v vcs >/dev/null 2>&1 || { echo "vcs not on PATH" >&2; exit 3; }
echo "project : $project"
echo "tests : $testlist"
# No redirect here — the caller decides where output goes.Five decisions, each from this chapter:
script_dirfirst, so nothing later depends on the caller's directory.${PROJECT_ROOT:-$script_dir}— allow an override, default to something sensible.export LC_ALL=Crather than inheriting whatever locale the caller had.- Preconditions checked and named, with documented exit codes.
- No internal redirect, so the script composes.
Practice
mkdir -p /tmp/inh/proj/data && cd /tmp/inh
printf 'the data\n' > proj/data/f.txt
export OUTER_EXPORTED=yes; OUTER_LOCAL=no1 — Prove the four inheritance facts.
cat > p.sh <<'EOF'
#!/bin/bash
echo " PID=$$ PPID=$PPID"
echo " cwd=$(pwd)"
echo " exported=[${OUTER_EXPORTED-unset}] local=[${OUTER_LOCAL-unset}]"
cd /tmp; echo " moved to $(pwd)"
EOF
chmod +x p.sh
echo "shell PID=$$ cwd=$(pwd)"
./p.sh
echo "shell cwd after: $(pwd)"2 — Write the bug.
printf '#!/bin/bash\ncat data/f.txt\n' > proj/rel.sh; chmod +x proj/rel.sh
( cd proj && ./rel.sh )
./proj/rel.sh3 — Fix it.
cat > proj/abs.sh <<'EOF'
#!/bin/bash
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
cat "$script_dir/data/f.txt"
EOF
chmod +x proj/abs.sh
( cd proj && ./abs.sh )
./proj/abs.sh
( cd / && /tmp/inh/proj/abs.sh )Three directories, one answer.
4 — See $0 and BASH_SOURCE diverge.
printf 'echo " \$0=[$0] BASH_SOURCE=[${BASH_SOURCE[0]}]"\n' > src.sh
chmod +x src.sh
./src.sh
. ./src.sh5 — Prove a script cannot change your shell.
printf '#!/bin/bash\nexport FROM_SCRIPT=yes\ncd /tmp\n' > setenv.sh; chmod +x setenv.sh
./setenv.sh; echo " executed: [${FROM_SCRIPT-unset}] cwd=$(pwd)"
. ./setenv.sh; echo " sourced: [${FROM_SCRIPT-unset}] cwd=$(pwd)"
cd /tmp/inh6 — Add the must-be-sourced guard.
cat > guarded.sh <<'EOF'
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
echo "error: source this, do not execute it" >&2
exit 1
fi
export GUARDED=ok
EOF
chmod +x guarded.sh
./guarded.sh; echo " status $?"
. ./guarded.sh && echo " sourced fine: $GUARDED"7 — Test with a scrubbed environment.
printf '#!/bin/bash\necho "HOME=[$HOME] PATH head=[${PATH%%:*}]"\nls /tmp >/dev/null && echo "ls found"\n' > envtest.sh
chmod +x envtest.sh
./envtest.sh
env -i HOME="$HOME" PATH=/usr/bin:/bin ./envtest.sh
env -i ./envtest.sh8 — Confirm streams are inherited.
printf '#!/bin/bash\necho "outer"\n./p.sh\n' > outer.sh; chmod +x outer.sh
./outer.sh > all.log 2>&1
cat all.log9 — Clean up. cd /tmp && rm -rf /tmp/inh
Check Your Understanding
1. Name four things a script inherits and two it does not.
2. ./build.sh works from the project directory and fails from your home directory. What is wrong?
3. What does $(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) give you, and why does the cd not affect the script?
4. When do $0 and ${BASH_SOURCE[0]} differ?
5. Your setup script exports a variable and nothing happens. What are the two fixes?
6. Why should a script usually not redirect its own output to a log file?
9. Remember
- A script is a child process: new PID, the caller's
PPID, a copy of everything. - Inherited: exported variables, the working directory, the three streams, the umask, limits, signal dispositions, the terminal.
- Not inherited: non-exported variables, functions, aliases, shell options.
- Returned: one integer, and nothing else.
- The whole interface is eight items — arguments, environment, input, directory in; output, errors, status, filesystem out.
- A relative path resolves from the caller's directory, which is why a script works where you wrote it and fails everywhere else.
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)makes a script independent of where it was run.- The
cdin that idiom is inside a subshell, which is why it is safe. - Use
BASH_SOURCE[0], not$0, so the idiom works when sourced too. - A script cannot change your directory, your variables or your functions. Those need sourcing.
- Decide which kind of script you are writing, and guard a sourced one so the mistake reports itself.
- Require what you need with
:?, set what you depend on, source environments explicitly. - Test with
env -ito find undeclared dependencies on your own setup. - Do not redirect your own output unless logging is the point — the caller decides.
A script knows where it is. The next chapter is how it learns what it was asked to do.
Where this fits
Part of the Linux & Bash curriculum.
