Bash shell prompt dissected: Display Git branch name and status in color

Octocat and a baby Groot looking over a newspaper.
Jason and Ryan sitting at a coffee shop

I received a snippet of code from my good friend Jason today. It goes into your .bash_profile and what it does is add two pieces of information to your Bash shell prompt when you’re in a Git working directory. With it, you’ll be able to tell:

  1. Which branch you’re currently on and
  2. Whether the branch has untracked changes.

The code, now compacted, looks like this:

export PS1='\[email protected]\h:\[\033[1;34m\]\w\[\033[0;33m\]$(git branch 2> /dev/null | sed 
-e '/^[^*]/d' -e "s/* \(.*\)/[\1$([[ $(git status 2> /dev/null | tail -n1) != 
"nothing to commit, working directory clean" ]] && echo "*")]/")\[\e[0m\]$ '

Note (added on 5/19/14): I added two manual line breaks to the snippet above for readability, so if you are copying and pasting it, make sure everything ends up on one line. You can also, as Henry suggests, add a line continuation character to the end of the first two lines.

Holy Halloween, Batman!” is what I thought when I initially saw this, but after a good bit of research, it all made sense. I set off on a mission to be able to explain every character in this one-liner. Hopefully, after you read this, you’ll be able to modify your prompt to meet your needs and feel comfortable creating your own variation of it.

export

The export command allows environment variables to be modified.

When a program is invoked, it is given an array of strings called the environment. This is a list of name–value pairs, of the form name=value.

The shell provides several ways to manipulate the environment. On invocation, the shell scans its own environment and creates a parameter for each name found, automatically marking it for export to child processes. Executed commands inherit the environment. The export and declare -x commands allow parameters and functions to be added to and deleted from the environment.

If you run the printenv command, you can see all the variables that are currently loaded (I removed a few variables):

[email protected]:~ $ printenv
TERM_PROGRAM=Apple_Terminal
SHELL=/bin/bash
TERM=xterm-256color
USER=ryansechrest
PWD=/Users/ryansechrest
LANG=en_US.UTF-8
PS1=\[email protected]\h:\w\$
HOME=/Users/ryansechrest
...

PS1

As you can see from above, one of the environment variables is PS1, which stands for “Prompt Statement 1,” not to be confused with the PlayStation 1. There are a total of four prompt statements:

  1. Default interaction prompt (PS1)— When you open Terminal.
  2. Continuation interaction prompt (PS2)— When your command spans multiple lines.
  3. Select prompt (PS3)— When you use select to build a menu.
  4. Debug prompt (PS4)— When you use set -x to debug output.

Default interaction prompt

# export PS1='\[email protected]\h \w$ '

[email protected] ~$

Continuation interaction prompt

# export PS2='continue> '

[email protected] ~$ echo 'Who knew multiple lines
continue> could be so much fun?'
Who knew multiple lines
could be so much fun?

Select prompt

# export PS3='Take: '

[email protected] ~$ ready
This is your last chance. After this, there is no turning back. You take the blue pill - the story ends, you wake up in your bed and believe whatever you want to believe. You take the red pill - you stay in Wonderland and I show you how deep the rabbit-hole goes.

1) Blue
2) Red

Take: 2

The pill you took is part of a trace program. It's designed to disrupt your input/output carrier signal so we can pinpoint your location.

Debug prompt

# export PS4='Debug (\@) $ '

+ export 'PS1=\[email protected]\h \w$ '
+ PS1='\[email protected]\h \w$ '
+ export 'PS2=continue > '
+ PS2='continue > '
+ export 'PS3=Take: '
+ PS3='Take: '
+ export 'PS4=Debug (\@) $ '
+ PS4='Debug (\@) $ '
Debug (03:41 PM) $ alias 'll=ls -aGhlp'
Debug (03:41 PM) $ alias '~=cd ~'
Debug (03:41 PM) $ alias '..=cd ../'
...
[email protected] ~$

PROMPT_COMMAND

There’s also a variable called PROMPT_COMMAND, which is executed right before the prompt statement is displayed. You could use this, for example, to modify the prompt or display additional information above it:

# export PROMPT_COMMAND=date

