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?
- What would happen if I
- 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
See the XDG Base Directory Specification.↩︎
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.↩︎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.↩︎