An indulgent tour of my git prompt

An introduction to my humble-yet-practical git prompt.
Published

14 August 2022

Photo: Set of well-worn tools, by Nina Mercado

What makes a good git prompt?

This is somewhat in the eye of the beholder, but in my opinion the context you need from a git prompt is just enough to decide what command you might need to run next (e.g. git commit? git status?)

Secondly, a noisy prompt is counterproductive. I find that people often can’t read their own plugin-powered clown prompts with bells and whistles powerline glyphs. Really you just need a simple reminder (a prompt if you will): where am I, and what am I doing?

In my experience the sweet spot between necessary context and simplicity is to be able to immediately answer the following combination of questions:

  • What do I have checked out at the moment?
    • Am I on a specific branch or tag?
    • Am I in a detached HEAD state? Which commit am I on?
  • Are there any untracked files?
    • What would happen if I git reset right now, for example?
    • Is there something that needs to be ignored or deleted?
  • Do I have any changes staged? Did I stage all of the changes?
    • Am I ready to commit?
    • Have I touched any files since staging them?
    • Is there something I’ve staged and forgotten about?

So my git prompt — a git-prompt command which I add to my path — is a program which outputs a string answering these questions in a concise, readable way.

Creating the git-prompt command

Firstly we create a new file somewhere on our $PATH — in this case I’m using ~/.local/bin (a standard location for user executables1), but it could just as easily be ~/bin or /usr/local/bin or anywhere else:

$ touch ~/.local/bin/git-prompt

We then mark the file as exutable:

$ chmod +x ~/.local/bin/git-prompt

Finally we add a shebang as the first line of our script. This tells the operating system to execute the file by interpreting its contents using the (system’s) bash shell.2

#!/bin/bash

Now we’re ready to look at the content of our git prompt command.

Building up the prompt output

We begin by getting the currently checked out commit hash, while redirecting any errors to /dev/null (i.e. discarding standard error, rather than writing git’s error output to the prompt):

commit="$(git rev-parse HEAD 2>/dev/null)"

This sets the value of $commit to the SHA sum of the currently checked out commit — a long hexadecimal string like 815526d6259a....

In the case that there was no return value from git rev-parse HEAD, we can assume that we aren’t in a git repository and can simply exit (with an error3 which we will ignore) without outputting a prompt:

if [[ -z $commit ]]; then
    exit 1
fi

Now that we know we’re in a git repository, we want to find a useful name for the currently checked out commit. First, we ask git if there’s a branch name associated with this commit (again discarding errors):

ref="$(git branch --show-current 2>/dev/null)"

If we don’t get a branch name back, then we instead try to find out if there’s a matching tag instead. This means that our prompt will be able to tell us if we’ve checked out the tag v1.0.0, for example, even if it doesn’t correspond to a branch:

if [[ -z $ref ]]; then
    ref+="$(git tag --points-at HEAD)"
fi

If all else fails, then we use a truncated version of the commit hash, taking the first 8 characters, which will effectively uniquely identify this commit:

if [[ -z $ref ]]; then
    ref+="${commit:0:8}"
fi

So we now have a value in $ref that looks like main or v1.0.0 or 815526d6, for example.

All we need now is to annotate that with a summary of the state of the working tree, to let us know if we have any staged changes, untracked files, etc. (with the expectation that we’ll be running git status to get the actual details if necessary).

For this we use git status --porcelain and parse the resulting lines:

status=$(git status --porcelain)

IFS=$'\n'
for line in $status; do
    [[ -z $untracked ]] && [[ $line =~ ^\? ]]           && untracked="?"
    [[ -z $staged ]]    && [[ $line =~ ^[[:alnum:]] ]]  && staged="+"
    [[ -z $modified ]]  && [[ $line =~ ^.[[:alnum:]] ]] && modified="*"
done

This is saying: for each line in the output of git status --porcelain, for each of the different status types we’re interested in (i.e. of the presence of untracked files, of staged files and of unstaged dirty files), identify whether such a file exists if we haven’t seen it before, and then set a corresponding variable to its display character — i.e.:

  • $untracked is set to ? if we’ve seen an untracked file
  • $staged is set to + if we’ve seen a staged file
  • $modified is set to * if we’ve seen a modified file

All that’s left is for us to output the prompt by concatenating the values of the $ref and our various status markers together:

echo "${ref}${untracked}${modified}${staged}"

We now have a command git-prompt which, when run within the context of a git repo, will output strings like main?*, some/branch+, v1.0.0, e11c195e*, etc.

Rendering the prompt

Displaying this prompt is just a matter of invoking this command from wherever the shell defines its prompt. For example…

bash:

# ~/.bashrc
export PS1='...$(git-prompt) ...'

zsh:

# ~/.zshrc
PROMPT='...$(git-prompt) ...'

using starship.rs:

# starship.toml

format = "...${custom.git}..."

[custom.git]
command = "git-prompt"
format = "([$output]($style) )"
style = "green"
when = true

Footnotes

  1. See the XDG Base Directory Specification.↩︎

  2. Note that we could instead use the user’s preferred bash here by using #!/usr/bin/env bash. There’s no strong reason to choose one over the other — this is just to discourage us from using “newer” bash features, for better portability.↩︎

  3. We’re choosing to return non-zero here since it has no consequence in the context of the prompt but might make the git-prompt command more useful to other scripts some day.↩︎