· 7 min read ·

Shell Knowledge That Compounds

Source: hackernews

Every few months, a shell tricks post surfaces on Hacker News and accumulates hundreds of upvotes. This one by Hofstede hit 611 points and 261 comments, which is consistent with the genre. The pattern repeats because shell knowledge distributes unevenly across teams. Developers who learned Unix in the 1990s carry mental models that are still optimal in 2026; developers who came up in the cloud era may have absorbed different instincts about how to navigate a filesystem or repeat a command. The knowledge is freely available, but the investment to actually internalize it is small enough that people keep rediscovering it years into their careers.

The tricks worth building into muscle memory fall into a few categories. History manipulation is the highest-leverage one. Parameter expansion and brace expansion together eliminate a surprising amount of awk and sed. Process substitution is niche but irreplaceable when you need it. Then there is the modern layer: fzf, zoxide, atuin. These tools extend the shell’s native mechanisms rather than bypassing them, which is what makes them worth installing.

History Expansion Has Been Stable for 45 Years

The ! history expansion syntax has been in the shell since at least Version 7 Unix in 1979. It feels arcane because the syntax is dense, but it is also extremely stable. These incantations work identically on every bash and zsh you will ever touch.

The basics are well known: !! reruns the last command, !$ gives you the last argument of the previous command, !^ the first. Less commonly internalized: !* expands to all arguments of the previous command, and !:n gives you the nth word, counting from 0 as the command itself.

$ cp /some/long/path/file.txt /another/long/path/
$ chmod 644 !$    # expands to /another/long/path/

The quick substitution shorthand ^old^new replaces the first occurrence of old in the last command:

$ git commit -m "fix typo in README"
$ ^README^CHANGELOG
# runs: git commit -m "fix typo in CHANGELOG"

The long form is !!:s/old/new/. Using gs instead of s makes it a global substitution across all occurrences in the line.

ctrl-r reverse search and fc exist on opposite ends of the same spectrum. ctrl-r is interactive and well suited to fuzzy recall of recent commands. fc opens the last command in $EDITOR, which is the right tool when you are about to run something complicated and want to review or modify it before executing. fc -l lists recent history; fc 100 150 lists commands 100 through 150 with their numbers.

The history configuration variables matter more than the tricks themselves once you care about persistence across sessions:

HISTSIZE=100000
HISTFILESIZE=200000
HISTCONTROL=ignoreboth:erasedups
HISTTIMEFORMAT="%F %T "
shopt -s histappend
PROMPT_COMMAND="history -a; $PROMPT_COMMAND"

HISTCONTROL=ignoreboth combines ignorespace (lines beginning with a space are not saved, which is useful for commands you want to run without logging) and ignoredups. erasedups removes earlier duplicates when a command is added. With histappend and history -a in PROMPT_COMMAND, each session appends to the history file after every command rather than waiting until the session exits. You stop losing history when a terminal window closes unexpectedly.

Zsh offers setopt SHARE_HISTORY, which reads and writes history across live sessions simultaneously, more aggressive than bash’s append-only model. Atuin goes further still: it replaces the history backend entirely with a SQLite database, stores context alongside each entry (working directory, exit code, duration, hostname), enables encrypted cross-machine sync, and replaces ctrl-r with a full-screen fuzzy search UI. The query syntax supports filtering by exit status (exit!=0), by directory (cwd:), and by time range, which addresses the case where you remember a command ran on a specific machine last week but cannot reconstruct its exact text.

Parameter Expansion Does More Than You Remember

Parameter expansion handles most string manipulation tasks without spawning a subprocess. The full set is covered in the bash manual, but the practical summary is this: ${var:-default} returns default if var is unset or empty; ${var:=default} also assigns the default back to var; ${var:+alt} returns alt if var is set and non-empty; ${var:?message} exits with an error if var is unset.

The prefix and suffix stripping operators are among the most useful:

path="/usr/local/bin/bash"