Sun Nov 17 15:45:39 CST 2013
[email protected] ~$

I executed the date command prior to displaying the prompt, which, as you can see, printed the date and time information right above the prompt.

All that said, if we wrap the PS1 value in single quotes, our new prompt starts like this:

export PS1=''

f you are a less-is-more kind of person, this prompt is for you, but if the possibilities intrigue you, read on.

\[email protected]\h:

The backslash you see is the Bash escape character, which tells the shell to interpret the next character literally. If you’ve ever written anything in PHP, for example, I’m sure you’ve used literals to output newlines (\n), returns (\r), tabs (\t), and more.

I was just recently reminded that you have to remove newline breaks when processing contact form messages, otherwise the Agent Smiths of the world will exploit your contact form to spam the human race.

If you look at a table of special characters in Bash, you’ll see that:

  • \u is the username e.g. ryansechrest
  • \h is the hostname e.g. home

Here are all the characters that can appear in a prompt variable:

  • \a— A bell character.
  • \d— The date, in “Weekday Month Date” format (e.g., “Tue May 26”).
  • \e— An escape character.
  • \h— The hostname, up to the first ‘.’.
  • \H— The hostname.
  • \j— The number of jobs currently managed by the shell.
  • \l— The basename of the shell’s terminal device name.
  • \n— A newline.
  • \r— A carriage return.
  • \s— The name of the shell, the basename of $0 (the portion following the final slash).
  • \t— The time, in 24-hour HH:MM:SS format.
  • \T— The time, in 12-hour HH:MM:SS format.
  • \@— The time, in 12-hour am/pm format.
  • \A— The time, in 24-hour HH:MM format.
  • \u— The username of the current user.
  • \v— The version of Bash (e.g., 2.00).
  • \V— The release of Bash, version + patchlevel (e.g., 2.00.0).
  • \w— The current working directory, with $HOME abbreviated with a tilde.
  • \W— The basename of $PWD, with $HOME abbreviated with a tilde.
  • \!— The history number of this command.
  • \#— The command number of this command.
  • \$— If the effective uid is 0, #, otherwise $.
  • \nnn— The character whose ASCII code is the octal value nnn.
  • \\— A backslash.
  • \[— Begin a sequence of non-printing characters. This could be used to embed a terminal control sequence into the prompt.
  • \]— End a sequence of non-printing characters.

The at sign (@) and the colon (:) are printed out as-is. If you set PS1 to \[email protected]\h:, it would look like this:

[email protected]:

At this point, PS1 looks like this:

export PS1='\[email protected]\h:'

