Skip to main content

Copying a Bash Environment to a New Shell

In Running Base in a New Shell, I presented a script that demonstrates a method for copy the environment variables and aliases from the current Bash shell to a new Bash shell. While preparing a new release of Base, I fixed a number of bugs:

This blog entry presents an updated demonstration script. This script, sans commentary, is available on GitHub: demo.sh

Note that this demonstration script still has issues! The final version can be found in Abort Transformation!

Demo Script

The script needs access to the full environment of your current shell, so it must be sourced instead of executed in a new process. To try it out, be sure to set the permissions of the script to make it executable and run . demo.sh (or source demo.sh) to recreate your current environment in a new shell.

#!/usr/bin/env bash

The _demo_load_env function queries the current Bash shell environment and outputs configuration commands that are to be executed in the new Bash shell environment.

Environment variables are queried using the declare -p command. This command ostensibly prints commands that can be evaluated to set the same variables. The values may contain newlines, however, which makes the printed commands invalid. This implementation processes the output and transforms the commands for environment variables with values that contain newlines to use escape-quoted ($'...') syntax.

The output of declare -p is processed one line at a time, and any line starting with declare and a space is treated as the start of a declare command. Note that values that contain declare and a space after a newline are incorrectly treated as the start of a declare command. Fixing this issue requires matching quotations, which is difficult to do in Bash. (I will likely resolve this issue in the future, but it is not a priority at this time since such a value is unlikely to be used outside of tests.) This is a good example of how the limitations of Bash result in poor implementations. (See Rewrite When More Than N Lines.)

To transform the commands for environment variables with values that contain newlines the value is processed as follows:

  1. Double quotes are unescaped.
  2. Backslashes are escaped.
  3. Single quotes are escaped.
  4. A trailing double quote is transformed to a single quote. If an additional value line is found, it is transformed back to a double quote.
  5. The additional value line is appended to the current definition, after an escaped newline.

Note that some environment variables that should not be copied are filtered from the output.

This function prints the commands to STDOUT so that they can be easily loaded into an array using readarray below.

_demo_load_env () {
  local defcmd defn line quoteflag value var
  while IFS=$'\n' read -r line ; do
    if [[ "${line}" =~ ^declare ]] ; then
      if [ -n "${defn}" ] ; then
        echo "${defn}"
        defn=""
      fi
      defcmd="${line#declare -* }"
      var="${defcmd%%=*}"
      case "${var}" in
        BASH_* | FUNCNAME | GROUPS | cmd | val )
          ;;
        DEMO_ENV | defcmd | defn | line | quoteflag | value | var )
          ;;
        * )
          defn="${line}"
          ;;
      esac
    else
      if [[ "${defn}" =~ ^[^=]*=\$ ]] ; then
        if [ "${quoteflag}" -eq 1 ] ; then
          defn="${defn%"'"}\""
        fi
      else
        value="${defn#*'"'}"
        value="${value//\\'"'/'"'}"
        value="${value//\\/\\\\}"
        value="${value//"'"/\\"'"}"
        defn="${defn%%=*}=\$'${value}"
      fi
      line="${line//\\'"'/'"'}"
      line="${line//\\/\\\\}"
      line="${line//"'"/\\"'"}"
      if [ "${line: -1}" == "\"" ] ; then
        line="${line%?}'"
        quoteflag=1
      else
        quoteflag=0
      fi
      defn="${defn}\\n${line}"
    fi
  done < <(declare -p)
  test -z "${defn}" || echo "${defn}"
  alias -p
}

When sourced, the current environment configuration is loaded into an array. A new Bash shell process is executed using the script for initialization. The current environment configuration is serialized and passed to the new process using the DEMO_ENV_SER environment variable.

When the new shell process exits (presumably when the user runs exit), the remaining environment variable is removed and the source command is exited. (Note that it would be preferable to exit with the exit status of the new shell process, but I do not know of a good way to do so in this case.)

if [ -z "${DEMO_ENV_SER}" ] ; then
  if [ "${BASH_SOURCE[0]}" == "${0}" ] ; then
    echo "usage: . ${0}" >&2
    exit 2
  fi

  declare -a DEMO_ENV
  readarray -t DEMO_ENV < <(_demo_load_env)
  unset -f _demo_load_env

  /usr/bin/env \
    DEMO_ENV_SER="$(declare -p DEMO_ENV)" \
    bash --init-file "${BASH_SOURCE[0]}"

  unset DEMO_ENV
  return 0
fi

The rest of the script is executed during initialization of the new Bash shell process. The _demo_load_env function is no longer needed, so it is unset.

unset -f _demo_load_env

The _demo_restore_env function loops through configuration commands and determines which ones should be evaluated. It filters out environment variable declarations for which the environment variable already exists and is read-only.

Like _demo_load_env, this function prints to STDOUT.

  local defcmd envcmd var
  for rstcmd in "${DEMO_ENV[@]}" ; do
    if [[ "${rstcmd}" =~ ^declare ]] ; then
      defcmd="${rstcmd#declare -* }"
      var="${defcmd%%=*}"
      envcmd="$(declare -p "${var}" 2>/dev/null)"
      if [[ -z "${envcmd}" || "${envcmd}" =~ ^declare\ -[^r\ ]*\  ]] ; then
        echo "${rstcmd}"
      fi
    else
      echo "${rstcmd}"
    fi
  done

The new Bash shell is initialized by evaluating the serialized configuration commands, restoring the array, and then evaluating the commands selected by the _demo_restore_env function. Note that this evaluation cannot be done within a function, where the declarations would create variables local to the function. Finally, the _demo_restore_env function and implementation environment variables are unset.

eval "${DEMO_ENV_SER}"
while IFS=$'\n' read -r rstcmd ; do
  eval "${rstcmd}"
done < <(_demo_restore_env)
unset -f _demo_restore_env
unset DEMO_ENV DEMO_ENV_SER rstcmd

The following is a simple test of this demonstration script:

$ echo "Current shell ID: $$"
Current shell ID: 403
$ TEST_VAR=$'one\n"two\'\tthree"\nfour'
$ declare -p TEST_VAR
declare -- TEST_VAR="one
\"two'  three\"
four"
$ bash
$ echo "New shell ID: $$"
New shell ID: 405
$ echo "New shell does not have TEST_VAR: ${TEST_VAR}"
New shell does not have TEST_VAR:
$ exit
exit
$ echo "Back to current shell: $$"
Back to current shell: 403
$ . demo.sh
$ echo "New shell ID: $$"
New shell ID: 412
$ echo "New shell has TEST_VAR: ${TEST_VAR}"
New shell has TEST_VAR: one
"two'   three"
four