${path#*/}      # usr/local/bin/bash   (remove shortest leading match)
${path##*/}     # bash                 (remove longest leading match)
${path%/*}      # /usr/local/bin       (remove shortest trailing match)
${path%%/*}     # empty               (remove longest trailing match)

${path/bin/sbin}    # /usr/local/sbin/bash   (first replacement)
${path//l/L}        # /usr/LocaL/bin/bash    (global replacement)

Length: ${#path} returns 20. Substring extraction: ${path:5:5} returns ocal/ (offset 5, length 5).

Case conversion arrived in bash 4.0 in 2009, but macOS ships bash 3.2 as its default shell due to the GPL v3 licensing change in bash 4. On a stock Mac, these require installing a newer bash via Homebrew:

str="hello world"
${str^}      # Hello world  (capitalize first character)
${str^^}     # HELLO WORLD  (all caps)
${str,}      # hello world  (lowercase first character)
${str,,}     # hello world  (all lowercase)

These replace a meaningful fraction of one-liner awk and tr invocations. The subprocess cost is not usually the bottleneck, but eliminating the subshell also eliminates the mental overhead of remembering whether you need awk '{print toupper($0)}' or tr '[:lower:]' '[:upper:]'.

Brace Expansion Is a Tiny Language

Brace expansion is processed before variable expansion and globbing in the shell’s evaluation order, and it generates strings that need not correspond to existing files. This makes it useful for construction rather than selection.

# Create a directory tree in one command
mkdir -p project/{src,tests,docs}/{main,utils}

# Rename a file without typing the path twice
mv /long/path/to/file.{old,new}
# expands to: mv /long/path/to/file.old /long/path/to/file.new

# Sequences with optional step
echo {1..10..2}     # 1 3 5 7 9
echo {001..010}     # 001 002 003 ... 010 (zero-padded)
echo {a..z}         # a b c ... z

The Cartesian product behavior is the part people most often overlook:

echo {a,b}{1,2}
# a1 a2 b1 b2

This is useful for generating symmetric test fixture paths, creating directory scaffolding with mirrored structure, or building out flag combinations for quick manual testing. The expansion happens in the shell before any command sees it, so there is no runtime overhead and no dependency on a scripting language.

Process Substitution for When You Need a File but Have a Stream

The <() syntax runs a command in a subshell and presents its output as a file path, implemented via a named pipe or a /dev/fd/ entry depending on the system. Commands that only accept filenames become composable:

diff <(ls dir1 | sort) <(ls dir2 | sort)
comm <(sort file1) <(sort file2)
wdiff <(curl -s https://api.example.com/v1) <(curl -s https://api.example.com/v2)

The output form >() handles the symmetric case, piping stdout into a command that reads from a file:

tee >(gzip > archive.gz) >(wc -l >&2) > /dev/null

This sends stdout simultaneously to a gzip archiver and a line counter. On Linux, this uses /proc/self/fd/. On macOS it uses named pipes. The behavior is not identical across platforms in edge cases involving process timing and cleanup, but for interactive use it is reliable enough to reach for regularly.

The Modern Tools Compose With This

fzf, released in 2013 by Junegunn Choi and written in Go, is a general-purpose fuzzy finder that integrates with the shell through key bindings and completion triggers. The shell integration (sourced via $(fzf --zsh) or the bash equivalent) replaces the default ctrl-r with a fuzzy, scrollable history search. The ** tab completion trigger lets you type vim **<TAB> and pick a file from a recursive fuzzy search. ctrl-t opens a file picker that pastes the selected path onto the command line. These are composable rather than opinionated: fzf itself does nothing except filter lines; what it filters and what happens with the output is entirely up to you.

zoxide tracks which directories you visit and provides a z command that jumps to the most “frecent” (frequent + recent) match. z proj resolves to ~/work/myproject if that’s where you spend time. It is a maintained, Rust-based replacement for the older autojump and fasd projects, both of which have seen declining maintenance. The zi command opens an interactive fzf picker over your frecency-ranked directories, combining both tools into a single interaction.

The shell itself gains capabilities over time. Bash 5.0, released in January 2019, improved associative array handling, added BASH_ARGV0, and refined globbing behavior. Bash 5.2 in September 2022 made lastpipe available in interactive mode and improved local variable semantics. Zsh has had most of these features longer, and its completion system remains substantially more capable out of the box. The gap between bash and zsh has narrowed for scripting use cases, but zsh’s zstyle-based completion configuration is still in a different category for interactive use.

The Compounding Argument

The productivity case for shell knowledge is different from the case for learning a new framework. Frameworks have churn. Shell expansion syntax from 1979 runs unchanged on every Linux server you will ever administer. Time invested in understanding word splitting, IFS, quoting semantics, and history expansion is time that compounds indefinitely.

The Hofstede article is a good starting inventory, and the HN comments extend it further: CDPATH for instant navigation to frequently used parent directories, noclobber to prevent accidental overwrites with >, bind -p to list all active readline key bindings, HISTIGNORE to exclude specific command patterns from history. The list is long. The useful approach is not to memorize all of it but to identify the commands you type most often and ask whether a shorter form exists. For nearly any repetitive shell interaction, the answer is yes, and it has been for decades.

Was this interesting?