The Universe of Discourse


Sat, 06 Feb 2021

Hacking the git shell prompt

Git comes with a very complicated shell function,, called __git_ps1, for interpolating Git information into your shell prompt. A typical use would be:

    PS1='>>> $(__git_ps1) :) '

PS1 is the variable that contains the shell's main prompt. Before printing the prompt, the shell does variable and command interpolation on this string. This means that if PS1 contains something like $(command args...), the shell replaces that string with the output from running command args…. Here, it runs __git_ps1 and inserts the output into the prompt. In the simplest case, __git_ps1 emits the name of the currently-checked-out branch, so that the shell will actually print this prompt:

    >>> the-branch :) 

But __git_ps1 has many other features besides. If you are in the middle of a rebase or cherry-pick operation, it will emit something like

    the-branch|REBASE-i 1/5

or

    the-branch|CHERRY-PICKING

instead. If HEAD is detached, it can still display the head location in several formats. There are options to have the emitted string indicate when the working tree is dirty and other things. My own PS1 looks like this:

    PS1='[$(_path) $(__git_ps1 "(%s)" )]> '

The _path command is something I wrote to emit the path of the current working directory, abbreviated in a contextually dependent way. It makes my prompt look like this:

    [lib/app (the-branch)]> 

Here lib/app is the path relative to the root of the repository.

The %s thing is an additional formatting instruction to __git_ps1. After it computes the description string, __git_ps1 inserts it into "(%s)" in place of the %s, and emits the result of that replacement. If you don't give __git_ps1 an argument, it uses "(%s) " as a default, which has an extra space compared with what I have.

Lately I have been experimenting with appending .mjd.yyyymmdd to my public branch names, to help me remember to delete my old dead branches from the shared repository. This makes the branch names annoyingly long:

    gh1067-sort-dates-chronologically.mjd.20210103
    gh1067-sort-dates-no-test.mjd.20210112
    gh1088-cache-analysis-list.mjd.20210105

and these annoyingly long names appear in the output of __git_ps1 that is inserted into my shell prompts.

One way to deal with this is to have the local branch names be abbreviated and configure their upstream names to the long versions. And that does work: I now have a little program called new-branch that creates a new branch with the local short name, pushes it to the long remote name, and sets the upstream. But I also wanted a generic mechanism for abbreviating or transforming the branch name in the prompt.

The supplied __git_ps1 function didn't seem to have an option for that, or a callback for modifying the branch name before inserting it into the prompt. I could have copied the function, modified the parts I wanted, and used the modified version in place of the supplied version, but it is 243 lines long, so I preferred not to do that.

But __git_ps1 does have one hook. Under the right circumstances, it will attempt to colorize the prompt by inserting terminal escape codes. To do this it invokes __git_ps_colorize_gitstring to insert the escape codes into the various prompt components before it assembles them. I can work with that!

The goal is now:

  • Figure out how to tell __git_ps1 to call __git_ps_colorize_gitstring
  • Figure out how __git_ps1 and __git_ps_colorize_gitstring communicate prompt components
  • Write my own __git_ps_colorize_gitstring to do something else

How to tell __git_ps1 to call __git_ps_colorize_gitstring

You have to do two things to get __git_ps1 to call the hook:

  1. Set GIT_PS1_SHOWCOLORHINTS to some nonempty string. I set it to true, which is a little deceptive, because false would have worked as well.

  2. Invoke __git_ps1 with two or more arguments.

Unfortunately, invoking the __git_ps1 with two or more arguments changes its behavior in another way. It still computes a string, but it no longer prints the string. Instead, it computes the string and assigns it to PS1. This means that

  PS1="$(__git_ps arg arg….)"

won't work properly: the next time the shell wants to prompt, it will evaluate PS1, which will call __git_ps arg arg…, which will set PS1 to some string like (the-branch). Then the next time the shell wants to print the prompt, it will evaluate PS1, which will be just some dead string like (the-branch), with nothing in it to call __git_ps1 again.

So we need to use a different shell feature. Instead of setting PS1 directly, we set PROMPT_COMMAND. This command is run before the prompt is printed. Although this doesn't have anything to do directly with the prompt, the command can change the prompt. If we set PROMPT_COMMAND to invoke __git_ps1, and if __git_ps1 modifies PS1, the prompt will change.

Formerly I had had this:

    PS1='[$(_path) $(__git_ps1 "(%s)")]> '

but instead I needed to use:

    GIT_PS1_SHOWCOLORHINTS=true
    PROMPT_COMMAND='__git_ps1 "[$(_path) " " ] "' "(%s)"

Here __git_ps1 is getting three arguments:

  1. "[$(_path) "
  2. " ] "
  3. "(%s)"

__git_ps1 computes its description of the Git state and inserts it into the third argument in place of the %s. Then it takes the result of this replacement, appends the first argument on the front and the second on the back, and sets the prompt to the result. The shell will still invoke _path in the course of evaluating the first string, before passing it to __git_ps1 as an argument. Whew.

How __git_ps1 communicates prompt components to __git_ps_colorize_gitstring

The end result of all this rigamarole is that __git_ps1 is now being called before every prompt, as before, but now it will also invoke __git_ps_colorize_gitstring along the way. What does that actually get us?

The internals of __git_ps_colorize_gitstring aren't documented because I don't think this is a planned use case, and __git_ps_colorize_gitstring isn't an advertised part of the interface. __git_ps1 does something to construct the prompt, possibly colorizing it in the process, but how it does the colorizing is forbidden knowledge. From looking at the code I can see that the colorizing is done by __git_ps_colorize_gitstring, and I needed to know what was going in inside.

The (current) interface is that __git_ps1 puts the various components of the prompts into a family of single-letter variables, which __git_ps_colorize_gitstring modifies. Here's what these variables do, as best as I have been able to ascertain:

b contains a description of the current HEAD, either the current branch name or some other description

c indicates if you are in a bare repository

i indicates if changes have been recorded to the index

p contains information about whether the current head is behind or ahead of its upstream branch

r describes the rebase / merge / cherry-pick state

s indicates if there is something in the stash

u indicates whether there are untracked files

w indicates whether the working tree is dirty

z is the separator between the branch name and the other indicators

Oddly, the one thing I wanted to change is the only one that __git_ps_colorize_gitstring doesn't modify: the b variable that contains the name or description of the current branch. Fortunately, it does exist and there's nothing stopping me from writing a replacement __git_ps_colorize_gitstring that does modify it.

Write a replacement for __git_ps_colorize_gitstring to do something else

So in the end all I needed was:

    GIT_PS1_SHOWCOLORHINTS=true
    PROMPT_COMMAND='__git_ps1 "[$(_path) " " ] "' "(%s)"

    __git_ps1_colorize_gitstring () {
        b=${b%%.[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]}
        b=${b%%.mjd}
    }

The ${b%%PAT} thing produces the value of the variable b, except that if the value ends with something matching the pattern PAT, that part is removed. So the first assignment trims a trailing .20210206 from the branch name, if there is one, and the second trims off a trailing .mjd. If I wanted to trim off the leading gh also I could use b=${b##gh}.

There's probably some way to use this in addition to the standard __git_ps_colorize_gitstring, rather than in place of it. But I don't know how.

In conclusion

This was way harder to figure out than it should have been.


[Other articles in category /prog] permanent link