I wanted to add some information to the shell prompt from a script recently and learned some things about bash, PS1 and shell variables along the way.

What is a shell prompt and why would I want to set it?

The shell prompt is the chunk of text that comes before your cursor when you open a terminal. For instance, a bash prompt might look like this:

bash-3.2$ echo "hi"
hi
bash-3.2$ echo $PS1
\s-\v\$

The variable called PS1 handles the prompting. You can check out the bash man page for all of the escape characters that are available. In this case, my prompt is using \s for “Shell Name” (bash) and \v for the Version of bash (3.2).

Why would I want to change it in a script?

There’s all sorts of useful things I’ve seen people put into their shell prompts. The default robbyrussell theme for Oh-My-Zsh is a good example:

Zsh prompt showing git branch, path, and previous exit status

It shows the current path, the exit status of the last command as the color of the arrow prompt, and the current git branch (if applicable).

Tools like python virtualenv want to modify the prompt to indicate the state they’re currently in. A virtual environment for python changes which version of python runs and where packages are installed. In order to remind the user which virtual environment they’re in, when you “activate” a virtual environment, it adds a string to the beginning of your PS1 to indicate the state.

➜  ~ source venv/bin/activate
(venv) ➜  ~

How can I set the PS1 in a script?

Setting the PS1 for the current shell is simple. Just set the PS1 equal to some new string. For instance:

➜  ~ PS1="my-super-prompt >> "
my-super-prompt >>
my-super-prompt >>
my-super-prompt >>
my-super-prompt >>

However, things get a little weird when I try setting it from a script:

➜  ~ cat set_ps1.sh
#!/bin/bash

PS1="my-super-prompt >> "
➜  ~ bash set_ps1.sh
➜  ~

The shell execution model helps understand why this doesn’t work.

Shell Execution Model

I find it useful to think of the shell as a “bash” REPL (Read Eval Print Loop). I type in some text, my shell reads and evaluates the text, prints the result, and loops back to prompt me what to do next.

When it’s evaluating my command to execute the script, however, it’s actually forking an entirely new process off my shell session1. In this sub-process, it evaluates the commands, and sends the results back to its parent: my shell.

This means that the variables in my current shell are unaffected by whatever happens from the command I execute. (With the exception of $? and other specific variables that contain the exit code). It’s sort of like how light can’t escape a blackhole once it has passed the event horizion. Once we’ve forked the subprocess, there’s no way for the sub-process to change my PS1. However, variables from my shell sesssion are inherited by the sub-process.

Options for Setting variables

Fortunately, I’m not completely out of luck. There are a few ways to set variables in the current environment.

  1. Using source or .
  2. Printing commands and running eval on them
  3. Set variables for a specific use case and run another sub command.

source or .

In the bash source code2 for source, it says:

Read and execute commands from FILENAME in the current shell.

This changes the execution model by executing commands in the current shell process rather than forking. So we can do:

➜  ~ source set_ps1.sh
my-super-prompt >>
my-super-prompt >>
my-super-prompt >>

The source code also indicates that source and . are literally identical, so they can be used interchangeably. source is more readable in my opinion.

Printing commands and running eval on them

Another path forward is to have the script print out the variable assignments it wants to and running eval on them. From the source:

Combine ARGs into a single string, use the result as input to the shell, and execute the resulting commands.

Here is how that looks:

➜  ~ cat set_ps1_eval.sh
#!/bin/bash

echo "PS1=\"my-super-prompt >> \""

➜  ~ eval $(bash set_ps1_eval.sh)
my-super-prompt >>
my-super-prompt >>
my-super-prompt >>

Set variables and invoke sub command

Another option is to set variables and invoke another subcommand that will inherit from our subprocess. Here is what that looks like:

➜  ~ cat set_ps1_subcmd.sh
#!/bin/bash

export PS1="my-super-prompt >> "

bash
➜  ~ bash set_ps1_subcmd.sh
my-super-prompt >>
my-super-prompt >>
my-super-prompt >>

In this example, there’s 3 shell’s running. The initial session, the one running the script, and the one that gets forked off the script by calling bash at the end.

Weighing the Options

The first two options require the user to type source or eval. The last option avoids this, but at the cost of spawning a bunch of sub-shells. Additionally, if you kept invoking commands, you would do deeper and deeper into nested shell sessions.

One way to make the first two options more palatable is to set up some bash functions in the user’s .bashrc/.zshrc so that invoking the command can run source for them. This prevents the user from needing to remember the source incantation. This looks like this:

bash-3.2$ cat ~/.bashrc

function set_ps1() {
  source set_ps1.sh
}

bash-3.2$ set_ps1
my-super-prompt >>

It turns out that messing with .bashrc files is pretty invasive and finicky. Order often matters, and everyone’s setup is often a fragile snowflake that can come crashing down from minor tweaks. This approach provides a better happy path experience, but may cause some pain in administration since the state-space is essentially infinite.

Conclusion

My takeaway from this experience is that if I should think long and hard before relying on the PS1 for any tools I’m writing. Every way to adjust it has its tradeoffs, so there is a high bar for the value the information has to add for it to be worth it.

Resources

Further investigation

  • What are the reasons for the fork / exec model for shells?
  • How are variables stored and looked up?
  • What are some recent features added to bash?
  • What’s different about the execution models of bash / zsh / fish / dash etc.?

  1. The relevant pieces from the source code are: execute_disk_command and make_child↩︎

  2. Here’s how to download the bash source git clone git://git.savannah.gnu.org/bash.git. It’s 224MB and it’s mostly C code (from sloccount):

    Totals grouped by language (dominant language first):
    ansic:       104189 (86.14%)
    sh:            7236 (5.98%)
    yacc:          5214 (4.31%)
    perl:          4227 (3.49%)
    asm:             48 (0.04%)
    awk:             23 (0.02%)
    sed:             16 (0.01%)
    
     ↩︎