Kiet Nguyen logo
NotesNotesResumeResume
© 2026 Kiet Nguyen
← All categories

00

Introduction to GNU Bash

What Bash is, what a shell is, and a first log-summary script

  • What is a shell vs Bash
  • Read a small script: variables, quoting, if, for
  • GNU Bash Reference Manual
log-summary.shHow one line is actually runShebang and commentsFunction, $0, printfParameter expansioncommand -v, redirections, !, 127Assignment vs simple commandFile testsRelative paths and cd: > truncate, { } group, >> appendfor, glob, empty match, continueArithmetic, grep, $( ), || trueClosing if, statusesExpansion order (only what this file uses)Quoting cheat-sheet from the fileSurface mapWhat each builtin is doing to the shell itself

Must-know cold

  • Bash is a shell: command interpreter and programming language (GNU’s default).
  • Interactive = keyboard. Non-interactive = commands from a file (a script).
  • A builtin is implemented inside the shell (cd, printf, exit). grep is not.
  • $name is a parameter. Quote it: "$dir". Unquoted words split on blanks.
  • man bash locally.

What is Bash?

Bash is the shell, or command language interpreter, for the GNU operating system. The name is an acronym for the ‘Bourne-Again SHell’, a pun on Stephen Bourne, the author of the direct ancestor of the current Unix shell sh, which appeared in the Seventh Edition Bell Labs Research version of Unix.

Bash is largely compatible with sh and incorporates useful features from the Korn shell ksh and the C shell csh. It is intended to be a conformant implementation of the IEEE POSIX Shell and Tools portion of the IEEE POSIX specification (IEEE Standard 1003.1). It offers functional improvements over sh for both interactive and programming use.

While the GNU operating system provides other shells, including a version of csh, Bash is the default shell. Like other GNU software, Bash is quite portable. It currently runs on nearly every version of Unix and a few other operating systems — independently-supported ports exist for Windows and other platforms.

What is a shell?

At its base, a shell is simply a macro processor that executes commands. The term macro processor means functionality where text and symbols are expanded to create larger expressions.

A Unix shell is both a command interpreter and a programming language. As a command interpreter, the shell provides the user interface to the rich set of GNU utilities. The programming language features allow these utilities to be combined. Users can create files containing commands, and these become commands themselves. These new commands have the same status as system commands in directories such as /bin, allowing users or groups to establish custom environments to automate their common tasks.

Shells may be used interactively or non-interactively. In interactive mode, they accept input typed from the keyboard. When executing non-interactively, shells execute commands read from a file or a string.

A shell allows execution of GNU commands, both synchronously and asynchronously. The shell waits for synchronous commands to complete before accepting more input; asynchronous commands continue to execute in parallel with the shell while it reads and executes additional commands. The redirection constructs permit fine-grained control of the input and output of those commands. Moreover, the shell allows control over the contents of commands’ environments.

Shells also provide a small set of built-in commands (builtins) implementing functionality impossible or inconvenient to obtain via separate utilities. For example, cd, break, continue, and exec cannot be implemented outside of the shell because they directly manipulate the shell itself. The history, getopts, kill, or pwd builtins, among others, could be implemented in separate utilities, but they are more convenient to use as builtin commands. All of the shell builtins are described in subsequent sections.

While executing commands is essential, most of the power (and complexity) of shells is due to their embedded programming languages. Like any high-level language, the shell provides variables, flow control constructs, quoting, and functions.

Shells offer features geared specifically for interactive use rather than to augment the programming language. These interactive features include job control, command line editing, command history and aliases. This manual describes how Bash provides all of these features.

Definitions

These definitions are used throughout the remainder of this manual.

