The Shell Game Part 2: Bash, the Universal Baseline

Part 1 of The Shell Game laid out the question and the lab: debian1, a fresh Debian 13 VM on the homelab, Bash, Zsh, and Fish installed side by side. This post is Bash on its own, deliberately, before either of the other two shows up to make it look old-fashioned by comparison. You can’t fairly judge what Zsh and Fish add until you’ve actually seen the thing they’re adding it on top of.

Everything below is a real run against debian1, not a description of expected behaviour.

$ history | tail -20
    1  exit
    2  sudo apt update && sudo apt install -y zsh fish
    3  bash --version; zsh --version; fish --version
    4  usermod -aG sudo anon
    5  sudo su
    ...
   14  history | tail -20

Every command is numbered and recallable with !14 or the up arrow, which is table stakes for any shell and not really worth dwelling on. The more interesting one is reverse search:

(reverse-i-search)`apt': sudo apt update && sudo apt install -y zsh fish

Ctrl+R plus apt found the right line first try. This works, and it’s genuinely useful, but it’s also the whole feature. There’s no fuzzy matching, no ranking by frequency, no preview of alternate matches while you type — hit Ctrl+R again and it walks backwards through every other match one at a time, which is fine for a short history and a minor chore for a long one.

Tab completion: readline plus bash-completion

Debian 13’s desktop install ships bash-completion already, which I hadn’t accounted for going in — I don’t know for certain whether that’s specifically the desktop task pulling it in as a dependency or something present regardless, so I won’t state that as settled, but it’s confirmed present on this box either way:

$ dpkg -l | grep bash-completion
ii  bash-completion    1:2.16.0-7    all    programmable completion for the bash shell

That matters because plain readline, with nothing installed on top, only completes filenames and command names. bash-completion is what makes completion context-aware — it knows that the argument after apt install should be a package name, not a file:

$ sudo apt install fire<TAB><TAB>
Display all 136 possibilities? (y or n)
firebird3.0-common            firefox-esr-l10n-bs           firefox-esr-l10n-gn ...
firebird3.0-common-doc        firefox-esr-l10n-ca           firefox-esr-l10n-gu-in ...
...

136 real package names, correctly scoped to what apt install actually takes. That’s a genuine capability, not a cosmetic one. What it isn’t is pretty: a flat, alphabetical, uncoloured wall of text and a (y or n) prompt standing between you and seeing the rest of it. No fuzzy narrowing, no colour distinguishing installed from available, no ranking by relevance. It’s worth remembering that this is the ceiling of Bash’s completion experience even with the completion package installed, not the floor — Part 3’s Zsh completion runs the exact same test.

Globbing: what * actually matches by default

$ mkdir -p ~/globtest/{a,b,c}
$ touch ~/globtest/a/one.conf ~/globtest/b/two.conf ~/globtest/c/three.txt
$ cd ~/globtest
$ ls *.conf
ls: cannot access '*.conf': No such file or directory
$ ls */*.conf
a/one.conf  b/two.conf
$ ls **/*.conf
a/one.conf  b/two.conf
$ shopt globstar
globstar        off

Exactly the behaviour the globstar setting implies: *.conf only looks in the current directory and finds nothing, */*.conf explicitly steps one level down and finds both real matches, and **/*.conf — which looks like it should recurse arbitrarily deep — behaves identically to the single-level version because globstar is off by default. Turning it on (shopt -s globstar) makes ** actually recursive, but that’s an opt-in most people never flip, which means most Bash users are one directory level shallower in their globs than they probably assume.

A real task: a host-check script

The point of a shell, day to day, is running things like this:

#!/usr/bin/env bash
for host in 1.1.1.1 8.8.8.8 192.0.2.1; do
  if ping -c 1 -W 1 "$host" >/dev/null 2>&1; then
    echo "$host: UP"
  else
    echo "$host: DOWN"
  fi
done
$ ./checkhosts.sh
1.1.1.1: UP
8.8.8.8: UP
192.0.2.1: DOWN

192.0.2.1 is a TEST-NET-1 documentation address, so DOWN is the correct, expected result, not a fault in the script. This is the part of the comparison that matters most for the eventual verdict of this series: this script runs unmodified in Zsh, and it will still need #!/usr/bin/env bash or #!/bin/bash at the top regardless of which shell you’re typing into interactively, because portability to whatever’s already installed on the next box matters more than which shell wrote it.

The baseline prompt

$ echo $PS1
\[\e]0;\u@\h: \w\a\]${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$

Two colours (green for user@host, blue for the path) and nothing else — no git branch, no exit status, no context about what’s actually running. It’s not broken, it’s just not telling you much, and building anything richer than this means either writing your own PS1 escape-sequence soup by hand or reaching for a framework. Zsh’s answer to this is next.

What this establishes

Nothing here is broken and nothing was a struggle — that’s rather the point. Bash does the job, completion works once bash-completion is installed, history recall works, scripts run exactly as written. What it doesn’t do is much beyond that: no colour in completion output, no fuzzy anything, a prompt that’s two colours and a path, and a glob operator that looks recursive and isn’t unless you ask it to be. That’s the baseline. Part 3 runs every one of these same five tests again, unmodified, in Zsh.

What’s next

Part 3 installs Zsh on the same debian1 box and repeats this exact set of tests — same history, same completion target, same globtest directory, same script, unmodified — to check the compatibility claim directly rather than just asserting it, before getting into what Zsh actually adds on top.

Cross-references: for the scripting side of Bash in more depth, see Bash Patterns Every Network Engineer Should Know.