Ramblings of an aging IT geek
← Ramblings of an aging IT geek
tooling

a prompt that earns its space

Rebuilding my shell prompt around the few things I actually check before hitting enter: git state, exit code, and which host I'm on.

A terminal with a custom shell prompt

I ran a destructive command on the wrong host last week. Nothing irreversible, but enough of a fright to make me look hard at the one piece of interface I stare at all day and almost never think about: the prompt. The realisation was that my prompt was decorative. It had colours and a clock and a little battery glyph, none of which I'd ever consciously read, and it was missing the one thing that would have stopped me, which is an unmissable signal of where I am.

So I rebuilt it around a simple rule. The prompt should only show things I check before I press enter, and it should show them in a way I can't help noticing. Everything else is noise dressed up as information.

what I actually check

When I'm honest about it, there are three things my eyes land on before a risky command:

  • Which host am I on. Local or remote, and if remote, is it somewhere I should be careful. This is the one that nearly bit me.
  • What git state am I in. Branch, and whether the tree is dirty. Most of my mistakes are committing or pushing from the wrong branch.
  • Did the last command succeed. A non-zero exit code is worth knowing immediately, not after I've already moved on and run the next thing assuming the last one worked.

That's it. Not the time, not the battery, not the phase of the moon.

A close-up of the prompt showing branch and exit status

building it

I kept it in plain bash so it works on every box I touch without dragging a framework along. The exit code is the fiddly bit, because you have to capture $? as the very first thing in PROMPT_COMMAND before anything else clobbers it:

__prompt() {
  local code=$?            # must be first
  local red='\[\e[31m\]' grn='\[\e[32m\]' ylw='\[\e[33m\]' rst='\[\e[0m\]'

  # exit status: a red marker only when something failed
  local status=""
  [ "$code" -ne 0 ] && status="${red}✗${code}${rst} "

  # git branch and dirty flag, quietly nothing outside a repo
  local git=""
  local branch
  branch=$(git symbolic-ref --short HEAD 2>/dev/null)
  if [ -n "$branch" ]; then
    local dirty=""
    git diff --quiet --ignore-submodules HEAD 2>/dev/null || dirty="*"
    git="(${ylw}${branch}${dirty}${rst}) "
  fi

  # host: leave local plain, shout about remote
  local host=""
  [ -n "$SSH_CONNECTION" ] && host="${red}\u@\h${rst} "

  PS1="${status}${host}${git}\w \$ "
}
PROMPT_COMMAND=__prompt

A few deliberate choices in there. The exit marker shows nothing at all on success, so a clean line means a clean run and a red ✗1 is impossible to miss. The hostname only appears over SSH, and in angry red, so a remote shell never looks like my laptop. The git dirty flag is a single *, which is all I need; I don't want a count of staged and unstaged and stashed files cluttering the line, just "is this tree clean or not".

The one performance note worth making: git diff --quiet runs on every prompt inside a repo, and in a very large repo that can add a perceptible lag. On the handful of monster repos where it matters I drop the dirty check, because a slow prompt is its own kind of papercut and I'll trade the * for instant responsiveness. Everywhere else it's imperceptible.

the result

It's plainer than what I had before and I read it far more often, which is the whole point. A clean line, a yellow branch, my own short hostname implied by its absence. When something's off, the screen tells me loudly: a red exit code, or worst of all, a red user@prod-box staring back at me before I do something I'll regret. That last one is the line I added for exactly the mistake that started this, and it's already made me pause once with my finger over enter. Cheap at the price.