TermMeaning
POSIXA family of open system standards based on Unix. Bash is primarily concerned with the Shell and Utilities portion of the POSIX 1003.1 standard.
blankA space or tab character.
whitespaceA character belonging to the space character class in the current locale, or for which isspace() returns true.
builtinA command that is implemented internally by the shell itself, rather than by an executable program somewhere in the file system.
control operatorA token that performs a control function. It is a newline or one of: ||, &&, &, ;, ;;, ;&, ;;&, |, |&, (, ).
exit statusThe value returned by a command to its caller. Restricted to eight bits, so the maximum value is 255.
fieldA unit of text that is the result of one of the shell expansions. After expansion, fields become the command name and arguments.
filenameA string of characters used to identify a file.
jobA set of processes comprising a pipeline, and any processes descended from it, that are all in the same process group.
job controlA mechanism by which users can selectively stop (suspend) and restart (resume) execution of processes.
metacharacterA character that, when unquoted, separates words. A space, tab, newline, or one of: |, &, ;, (, ), <, >.
nameA word of letters, numbers, and underscores, beginning with a letter or underscore. Used as variable and function names. Also called an identifier.
operatorA control operator or a redirection operator. Operators contain at least one unquoted metacharacter.
process groupA collection of related processes each having the same process group ID.
process group IDA unique identifier that represents a process group during its lifetime.
reserved wordA word with special meaning to the shell. Most introduce flow-control constructs, such as for and while.
return statusA synonym for exit status.
signalA mechanism by which a process may be notified by the kernel of an event occurring in the system.
special builtinA shell builtin classified as special by the POSIX standard.
tokenA sequence of characters considered a single unit by the shell. Either a word or an operator.
wordA sequence of characters treated as a unit by the shell. Words may not include unquoted metacharacters.

A first script

Task: you have a directory of *.log files. You want a one-page count of ERROR and WARN lines, skip unreadable files, write a report, and fail the script if any errors exist (handy in CI). No banners. No color.

Save as log-summary.sh, then:

chmod +x log-summary.sh
./log-summary.sh /var/log ./report.txt

log-summary.sh

#!/usr/bin/env bash
# log-summary.sh — count ERROR and WARN lines in *.log under a directory.
# Usage: ./log-summary.sh [directory] [outfile]

usage() {
  printf 'usage: %s [directory] [outfile]\n' "$0"
}

if [[ ${1:-} == -h || ${1:-} == --help ]]; then
  usage
  exit 0
fi

if ! command -v grep >/dev/null 2>&1; then
  printf 'grep not found on PATH\n' >&2
  exit 127
fi

dir=${1:-.}
out=${2:-log-summary.txt}

if [[ ! -d $dir ]]; then
  printf 'not a directory: %s\n' "$dir" >&2
  exit 1
fi

# Relative outfile is vs the directory we started in, not vs $dir after cd.
if [[ $out != /* ]]; then
  out="$(pwd)/$out"
fi

cd -- "$dir" || exit 1
here=$(pwd)

total_err=0
total_warn=0
file_count=0

: > "$out"

{
  printf 'dir=%s\n' "$here"
  printf '\n'
} >> "$out"

for f in *.log; do
  if [[ $f == '*.log' && ! -e $f ]]; then
    printf 'no *.log files in %s\n' "$here"
    exit 0
  fi

  if [[ ! -r $f ]]; then
    printf 'skip unreadable: %s\n' "$f" >&2
    continue
  fi

  file_count=$((file_count + 1))

  err=$(grep -c 'ERROR' -- "$f" || true)
  warn=$(grep -c 'WARN' -- "$f" || true)

  total_err=$((total_err + err))
  total_warn=$((total_warn + warn))

  printf '%s  error=%s  warn=%s\n' "$f" "$err" "$warn" >> "$out"
done

{
  printf '\nfiles=%s\n' "$file_count"
  printf 'total_error=%s  total_warn=%s\n' "$total_err" "$total_warn"
} >> "$out"

if [[ $total_err -gt 0 ]]; then
  printf 'report written: %s (errors present)\n' "$out"
  exit 2
fi

printf 'report written: %s\n' "$out"
exit 0

Syntax and mechanics

This is a non-interactive shell: Bash reads the file top to bottom, not the keyboard. For each command it roughly: splits into words and operators using quoting → parses a simple or compound command → expands → applies redirections → executes → keeps an exit status. Interactive extras (job control, history, aliases, line editing) are off the table here.

Worked invocation used below: you are in /home/you/work, the script is executable, and you run:

./log-summary.sh /var/log ./report.txt

Then: $0 is ./log-summary.sh, $1 is /var/log, $2 is ./report.txt. $# is 2 (not used in the file, but that is the count of positional parameters).

How one line is actually run

Take cd -- "$dir" || exit 1.

  1. Quote: "$dir" is one word. After parameter expansion it is still one word even if $dir has spaces.
  2. Simple command: words cd, --, /var/log. cd is a builtin (it must change this process’s cwd; an external /usr/bin/cd could not).
  3. --: end of options. A directory named -P is not cd -P.
  4. List: || is a control operator. exit 1 runs only if cd’s status is non-zero.
  5. Synchronous: the shell waits for cd before the next line. No &, so not a job in the background.

Same seven-step engine on every later line. The rest of this section is that engine applied to the file, in order.

Shebang and comments

#!/usr/bin/env bash
# log-summary.sh — count ERROR and WARN lines in *.log under a directory.

#! is not Bash. The kernel reads the first line and execs that interpreter with the script path as an argument. env walks PATH and finds bash (so you are not hard-coding /bin/bash). After chmod +x, the file is a command.

# starts a comment: that word and the rest of the line are discarded. True for every non-interactive shell.

Function, $0, printf

usage() {
  printf 'usage: %s [directory] [outfile]\n' "$0"
}
  • A name (usage) bound to a compound command. Defining it does not run it.
  • { list; } is brace grouping: current shell, not a subshell. (( list ) would fork; assignments inside would vanish.)
  • printf is a builtin. The format is in single quotes: every character is literal, so %s and \n are for printf, not for Bash. \n is a newline in the format, not a Bash escape.
  • "$0" is a special parameter: the invocation name (./log-summary.sh here, or a full path if you used one). Double quotes keep it one word.
  • Calling usage later is a simple command whose first word is a function name, not a file on PATH.

Parameter expansion

if / [[ / ||

if [[ ${1:-} == -h || ${1:-} == --help ]]; then
  usage
  exit 0
fi
  • if then fi are reserved words. The exit status of the test list decides the branch. Zero = true.
  • [[ ... ]] is a Bash conditional construct (reserved words), not POSIX [ and not an external test. No word-splitting and no globbing of the words between [[ and ]].
  • ${1:-} is parameter expansion: positional $1, or empty if $1 is unset or null. Bare $1 == -h with no $1 can become a syntax error (== with a missing operand). The :- form makes a missing arg safe.
  • == inside [[ is pattern match (with extglob rules). -h has no glob characters, so it is exact. --help likewise.
  • || here is inside [[: it is the conditional or, with short-circuit. It is the same idea as the list operator || but it is not spawning two pipelines; it is one [[ expression.
  • ! is not on this line. Later, [[ ! -d $dir ]] — ! negates the primary.

exit 0 is a builtin. Status 0 = success. The caller sees it in $? (eight bits, 0–255).

command -v, redirections, !, 127

if ! command -v grep >/dev/null 2>&1; then
  printf 'grep not found on PATH\n' >&2
  exit 127
fi
  • command is a builtin. It skips functions/aliases and tells you how the shell would run grep. -v prints the path (or nothing + non-zero if missing).
  • ! as a reserved word in front of a pipeline negates the status. Missing grep → command -v fails → ! makes the if true.
  • >/dev/null redirects stdout (fd 1) to the bit bucket. 2>&1 then copies stderr (fd 2) onto wherever fd 1 now points. Order matters: 2>&1 >/dev/null would first copy stderr to the terminal, then send only stdout to /dev/null.
  • printf ... >&2 writes the error on fd 2 so it does not land in the report file.
  • 127 is the conventional “command not found” status (same family as a missing binary).

grep is not a builtin. It is found by command search: function → builtin → PATH (unless command bypasses the first).

Assignment vs simple command

dir=${1:-.}
out=${2:-log-summary.txt}

This is not dir with an argument. A parameter assignment is name=value with no spaces around =. name = letters, digits, underscore; cannot start with a digit.

${1:-.} means: if $1 is unset or empty, use . (current directory). With the worked invocation, $1 is /var/log, so dir becomes /var/log. $2 is ./report.txt, so out is that.

Unquoted $dir later would split on blanks (space/tab/newline, plus IFS). "$dir" does not split.

File tests

if [[ ! -d $dir ]]; then

-d is a conditional primary: true if the word names a directory. -r (later) is readable; -e is exists. Inside [[, $dir is not split, so a directory with a space still works. Outside [[, always quote.

printf 'not a directory: %s\n' "$dir" >&2 — %s consumes the next argument as a string; a directory named -v will not be taken as a printf flag.

Relative paths and cd

if [[ $out != /* ]]; then
  out="$(pwd)/$out"
fi

cd -- "$dir" || exit 1
here=$(pwd)
  • != inside [[ is also pattern match. /* as a pattern means “starts with /.” A relative ./report.txt does not match, so we prefix.
  • "$(pwd)/$out": command substitution $(pwd) plus / plus $out, all in double quotes → one word. $(...) runs pwd in a subshell, strips trailing newlines, inserts the text.
  • Why before cd: after cd /var/log, a relative out would mean /var/log/report.txt. The script writes the report in the directory you started in unless you pass an absolute path.
  • cd changes the script’s cwd. The parent shell that launched ./log-summary.sh is unchanged (the script is another process).
  • here=$(pwd) is the directory we will glob. Command substitution captures stdout only. pwd’s stderr, if any, still goes to the terminal.

$(pwd) vs $PWD: PWD is a shell variable the shell updates on cd. pwd is the builtin. Either is fine here.

: > truncate, { } group, >> append

total_err=0
total_warn=0
file_count=0

: > "$out"

{
  printf 'dir=%s\n' "$here"
  printf '\n'
} >> "$out"
  • 0 is a word assigned to a parameter. Later arithmetic treats it as an integer.
  • : is a special builtin: no-op, status 0. : > "$out" opens $out for writing and truncates it (creates if missing). Same idea as > "$out" on a no-op.
  • > vs >>: truncate vs append. Both are redirection operators, applied before the command runs. They are not arguments to printf.
  • { printf ...; printf ...; } >> "$out": one redirect on the compound command. Both prints go to the file. The semicolon (or newline) before } is required; { } are reserved words and need blanks.

Fd map in this script: 0 stdin (unused), 1 stdout (human one-liners), 2 stderr (skips / errors), $out (the report).

for, glob, empty match, continue

for f in *.log; do
  if [[ $f == '*.log' && ! -e $f ]]; then
    printf 'no *.log files in %s\n' "$here"
    exit 0
  fi

  if [[ ! -r $f ]]; then
    printf 'skip unreadable: %s\n' "$f" >&2
    continue
  fi
  • for in do done are reserved words. Bash expands *.log (filename expansion) once, then binds f to each resulting word in turn.
  • Globbing happens in $here, because cd already ran. *.log is a pattern, not a regex: * any string, ? one character, [...] a set.
  • Empty directory: by default Bash does not expand to nothing. It leaves the literal word *.log. The test == '*.log' (quoted pattern = literal) and ! -e (no such file) is the empty-dir trap. Then exit 0: no logs is success, not a crash.
  • If there is a file actually named *.log, -e is true and we process it. Rare, but that is why both tests are there.
  • && inside [[ is conditional and, short-circuit: if $f is not the literal glob, -e is not needed.
  • -r: unreadable → message on stderr (so it does not pollute the report) → continue (builtin) starts the next for iteration. break would leave the loop; we still want the other files.

f is a name in the current shell. No local (that exists only in functions). After done, $f is still the last filename.

Arithmetic, grep, $( ), || true

  file_count=$((file_count + 1))

  err=$(grep -c 'ERROR' -- "$f" || true)
  warn=$(grep -c 'WARN' -- "$f" || true)

  total_err=$((total_err + err))
  total_warn=$((total_warn + warn))

  printf '%s  error=%s  warn=%s\n' "$f" "$err" "$warn" >> "$out"
  • $(( )) is arithmetic expansion. file_count + 1 is evaluated; the result is a word (e.g. 3). C-style ++ is available in Bash arithmetic; this file uses the obvious form.
  • grep is external. -c counts matching lines. 'ERROR' is single-quoted so the shell does not touch it. -- means “options are over”: a log named -c is a file, not a flag.
  • $(grep ...): command substitution. The shell forks, runs the list, takes stdout, strips trailing newlines, inserts it. grep -c with no matches prints 0 and exits 1. That 1 is a status, not the text.
  • || true: list operator. If grep is 1 (no matches) or otherwise non-zero, true (builtin, always 0) runs. The substitution still captured 0. Without this, set -e (not on in this file, but common) would abort the script on “zero matches.”
  • A pipeline is cmd | cmd. This line is a list, not a pipe: grep ... || true. Status of the list is the last command that actually ran.
  • "$f" "$err" "$warn": three separate fields after expansion. printf consumes them in order for three %s.

Do not write err=$(grep -c ERROR $f). Unquoted $f splits; ERROR unquoted is still fine here but quoting the pattern is the habit.

Closing if, statuses

if [[ $total_err -gt 0 ]]; then
  printf 'report written: %s (errors present)\n' "$out"
  exit 2
fi

printf 'report written: %s\n' "$out"
exit 0
  • -gt is integer compare inside [[ (also -eq -ne -lt -le -ge). This is not string > (locale sort).
  • Human messages stay on stdout. The report is already in $out.
  • Status contract:
CodeMeaning
0ran; no ERROR lines (including “no *.log files”)
1bad directory / cd failed
2report written, but ERROR count > 0 (CI can fail the job)
127grep not on PATH

The parent sees only that number (echo $?). Values wrap at 256.

Expansion order (only what this file uses)

GNU Bash runs expansions in a fixed order. This script hits:

  1. Parameter expansion — $0 $1 $2 $dir $out $f ${1:-.} …
  2. Command substitution — $(pwd) $(grep …)
  3. Arithmetic expansion — $((file_count + 1))
  4. Word splitting — on unquoted expansions (we avoided this by quoting)
  5. Filename expansion — *.log after cd
  6. Quote removal — the quotes themselves are not passed to cd / printf / grep

Not used here: brace expansion {a,b}, tilde ~ (we used . and pwd), process substitution <( ).

Quoting cheat-sheet from the file

SyntaxWhat happens to $dir / * / spaces
"$dir"Expand; do not split; do not glob
'$dir'Literal dollar-d-i-r
$dirExpand, then split on IFS, then glob
'*.log' inside [[ == ]]Literal asterisk-dot-log
*.log after inGlob in the current directory
'ERROR'Literal pattern for grep

Surface map

KindIn the script
Reserved wordsif then fi for in do done [[ ]] ! { }
Builtinscommand cd pwd printf exit : continue true
Special / positional$0 $1 $2
Names we setdir out f here err warn total_err total_warn file_count
Control operators|| ; newline
Redirection> >> >&2 >/dev/null 2>&1
Expansion${p:-word} $( ) $(( )) *.log
[[ primaries-d -r -e -gt == != ! && ||
Externalgrep (chmod and env only when you install/launch the file)

Not in this script (on purpose): set -euo pipefail, arrays, local, getopts, case, while/until, select, coprocesses, process substitution, job control, aliases, history. Those are in the GNU manual.

What each builtin is doing to the shell itself

BuiltinWhy it cannot just be /usr/bin/... here
cdMust change this process’s cwd
exitMust end this shell with a status
continueMust skip to the next iteration of this for
commandMust look at this shell’s command search
: trueTrivial, but they are in-process no-ops (status 0)
printf pwdCould be external; builtin avoids a fork and sees the same cwd

What the script is doing

It is a non-interactive Bash program. It takes an optional directory (default: current) and optional report path (default: log-summary.txt, resolved against the starting directory before cd). It refuses a missing grep, refuses a non-directory, cds into the target, then for each *.log file it can read, counts lines containing ERROR and WARN, appends one line per file to the report, writes totals, and exits 2 if any errors were counted so a CI job can go red. Unreadable files are skipped on stderr. No *.log files is a clean exit 0, not a crash.

That is the whole job: look at logs, write a count, signal success or “errors found.”

GNU Bash Reference

This page is a doorway, not a replacement.

The sections are verbatim from the GNU Bash Reference Manual, edition 5.3 (18 May 2025), © Free Software Foundation, under the GNU Free Documentation License.

  • Check subsequent sections and full manual (HTML): GNU Bash Reference
  • Local: man bash · help cd · help [[ · help printf

If the live manual and this page ever disagree, the GNU page wins.

For more details, try man <command> in your terminal.

Next01 Navigation & Paths