\[\033[ ___ m\]

As you’ve just seen in my list, a backslash followed by an open (\[) and close (\]) square bracket marks the beginning and end, respectively, of a sequence of non-printing characters.

When you see a backslash and three digits (\033), it will be read as an eight-bit octal value, which is ASCII’s ESC character, presumably for the upcoming color code, 1;34.

The letter m tells Bash that it’s the end of the color sequence.

At this point, PS1 looks like this:

export PS1='\[email protected]\h:\[\033[ ___ m\]'

1;34

To set the color, you can supply multiple values separated by a semi-colon. Common values are found between 0 and 47, and can be grouped into three categories:

  1. 0-8— Text attributes
  2. 30-37— Foreground colors
  3. 40-47— Background colors

Here is a list of them:

Text attributes

  • 0— All attributes off
  • 1— Bold on
  • 4— Underscore (on monochrome display adapter only)
  • 5— Blink on
  • 7— Reverse video on
  • 8— Concealed on

Foreground colors

  • 30— Black
  • 31— Red
  • 32— Green
  • 33— Yellow
  • 34— Blue
  • 35— Magenta
  • 36— Cyan
  • 37— White

Background colors

  • 40— Black
  • 41— Red
  • 42— Green
  • 43— Yellow
  • 44— Blue
  • 45— Magenta
  • 46— Cyan
  • 47— White

For even more escape sequences, take a look at the ANSI escape sequence table.

If we put all that together, we get export PS1='\[email protected]\h:\[\033[1;34m\], which looks like this:

[email protected]:

That’s right, it looks exactly the same as before, however, any characters I now type into the shell will be a bold and blue:

(image)

At this point, PS1 looks like this:

export PS1='\[email protected]\h:\[\033[1;34m\]'

\w

Adding \w will output my current working directory:

(image)

It looks messy now, because once the color has been set (and not unset), everything will be in that color going forward.

At this point, PS1 looks like this:

export PS1='\[email protected]\h:\[\033[1;34m\]\w'

\[\033[0;33m\]

You can probably deduce this now. It will turn all text attributes off (0) and change the color to yellow (0;33).

At this point, PS1 looks like this:

export PS1='\[email protected]\h:\[\033[1;34m\]\w\[\033[0;33m\]'

$(git branch 2> /dev/null | sed -e ‘/^[^*]/d’ -e “s/* \(.*\)/[\1$([[ $(git status 2> /dev/null | tail -n1) != “nothing to commit, working directory clean” ]] && echo “*”)]/”)

This is the next big piece of the puzzle. Let’s break that up into two smaller parts:

  1. $([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working directory clean" ]] && echo "*")
  2. $(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/* \(.*\)/[\1 _<#1>_ ]/")

The first part is an if statement that will either print an asterisk or not, and the second part will display the current Git branch name.

Note that the code from part one was extracted from two, hence the made-up placeholder (_<#1>_) in part two to indicate this.

$([[ $(git status 2> /dev/null | tail -n1) != “nothing to commit, working directory clean” ]] && echo “*”)

$( ___ )

A dollar sign ($) followed by parenthesis (()) is called command substitution, which means that the output within the parenthesis will replace the command itself.

Note that when you see three underscores (___), more code is to follow here in one of the upcoming steps.

At this point, part one looks like this:

$( ___ )

[[ ___ ]]

This is a conditional construct. Whatever happens in there will either return a zero (0) or a one (1), depending on whether the condition is true or false.

At this point, part one looks like this:

$([[ ___ ]] ___ )

git status

This runs git status, which outputs the status of a directory initialized with Git:

# On branch develop
nothing to commit, working directory clean

Or, if it’s not initialized:

fatal: Not a git repository (or any of the parent directories): .git

At this point, part one looks like this:

$([[ $(git status ___ ) ___ ]] ___ )

2

The number two here refers to a stream. There are a total of three streams:

  1. Standard input (STDIN)— Data going into the program.
  2. Standard output (STDOUT)— Data leaving the program when successful.
  3. Standard error (STDERR)— Data leaving the program when unsuccessful.

These streams are referenced as 0, 1, and 2, respectively. So in our case above, we’re grabbing any errors that may have occurred, such as when git status was run in a non-Git directory.

An error is determined by a program’s exit status. If a program exits with a zero (0), everything worked as expected, however, should it exit with any other number, we know the program failed.

At this point, part one looks like this:

$([[ $(git status 2 ___ ) ___ ]] ___ )

>

In our context, the greater than sign (>) is a redirection operator, which is used to direct output. In our case, we’re directing the error response somewhere.

Note that there is no space between the number two and the redirection operator.

At this point, part one looks like this:

$([[ $(git status 2> ___ ) ___ ]] ___ )

/dev/null

Please send all complaints to /dev/null.

If you have output in your program and don’t know what to do with it, you can always send it to /dev/null. It’s a file that discards all content it receives, but still reports back that the write operation has succeeded.

At this point, part one looks like this:

$([[ $(git status 2> /dev/null ___ ) ___ ]] ___ )

|

It’s an “i,” it’s an “L,” no, it’s a pipe!

The pipe is a control operator, which let’s you chain commands. Whatever output happens on the left, gets sent over to the right. We’re sending errors to /dev/null, but any other output that’s not an error will be passed along.

At this point, part one looks like this:

$([[ $(git status 2> /dev/null | ___ ) ___ ]] ___ )

tail

The tail command lets you display the last part of a file, or in this case, the success output from the left side of the pipe.

At this point, part one looks like this:

$([[ $(git status 2> /dev/null | tail ___ ) ___ ]] ___ )

-n1

As an option of tail, -n lets you specify the last n lines you want, and in this case, we just want the last line.

At this point, part one looks like this:

$([[ $(git status 2> /dev/null | tail -n1) ___ ]] ___ )

!=

The not equal sign (!=) is an operator that will compare what’s on the left of it to what’s on the right. If you use this with the previously mentioned conditional construct, you can ask questions within the program that will result in either a true or false.

In terms of using operators in expressions, you have a few other options, too:

  • string1 == string2— Returns true if both strings are equal.
  • string1 < string2— Returns true if string1 lexicographically sorts before string2 .
  • string1 > string2— Returns true if string1 lexicographically sorts after string2.

Take a look at a table of operators that can be used in conditional expressions.

At this point, part one looks like this:

$([[ $(git status 2> /dev/null | tail -n1) != ___ ]] ___ )

“nothing to commit, working directory clean”

To put it all together, there are three possible answers to the “not equal” question:

  1. Is ___ not equal to nothing to commit, working directory clean?
    The only time it would be blank is if an error occurred, since the response is discarded, so in this case it would be true, considering ___ is not equal to the string provided.
  2. Is nothing to commit, working directory clean not equal to nothing to commit, working directory clean?
    When git status is run in a valid Git directory and no files have been modified, then no, that would be false, because they are equal.
  3. Is nothing added to commit but untracked files present (use "git add" to track) not equal to nothing to commit, working directory clean?
    Yes, this is true, the two strings are not equal.

At this point, part one looks like this:

$([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working 
directory clean" ]] ___ )

&&

When dealing with shell arithmetic, the double ampersand (&&) checks whether both sides of the ampersands are true. If they are, the entire statement is true. If one of them were false, the entire statement would be considered false.

When dealing with list commands, as we are in this case, the second command, the one on the right of the double ampersands, will only be executed if the command on the left was successful i.e. the command on the left exited with a zero (exit 0).

At this point, part one looks like this:

$([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working 
directory clean" ]] && ___ )

echo “*”

This simply prints an asterisk to the screen.

The completed part one looks like this:

$([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working 
directory clean" ]] && echo "*")

$([[ $(git status 2> /dev/null | tail -n1) != “nothing to commit, working directory clean” ]] && echo “*”)

So, to put all of part one together, if git status exits with anything other than a zero, which indicates that an error has occurred, it will send those contents to /dev/null. If git status exits with a zero, it will send the output over the pipe to the right side. Then tail will run and look at the last line (-n1) of the output and compare it to our “nothing to commit…” string. In the event that the strings don’t match, an asterisk will be printed on the screen to show you that something has changed in the Git working directory.

Let’s look at the second part of this.

$(git branch 2> /dev/null | sed -e ‘/^[^*]/d’ -e “s/* \(.*\)/[\1 __#1__ ]/”)

$(git branch 2> /dev/null ___ )

This should be familiar now. While we’re running git branch instead of git status, it behaves exactly the same. We’re also reusing the pipe to chain commands.

At this point, part two looks like this:

$(git branch 2> /dev/null | ___ )

sed

This is a stream editor that can automatically (i.e. non-interactive) read and manipulate data streams, or in this case, our result from the left side of the pipe. There is a whole 104KB page dedicated to explaining what sed can do or you can take a look at the sed manual.

In simple terms, every line is read (newline character discarded) by sed and placed into a pattern buffer. After that, it will run any commands you supplied and modify the contents of the pattern buffer per your request. Once completed, it will output the contents to STDOUT.

At this point, part two looks like this:

$(git branch 2> /dev/null | sed ___ )

-e

Option -e is used to combine multiple commands, which allows us to drill down to the data we want. It’s almost the equivalent of the pipe when chaining commands, but this let’s you chain filters/patterns.

At this point, part two looks like this:

$(git branch 2> /dev/null | sed -e ___ )

‘/^[^*]/d’

The whole thing is wrapped in single quotes so sed knows where the command starts and ends. The two forward slashes (/ ___ /) are for using patterns. The first carrot (^), also known as an anchor, says to start at the beginning of the line. The square brackets ([ ___ ]) are for character sets, which lets you supply the characters you’re looking for, however, because it is preceded by another carrot, the pattern is changed from “any character that is an asterisk” to “any character that is not an asterisk”. The last letter is a d, which stands for delete.

To see what output sed is dealing with, let’s run git branch:

* develop
  master

Here is how sed evaluates our command:

  1. Read the first line: * develop\n.
  2. Discard the newline character: \n.
  3. Start at the beginning of the line: * develop.
  4. Go down one of two paths:
  5. -- If there isn’t an asterisk (^*), which is what we’re looking for, then delete that line (d) and go to the next.
  6. -- If there is an asterisk, which fails our pattern check, then don’t do anything with that line and go to the next.

In the end, we’re left with the line that had an asterisk, because all other lines were deleted.

At this point, part two looks like this:

$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e ___ )

” ___ “

The reason this next command is wrapped in double quotes is because part one will later be executed in there, and it won’t get executed if wrapped in single quotes.

Just as a reminder, the string we’re now applying the pattern to is * develop.

At this point, part two looks like this:

$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e " ___ ")

s/ ___ / ___ /

The s you see here is for substitution. Anything on the left of the middle forward slash (/) will be replaced with what’s on the right.

At this point, part two looks like this:

$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/ ___ / ___ /")c

* \(.*\)

This is the pattern for what we’re replacing. The first asterisk and space (* ) says to match the asterisk and space from * develop. The escaped parenthesis (\( ___ \)) are for creating backreference, which remembers our previous match (the asterisk and space). The period, used to match any character, and the asterisk, used to match any number of times, grabs all of the remaining characters, which is now just develop.

At this point, part two looks like this:

$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/* \(.*\)/ ___ /")

[\1 _<#1>_ ]

The square brackets ([ ___ ]) are printed as-is. The backslash one (\1) is a placeholder for the match that occurred within the parenthesis from the previous step, which had a value of develop. Last, but not least, the _<#1>_ is where the result from part one will appear, which if you remember, will either be an asterisk or nothing, indicating whether there are untracked changes or not.

The completed part two looks like:

$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/* \(.*\)/[\1 _<#1>_ /")

If we place part one into part two, it now looks like this:

$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/* \(.*\)/[\1$([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working directory clean" ]] && echo "*")/")

$(git branch 2> /dev/null | sed -e ‘/^[^*]/d’ -e “s/* \(.*\)/[\1$([[ $(git status 2> /dev/null | tail -n1) != “nothing to commit, working directory clean” ]] && echo “*”)]/”)

Putting that all together now, this may output one of the following:

  • [develop]
  • [develop*]
  • [master]
  • [master*]

Pretty cool, right?

If we place our two parts into our original PS1, it’ll now look like this:

export PS1='\[email protected]\h:\[\033[1;34m\]\w\[\033[0;33m\]$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/* \(.*\)/[\1$([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working directory clean" ]] && echo "*")]/") ___ '

\[\e[0m\]

This changes the prompt color back to the default (in my case orange). Interestingly enough, instead of using \033 for ESC, \e was used, but it’s good to know that they are interchangeable.

At this point, PS1 looks like this:

export PS1='\[email protected]\h:\[\033[1;34m\]\w\[\033[0;33m\]$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/* \(.*\)/[\1$([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working directory clean" ]] && echo "*")]/")\[\e[0m\] ___ '

$

And finally, we print out a dollar sign ($) followed by a space, which is the last character that appears in the prompt.

The complete PS1 then looks like this:

export PS1='\[email protected]\h:\[\033[1;34m\]\w\[\033[0;33m\]$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/* \(.*\)/[\1$([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working directory clean" ]] && echo "*")]/")\[\e[0m\]$ '

Conclusion

This has been really interesting to figure out. Some of it was completely new to me, but by trying to explain it, it made me think about how and why things work the way they do.

If you have any questions or problems, or spot a mistake, please let me know!

Featured image by Praveen Thirumurugan.


Comments (3)

Previously posted in WordPress and transferred to Ghost.

ivan
March 23, 2014 at 7:25 pm

Awesome explanation. Thanks!

Henry
May 19, 2014 at 6:05 am

Great tip. Only thing I had to add were Unix line continuation characters (`/`) at end of first two lines. Minor point, but added another couple minutes to deploy. Would be great for the next person if they were included in your snippet.

Ryan Sechrest
May 19, 2014 at 9:43 am

Thanks for the caveat, Henry. I added a note below the first occurrence to inform readers of that.