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

$(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:

1
$( ___ )
$( ___ )

[[ ___ ]]

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:

1
$([[ ___ ]] ___ )
$([[ ___ ]] ___ )

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:

1
$([[ $(git status ___ ) ___ ]] ___ )
$([[ $(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:

1
$([[ $(git status 2 ___ ) ___ ]] ___ )
$([[ $(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:

1
$([[ $(git status 2> ___ ) ___ ]] ___ )
$([[ $(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:

1
$([[ $(git status 2> /dev/null ___ ) ___ ]] ___ )
$([[ $(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:

1
$([[ $(git status 2> /dev/null | ___ ) ___ ]] ___ )
$([[ $(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:

1
$([[ $(git status 2> /dev/null | tail ___ ) ___ ]] ___ )
$([[ $(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:

1
$([[ $(git status 2> /dev/null | tail -n1) ___ ]] ___ )
$([[ $(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:

1
$([[ $(git status 2> /dev/null | tail -n1) != ___ ]] ___ )
$([[ $(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:

1
2
$([[ $(git status 2> /dev/null | tail -n1) != "nothing to commit, working 
directory clean" ]] ___ )
$([[ $(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:

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

1
2
$([[ $(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 "*")

$([[ $(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:

1
$(git branch 2> /dev/null | ___ )
$(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:

1
$(git branch 2> /dev/null | sed ___ )
$(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:

1
$(git branch 2> /dev/null | sed -e ___ )
$(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:
    1. If there isn’t an asterisk (^*), which is what we’re looking for, then delete that line (d) and go to the next.
    2. 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:

1
$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e ___ )
$(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:

1
$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e " ___ ")
$(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:

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

* \(.*\)

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:

1
$(git branch 2> /dev/null | sed -e '/^[^*]/d' -e "s/* \(.*\)/ ___ /")
$(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:

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

1
2
$(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 "*")/")

$(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!

3 thoughts on “Bash shell prompt dissected: Display Git branch name and status in color

  1. Henry

    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.

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *