Terminal, Git & GitHub
Terminal, Git & GitHub

Terminal survival kit: what the shell does to your line

Lesson · ~30 min
After this unit you can
  • Predict what the shell hands a program after quoting and expansion: '…' vs "…", ~, $VAR, globs, and zsh's no matches found
  • Spot the zsh-vs-bash traps — an unquoted $VAR isn't split, path is tied to PATH — and write the quoted or array form that works in both
  • Route output with >, >>, 2>&1 and |, and explain why > file 2>&1 and 2>&1 > file differ
  • Chain commands on exit codes with &&, || and pipefail, and predict when a && b || c runs c
  • Explain how PATH and export decide what a command and its children see, and why a script or a tool's shell can't see your terminal's setup

Read

Simple
1 / 3

The shell turns your line into words, then runs a program

Flag

When you press Enter, the program you typed doesn't see your line. The shell (zsh, the macOS default since Catalina) reads it first, expands it, splits it into words, and then starts the program with those words as its arguments.

cd ~/projects/shop        # ~ becomes /Users/you before cd ever runs
ls src/*.ts               # the shell, not ls, turns *.ts into the matching file names
echo "Hello $USER"        # "$USER" is replaced; echo receives one word: Hello ada
echo 'Hello $USER'        # single quotes: nothing is replaced; echo receives Hello $USER

So ls never sees *.ts — it receives src/a.ts src/b.ts. That one fact explains most terminal surprises.

Where you are, and how paths read

The shell has a current working directory (pwd prints it). A path is read from there unless it starts with /:

PathMeans
/Users/ada/shop/package.jsonabsolute — from the root, works anywhere
src/app.tsrelative — from the current directory
. / ..this directory / the parent (cd .. goes up one)
~your home directory ($HOME), expanded by the shell

The everyday verbs: pwd (where am I), cd (go), ls (list), cat (print a file), less (page through one; q quits, /word searches), grep pattern file (lines that match), find . -name '*.json' (files by name, recursively). The flags you'll reach for are in the Common CLI flags cards; this unit is about what the shell does around them.

Quotes decide what the shell touches

  • Single quotes '…' — everything literal. No $VAR, no ~, no *.
  • Double quotes "…" — $VAR and $(command) still expand, but the result stays one word, and * and ~ stay literal.
  • No quotes — every expansion happens: ~, $VAR, globs like *.ts.

That's why this fails:

% cd "~/Documents"
cd: no such file or directory: ~/Documents

The quotes stopped ~ from expanding, so cd looked for a folder literally named ~. Write cd ~/Documents, or cd "$HOME/Documents" when the path has spaces.

Medium
1 / 2

zsh is not bash — three differences that bite

Flag

Most shell snippets online are written for bash. Your Mac's terminal runs zsh (5.9 on current macOS). They agree on almost everything, and the differences show up as silent wrong behaviour, not errors.

1. zsh does not split an unquoted $VAR

FILES="a.txt b.txt"
for f in $FILES; do echo "[$f]"; done
bashzsh
[a.txt] then [b.txt] — two iterations[a.txt b.txt] — one iteration

bash splits the unquoted value on spaces (the IFS characters). zsh doesn't — the zsh manual: unquoted parameters "are not automatically split on whitespace unless the option SH_WORD_SPLIT is set". So in zsh, rm $FILES tries to delete one file named a.txt b.txt:

% ls $FILES
ls: a.txt b.txt: No such file or directory

The zsh way to hold a list is an array, and an array works in bash too:

files=(a.txt b.txt)
for f in "${files[@]}"; do echo "[$f]"; done   # portable: bash and zsh
for f in $files; do echo "[$f]"; done          # zsh only: an unquoted array gives its elements

When the input really is a space-separated string (say, the output of a command), zsh's ${=FILES} splits it on purpose.

2. path is PATH

zsh keeps an array path tied to the scalar PATH: change one and the other changes. So a loop variable named path overwrites your command search path:

% for path in a.txt b.txt; do echo $path; done
a.txt
b.txt
% ls
zsh: command not found: ls

After the loop, PATH is b.txt. A local path=... inside a function does the same thing for the rest of that function. Use file, p or dir instead. (cdpath, fpath and manpath are tied the same way.)

3. A glob that matches nothing is an error

% ls *.log
zsh: no matches found: *.log

bash would pass the literal *.log to ls and let it complain. zsh refuses to run the command at all. The common victim is a URL: ? and * are glob characters, so an unquoted open https://example.com/search?q=git dies with no matches found. Quote anything that contains ?, * or [:

open 'https://example.com/search?q=git'

Complex
1 / 2

Splitting and globbing, precisely — and the habit that makes both shells agree

Flag

"zsh doesn't word-split" is true for parameters only. The exact rules, checked in zsh 5.9 and the bash 3.2 that ships with macOS, with FILES="a.txt b.txt" and P="*.txt":

Expansionbashzsh
$FILES (unquoted variable)split → 2 words1 word
$P (variable holding a glob)globbed → matching filesliteral *.txt
$(echo a.txt b.txt) (command substitution)split → 2 wordssplit → 2 words
X=$(…) then $Xsplit1 word (it's a variable again)
"$FILES", "$(…)" (double-quoted)1 word1 word
*.txt with no matchpasses *.txt literallyerror: no matches found

So in zsh, for f in $(ls) iterates word by word (a file name with a space still splits in two), but storing that output in a variable first quietly turns it back into one word. And a pattern kept in a variable doesn't glob unless you ask with ${~P} (or turn on GLOB_SUBST).

The habit: quote every expansion, use arrays for lists

Writing for both shells is simpler than memorising the table:

# A single value: always double-quote it. One word in both shells, spaces and all.
cp "$SOURCE" "$DEST/backup copy.txt"

# A list: an array, expanded as "${name[@]}" — each element one word, in both shells.
files=(a.txt "my notes.txt")
for f in "${files[@]}"; do wc -l "$f"; done

# Forwarding a script's arguments untouched:
exec node server.js "$@"          # "$@" = every argument, each still one word

The unquoted forms are where the shells disagree; the quoted forms are where they agree. Bash is the risky direction: an unquoted $FILE holding my notes.txt becomes two arguments, my and notes.txt, and a value containing * expands to whatever files happen to match.

When you do want a string split

Sometimes the input really is a space- or line-separated string — a command's output, an env var like EXTRA_ARGS. Split it deliberately, so the code says so:

# zsh
for f in ${=EXTRA_ARGS}; do …; done         # split on IFS (spaces, tabs, newlines)
for line in ${(f)"$(git diff --name-only)"}; do …; done   # split on newlines only

# bash
read -r -a args <<< "$EXTRA_ARGS"           # into an array

Splitting on newlines rather than spaces matters for file names: git diff --name-only puts one path per line, and a path can contain a space.

Quiz

cd "~/Documents" fails with cd: no such file or directory: ~/Documents, but cd ~/Documents works. Why?

Flag

In zsh, open https://example.com/search?q=git prints zsh: no matches found: https://example.com/search?q=git and nothing opens. What happened?

Flag

In zsh: FILES="old.log debug.log" then rm $FILES. What happens?

Flag

A zsh script runs for path in src/*.ts; do wc -l $path; done and then pnpm lint. Every pass of the loop prints command not found: wc, and pnpm lint fails with command not found: pnpm. Why?

Flag

You want everything pnpm build prints — normal output and errors — in build.log. Which line does that?

Flag

A CI step runs pnpm test | tee test.log. Three tests fail, yet the step is green. Why, and what fixes it?

Flag

setup.sh contains cd app and export NODE_ENV=production. You run ./setup.sh from ~/shop. Afterwards, in your terminal:

Flag

pnpm works in your terminal. A git pre-commit hook fails with pnpm: command not found — but only when you commit from a GUI git app opened from the Dock. Your export PATH=… line is in ~/.zshrc. Why?

Flag

Fill in the blank

Send both stdout and stderr of the build into one file.

Flag
pnpm build > build.log 

Loop over a list of file names — one of them has a space — in a way that works in both zsh and bash.

Flag
files=(a.txt "my notes.txt")
for f in ""; do
  wc -l "$f"
done

Make API_URL visible to the dev server you start next from this shell.

Flag
 API_URL=http://localhost:4000
pnpm dev

Print the message only when grep finds no match (it exits 1 then).

Flag
grep -q "TODO" notes.txt  echo "no TODOs left"

In ~/.zprofile, put ~/.local/bin first in the command search path without losing the rest.

Flag
export PATH="$HOME/.local/bin:"

Predict the output

# zsh
FILES="a.txt b.txt"
for f in $FILES; do echo "[$f]"; done
Flag
# a.txt exists; nope does not
ls a.txt nope 2>&1 > out.txt
cat out.txt
Flag
false && echo A || echo B
true && false || echo C
true || echo D && echo E
Flag
% pwd
/Users/ada/shop
% cat goto-app.sh
cd app
export STAGE=dev
% zsh goto-app.sh; pwd; echo "[$STAGE]"
% source goto-app.sh; pwd; echo "[$STAGE]"
Flag

Further reading