From afe1a1b610bb33666cdcb928dac528f9f2f0841a Mon Sep 17 00:00:00 2001 From: fatalerrors Date: Tue, 13 Oct 2026 14:35:00 +0200 Subject: [PATCH] prompt bug fix --- README.md | 5 + doc/CHANGELOG.md | 14 ++ doc/known-bugs.md | 2 +- profile.d/prompt.sh | 560 +++++++++++++++++++++++++------------------- profile.sh | 16 +- 5 files changed, 358 insertions(+), 239 deletions(-) diff --git a/README.md b/README.md index ebb314b..581364b 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,11 @@ PROMPT_COLOR_USER_FG = $ICyan PROMPT_COLOR_DIR_FG = $IYellow ``` +A value is one or more blocks, each a colour variable (`$ICyan`, `${ICyan}`) or a +raw ANSI literal (`\e[1;32m`, `\033[4m`), e.g. `$BWhite\e[4m`; anything else is +rejected with a warning (colours end up in `PS1`). These overrides always win over +the theme, also after `set_theme`. + The twelve available `PROMPT_COLOR_*` keys are: | Key | Role | diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 28191b8..c91695c 100755 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -119,6 +119,20 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). child, validates the PID and refuses the current shell; `ppn` matched PIDs and names such as `kde-bash` (now exact); `ppg`/`gpid` fallbacks no longer match their own `awk`; `PPU_DEFAULT_FORMAT` documented with its real value. +- `prompt.sh` (security): a git branch, conda environment or virtualenv named + like `x$(command)` was **executed** each time the prompt was drawn, because the + names were copied into `PS1`; the context segment is now referenced as a + variable and never re-expanded. +- `prompt.sh`: the exit status shown in the prompt was always `0` (the nested + function definitions at the top of `set_prompt` reset `$?`; they are now + defined once at load time); `PROMPT_COLOR_*` overrides of the `[prompt]` + section never beat the theme and `$ColorName` values were emptied by + `parse_conf` in a fresh login shell (now resolved, validated, and re-applied + after `set_theme`); `set_theme --preview` left the previewed colours behind + when none were set before, and a switch to a partial theme kept the previous + theme's colours; the prompt no longer forks `date` twice per command + (`$EPOCHREALTIME`) and runs one `git status` instead of three git commands, + without taking the index lock. - `compress.sh`: option flags and loop variables no longer leak into the interactive shell. diff --git a/doc/known-bugs.md b/doc/known-bugs.md index 0fcec82..cf37495 100755 --- a/doc/known-bugs.md +++ b/doc/known-bugs.md @@ -22,7 +22,7 @@ project and will not be addressed in Bash. stops when the prompt reappears; in Windows Terminal, timer events appear tied to prompt display rather than to the Enter keypress. - **Cause:** Execution time is measured via a `DEBUG` trap and `PROMPT_COMMAND` using - `date +%s%N` deltas. WSL + Windows Terminal introduces scheduling jitter between Bash signal + `$EPOCHREALTIME` (or `date +%s%N` on bash < 5) deltas. WSL + Windows Terminal introduces scheduling jitter between Bash signal events and the underlying Windows terminal layer that does not match wall-clock perception. - **Impact:** Cosmetic / observability only — commands execute normally. - **Status:** Not fixable in Bash; this is a limitation of the Windows Terminal / WSL diff --git a/profile.d/prompt.sh b/profile.d/prompt.sh index ff42e5d..8ecf619 100644 --- a/profile.d/prompt.sh +++ b/profile.d/prompt.sh @@ -34,19 +34,83 @@ # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ + load_conf "prompt" +# ------------------------------------------------------------------------------ +# Colour variable names defined by disp.sh. They are the only names a theme or +# the [prompt] configuration may reference (as $Name or ${Name}), and the only +# palette variables a theme may redefine. +_PROMPT_COLOR_NAMES=( + Black Red Green Yellow Blue Purple Cyan White + BBlack BRed BGreen BYellow BBlue BPurple BCyan BWhite + UBlack URed UGreen UYellow UBlue UPurple UCyan UWhite + On_Black On_Red On_Green On_Yellow On_Blue On_Purple On_Cyan On_White + IBlack IRed IGreen IYellow IBlue IPurple ICyan IWhite + BIBlack BIRed BIGreen BIYellow BIBlue BIPurple BICyan BIWhite + On_IBlack On_IRed On_IGreen On_IYellow On_IBlue On_IPurple On_ICyan On_IWhite + DEFAULTFG DEFAULTBG DEFAULTCOL RESETCOL +) +# The same names as an ERE alternation (Black|Red|...) +_PROMPT_COLOR_RE="" +for _pc_name in "${_PROMPT_COLOR_NAMES[@]}"; do + _PROMPT_COLOR_RE+="|${_pc_name}" +done +_PROMPT_COLOR_RE="${_PROMPT_COLOR_RE#|}" +unset _pc_name + +# ------------------------------------------------------------------------------ +# Check a colour value and put its resolved form in _PROMPT_RESOLVED. +# A value is a sequence of blocks, each one either +# $ColorName or ${ColorName} — colour variable from disp.sh (resolved by +# indirection, never evaluated) +# \e[...m or \033[...m — raw ANSI escape literal +# Anything else is refused (return 1): colours end up inside PS1, so they must +# not be able to carry anything else than colour codes. +# Usage: _prompt_resolve_color +_prompt_resolve_color() +{ + local rest="$1" out="" name + local ref_re='^\$(\{('"$_PROMPT_COLOR_RE"')\}|('"$_PROMPT_COLOR_RE"'))' + local ansi_re='^(\\e|\\033)\[[0-9;]*m' + + [[ -n $rest ]] || return 1 + while [[ -n $rest ]]; do + if [[ $rest =~ $ref_re ]]; then + name="${BASH_REMATCH[2]:-${BASH_REMATCH[3]}}" + out+="${!name:-}" + elif [[ $rest =~ $ansi_re ]]; then + out+="${BASH_REMATCH[0]}" + else + return 1 + fi + rest="${rest:${#BASH_REMATCH[0]}}" + done + _PROMPT_RESOLVED="$out" +} + +# ------------------------------------------------------------------------------ +# Path of the theme file for a bare name (searched in the theme directory) or an +# explicit path (anything containing a slash). +# Usage: _prompt_theme_file [theme_dir] +_prompt_theme_file() +{ + local name="$1" dir="${2:-${PROMPT_THEME_DIR:-$MYPATH/profile.d/themes}}" + if [[ "$name" == */* ]]; then + printf '%s' "$name" + else + printf '%s' "$dir/${name}.theme" + fi +} + # ------------------------------------------------------------------------------ # Parse a prompt theme file safely — it is NEVER sourced or executed. # Two categories of keys are accepted: # PROMPT_COLOR_* — prompt slot colours (TIME_FG, BAR_BG, …) # Standard colour variables from disp.sh (Blue, On_IBlack, …) — allows a # theme to redefine the palette used everywhere in the shell session. -# Allowed value forms: -# $ColorName or ${ColorName} — colour variable from disp.sh (resolved by -# indirection via ${!varname}) -# \e[...m or \033[...m — raw ANSI escape literal (single block) -# Any other key or value is rejected with a warning. +# Values follow the forms accepted by _prompt_resolve_color. Any other key or +# value is rejected with a warning. # Usage: load_theme [theme_dir] # theme_name_or_path : bare name (e.g. "dark") or an explicit path. # theme_dir : directory to search for bare names; defaults to @@ -55,23 +119,18 @@ load_conf "prompt" load_theme() { local theme_name="$1" - local theme_dir="${2:-${PROMPT_THEME_DIR:-$MYPATH/profile.d/themes}}" local theme_file="" [[ -z "$theme_name" ]] && return 0 - if [[ "$theme_name" == /* || "$theme_name" == */* ]]; then - theme_file="$theme_name" - else - theme_file="$theme_dir/${theme_name}.theme" - fi + theme_file=$(_prompt_theme_file "$theme_name" "${2:-}") if [[ ! -f "$theme_file" || ! -r "$theme_file" ]]; then printf "[ Warning ] load_theme: theme file not found: %s\n" "$theme_file" >&2 return 1 fi - # ---- Key whitelist: prompt slots ---------------------------------------- + # ---- Key whitelist: prompt slots and standard colour variables ---------- local -A _lth_allowed=( [PROMPT_COLOR_TIME_FG]=1 [PROMPT_COLOR_TIME_BG]=1 [PROMPT_COLOR_BAR_BG]=1 @@ -81,41 +140,13 @@ load_theme() [PROMPT_COLOR_DIR_FG]=1 [PROMPT_COLOR_CTX_FG]=1 ) - - # ---- Colour variable names exported by disp.sh -------------------------- - local _lth_color_re - _lth_color_re='Black|Red|Green|Yellow|Blue|Purple|Cyan|White' - _lth_color_re+='|BBlack|BRed|BGreen|BYellow|BBlue|BPurple|BCyan|BWhite' - _lth_color_re+='|UBlack|URed|UGreen|UYellow|UBlue|UPurple|UCyan|UWhite' - _lth_color_re+='|On_Black|On_Red|On_Green|On_Yellow|On_Blue|On_Purple|On_Cyan|On_White' - _lth_color_re+='|IBlack|IRed|IGreen|IYellow|IBlue|IPurple|ICyan|IWhite' - _lth_color_re+='|BIBlack|BIRed|BIGreen|BIYellow|BIBlue|BIPurple|BICyan|BIWhite' - _lth_color_re+='|On_IBlack|On_IRed|On_IGreen|On_IYellow|On_IBlue|On_IPurple|On_ICyan|On_IWhite' - _lth_color_re+='|DEFAULTFG|DEFAULTBG|DEFAULTCOL|RESETCOL' - - # ---- Key whitelist: standard colour vars (same list as above) ----------- local _lth_cn - for _lth_cn in \ - Black Red Green Yellow Blue Purple Cyan White \ - BBlack BRed BGreen BYellow BBlue BPurple BCyan BWhite \ - UBlack URed UGreen UYellow UBlue UPurple UCyan UWhite \ - On_Black On_Red On_Green On_Yellow On_Blue On_Purple On_Cyan On_White \ - IBlack IRed IGreen IYellow IBlue IPurple ICyan IWhite \ - BIBlack BIRed BIGreen BIYellow BIBlue BIPurple BICyan BIWhite \ - On_IBlack On_IRed On_IGreen On_IYellow On_IBlue On_IPurple On_ICyan On_IWhite \ - DEFAULTFG DEFAULTBG DEFAULTCOL RESETCOL; do + for _lth_cn in "${_PROMPT_COLOR_NAMES[@]}"; do _lth_allowed[$_lth_cn]=1 done - unset _lth_cn - - # ERE: safe colour reference $Name or ${Name} - local _lth_ref_re='^\$\{?('"$_lth_color_re"')\}?$' - - # ERE: raw ANSI escape literal \e[...m or \033[...m - local _lth_ansi_re='^(\\e|\\033)\[[0-9;]*m$' # ---- Line parser --------------------------------------------------------- - local _lth_line _lth_key _lth_value _lth_varname _lth_lineno=0 + local _lth_line _lth_key _lth_value _lth_lineno=0 while IFS= read -r _lth_line || [[ -n "$_lth_line" ]]; do _lth_lineno=$(( _lth_lineno + 1 )) @@ -151,25 +182,68 @@ load_theme() _lth_value="${_lth_value%%\'*}" fi - if [[ "$_lth_value" =~ $_lth_ref_re ]]; then - # Safe colour variable reference — resolve via indirection - _lth_varname="${_lth_value#\$}" - _lth_varname="${_lth_varname#\{}" - _lth_varname="${_lth_varname%\}}" - export "$_lth_key"="${!_lth_varname}" - elif [[ "$_lth_value" =~ $_lth_ansi_re ]]; then - # Raw ANSI escape literal — accept as-is - export "$_lth_key"="$_lth_value" + if _prompt_resolve_color "$_lth_value"; then + export "$_lth_key"="$_PROMPT_RESOLVED" else printf "[ Warning ] load_theme: %s:%d: invalid value for '%s', ignoring.\n" \ "$theme_file" "$_lth_lineno" "$_lth_key" >&2 fi done < "$theme_file" } -# Not exported, it remains private +# Not exported, it remains private (like the other _prompt_* helpers: set_theme +# and set_prompt are meant to be used from the interactive shell itself). # ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +# Apply the PROMPT_COLOR_* keys of the [prompt] section on top of the current +# colours, so they win over the theme. Their values may use $ColorName, which +# parse_conf leaves untouched while the colour variables do not exist yet. +# A value that is not a valid colour is reported and not applied. +# Usage: _prompt_apply_overrides +_prompt_apply_overrides() +{ + declare -p CONF_prompt >/dev/null 2>&1 || return 0 + + local k + for k in "${!CONF_prompt[@]}"; do + [[ $k == PROMPT_COLOR_* ]] || continue + if _prompt_resolve_color "${CONF_prompt[$k]}"; then + export "$k=$_PROMPT_RESOLVED" + else + printf "[ Warning ] [prompt] %s: invalid colour value '%s', ignoring.\n" \ + "$k" "${CONF_prompt[$k]}" >&2 + # load_conf exported the raw text: do not keep it as a colour + [[ "${!k:-}" == "${CONF_prompt[$k]}" ]] && unset "$k" + fi + done + return 0 +} + +# ------------------------------------------------------------------------------ +# Switch to a theme from a clean state: default palette, no PROMPT_COLOR_* +# left over from the previous theme (a partial theme must not inherit them), +# the theme, then the [prompt] overrides. The theme file is checked first, so a +# wrong name changes nothing. +# Usage: _prompt_load_theme_clean [theme_dir] +_prompt_load_theme_clean() +{ + local file v + file=$(_prompt_theme_file "$1" "${2:-}") + if [[ ! -f "$file" || ! -r "$file" ]]; then + printf "[ Warning ] set_theme: theme file not found: %s\n" "$file" >&2 + return 1 + fi + + set_colors + for v in ${!PROMPT_COLOR_@}; do + unset "$v" + done + load_theme "$1" "${2:-}" || return 1 + _prompt_apply_overrides +} +# ------------------------------------------------------------------------------ + # ------------------------------------------------------------------------------ # Dynamically switch the prompt theme for the current shell session. @@ -204,7 +278,7 @@ set_theme() printf "\tset_theme \t— list available themes\n" printf "\tset_theme dark \t— apply the dark theme\n" printf "\tset_theme -p dark \t— preview the dark theme\n" - printf "\tset_theme -S \t— save current theme in config\n" + printf "\tset_theme -S \t— save the current theme to the configuration\n" printf "\tset_theme -S dark \t— apply and save the dark theme\n" printf "\tset_theme ~/my.theme\t— apply a theme by path\n" return 0 @@ -288,57 +362,52 @@ set_theme() # -- preview mode -------------------------------------------------------- if (( preview )); then - local old_theme="${PROMPT_THEME:-}" - local -a old_prompt_color_vars=() + # Snapshot the palette and the prompt colours by value, to put back + # exactly what was there (including the PROMPT_COLOR_* that did not + # exist, which a previewed theme would otherwise leave behind). + local -A _snap=() local _v - for _v in ${!PROMPT_COLOR_@}; do - old_prompt_color_vars+=("$_v=${!_v}") + for _v in "${_PROMPT_COLOR_NAMES[@]}" ${!PROMPT_COLOR_@}; do + [[ -n "${!_v+x}" ]] && _snap[$_v]="${!_v}" done - set_colors - load_theme "$theme_name" || { - set_colors - if [[ -n "$old_theme" ]]; then - load_theme "$old_theme" || true - export PROMPT_THEME="$old_theme" - fi - return 1 - } + local _rc=0 + _prompt_load_theme_clean "$theme_name" "$theme_dir" || _rc=1 - printf "Preview for theme '%s':\n" "$theme_name" - printf "%b [ 13:37:00 ] %b%b [ 0 %b• (5s) %b] %b user@host%b [ git:main +1/-0 ] %b\n" \ - "${PROMPT_COLOR_TIME_FG:-}${PROMPT_COLOR_TIME_BG:-}" "${DEFAULTCOL:-}" \ - "${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_BAR_BG:-}" \ - "${PROMPT_COLOR_OK_MARK:-}${PROMPT_COLOR_BAR_BG:-}" \ - "${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_BAR_BG:-}" \ - "${PROMPT_COLOR_USER_FG:-}${PROMPT_COLOR_BAR_BG:-}" \ - "${PROMPT_COLOR_CTX_FG:-}${PROMPT_COLOR_BAR_BG:-}" "${DEFAULTCOL:-}" - printf "%b [ 13:37:00 ] %b%b [ 0 %b• (5s) %b] %b user@host%b [ git:main +1/-0 ] %b\n" \ - "${PROMPT_COLOR_TIME_FG:-}${PROMPT_COLOR_TIME_BG:-}" "${DEFAULTCOL:-}" \ - "${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_ERR_BG:-}" \ - "${PROMPT_COLOR_ERR_MARK:-}${PROMPT_COLOR_ERR_BG:-}" \ - "${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_ERR_BG:-}" \ - "${PROMPT_COLOR_USER_FG:-}${PROMPT_COLOR_BAR_BG:-}" \ - "${PROMPT_COLOR_CTX_FG:-}${PROMPT_COLOR_BAR_BG:-}" "${DEFAULTCOL:-}" - printf "%b/path/to/dir \$ %b\n" \ - "${PROMPT_COLOR_DIR_FG:-}" "${DEFAULTCOL:-}" - - set_colors - if [[ -n "$old_theme" ]]; then - load_theme "$old_theme" || true - export PROMPT_THEME="$old_theme" + if (( _rc == 0 )); then + printf "Preview for theme '%s':\n" "$theme_name" + printf "%b [ 13:37:00 ] %b%b [ 0 %b• (5s) %b] %b user@host%b [ git:main +1/-0 ] %b\n" \ + "${PROMPT_COLOR_TIME_FG:-}${PROMPT_COLOR_TIME_BG:-}" "${DEFAULTCOL:-}" \ + "${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_BAR_BG:-}" \ + "${PROMPT_COLOR_OK_MARK:-}${PROMPT_COLOR_BAR_BG:-}" \ + "${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_BAR_BG:-}" \ + "${PROMPT_COLOR_USER_FG:-}${PROMPT_COLOR_BAR_BG:-}" \ + "${PROMPT_COLOR_CTX_FG:-}${PROMPT_COLOR_BAR_BG:-}" "${DEFAULTCOL:-}" + printf "%b [ 13:37:00 ] %b%b [ 0 %b• (5s) %b] %b user@host%b [ git:main +1/-0 ] %b\n" \ + "${PROMPT_COLOR_TIME_FG:-}${PROMPT_COLOR_TIME_BG:-}" "${DEFAULTCOL:-}" \ + "${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_ERR_BG:-}" \ + "${PROMPT_COLOR_ERR_MARK:-}${PROMPT_COLOR_ERR_BG:-}" \ + "${PROMPT_COLOR_OK_FG:-}${PROMPT_COLOR_ERR_BG:-}" \ + "${PROMPT_COLOR_USER_FG:-}${PROMPT_COLOR_BAR_BG:-}" \ + "${PROMPT_COLOR_CTX_FG:-}${PROMPT_COLOR_BAR_BG:-}" "${DEFAULTCOL:-}" + printf "%b/path/to/dir \$ %b\n" \ + "${PROMPT_COLOR_DIR_FG:-}" "${DEFAULTCOL:-}" fi - for _v in "${old_prompt_color_vars[@]}"; do - export "${_v%%=*}=${_v#*=}" + + # Restore: every colour variable is either put back or removed + for _v in "${_PROMPT_COLOR_NAMES[@]}" ${!PROMPT_COLOR_@}; do + if [[ -n "${_snap[$_v]+x}" ]]; then + export "$_v=${_snap[$_v]}" + else + unset "$_v" + fi done - return 0 + return "$_rc" fi # -- apply mode ---------------------------------------------------------- - # Reset colours to defaults before loading the new theme - set_colors - load_theme "$theme_name" || return 1 + _prompt_load_theme_clean "$theme_name" "$theme_dir" || return 1 export PROMPT_THEME="$theme_name" if (( save )); then @@ -354,30 +423,50 @@ export -f set_theme # ------------------------------------------------------------------------------ -# timer_* functions : internal timing function for prompt -# Usage: timer_now -# This function returns the current time in nanoseconds since the epoch. It -# first tries to use the %N format specifier for nanoseconds, but if that is -# not supported (e.g., on older systems), it falls back to seconds. -function timer_now +# timer_* functions : internal timing functions for the prompt. +# The start is recorded by the DEBUG trap (profile.sh) before each command and +# the elapsed time is computed by set_prompt, through PROMPT_COMMAND. + +# Put the current time, in microseconds since the epoch, in _timer_us. +# EPOCHREALTIME (bash >= 5) costs no process; its decimal separator follows the +# locale ("." or ","), hence the removal of both. Otherwise "date +%s%N" is +# used (it prints a literal "N" where nanoseconds are not supported, which the +# digit check rejects), then plain seconds. +_timer_now() { - date +%s%N 2>/dev/null || date +%s + if [[ -n "${EPOCHREALTIME:-}" ]]; then + _timer_us="${EPOCHREALTIME//[.,]/}" + else + local t + t=$(date +%s%N 2>/dev/null) + if [[ $t =~ ^[0-9]{16,}$ ]]; then + _timer_us=$(( t / 1000 )) + else + _timer_us=$(( $(date +%s) * 1000000 )) + fi + fi } # Usage: timer_start -# This function initializes the timer_start variable with the current time in -# nanoseconds. It is used to measure the elapsed time for the prompt. -function timer_start +# Record the start time of the command about to run, unless already recorded +# (the DEBUG trap fires for every simple command of a pipeline or function: only +# the first one counts). +timer_start() { - timer_start=${timer_start:-$(timer_now)} + if [[ -z "${_timer_start_us:-}" ]]; then + _timer_now + _timer_start_us="$_timer_us" + fi } # Usage: timer_stop -# This function calculates the elapsed time since timer_start and formats it -# into a human-readable string with appropriate units (us, ms, s, m, h -function timer_stop +# Compute the time elapsed since timer_start and format it into timer_show with +# an appropriate unit (us, ms, s, m, h). +timer_stop() { - local delta_us=$((($(timer_now) - timer_start) / 1000)) + _timer_now + local delta_us=$(( _timer_us - ${_timer_start_us:-$_timer_us} )) + (( delta_us < 0 )) && delta_us=0 local us=$((delta_us % 1000)) local ms=$(((delta_us / 1000) % 1000)) local s=$(((delta_us / 1000000) % 60)) @@ -391,7 +480,7 @@ function timer_stop elif ((s >= 10)); then timer_show=${s}.$((ms / 100))s elif ((s > 0)); then - timer_show=${s}.$(printf %03d $ms)s + printf -v timer_show '%d.%03ds' "$s" "$ms" elif ((ms >= 100)); then timer_show=${ms}ms elif ((ms > 0)); then @@ -399,7 +488,108 @@ function timer_stop else timer_show=${us}us fi - unset timer_start + unset _timer_start_us +} + +# ------------------------------------------------------------------------------ +# Prompt segments. Each one puts its text in _PROMPT_SEG (empty when there is +# nothing to show), without a command substitution (no subshell per segment). +# They are defined once, at load time, and not inside set_prompt: a function +# definition resets $?, and set_prompt must read the exit status of the user's +# last command before anything else runs. + +_prompt_seg_git() +{ + _PROMPT_SEG="" + # Fast path: skip git lookup when feature is disabled. + [[ "${PROMPT_SHOW_GIT:-1}" == "0" ]] && return 0 + + local branch + branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || \ + branch=$(git rev-parse --short HEAD 2>/dev/null) || return 0 + + local dirty="" sync="" timed_out=0 + + if [[ "${PROMPT_SHOW_GIT_STATUS:-1}" != "0" ]]; then + # Build a timeout wrapper if the 'timeout' command is available; + # fall back to direct execution on systems that lack it. + local -a _tw=() + command -v timeout >/dev/null 2>&1 && _tw=(timeout "${PROMPT_GIT_TIMEOUT:-2}") + + # One call for everything: --branch gives the "# branch.ab +A -B" line + # (only when an upstream exists) and each changed tracked file gives a + # line starting with 1, 2 or u. Untracked files are not counted. + # GIT_OPTIONAL_LOCKS=0 keeps git from taking the index lock to refresh + # it, which would make a concurrent "git commit" fail. + local out ec + out=$(GIT_OPTIONAL_LOCKS=0 "${_tw[@]}" git status --porcelain=v2 --branch \ + --untracked-files=no --ignore-submodules 2>/dev/null) + ec=$? + if [[ $ec -eq 124 ]]; then + timed_out=1 + else + [[ $'\n'"$out" == *$'\n'[12u]\ * ]] && dirty="*" + if [[ "$out" =~ \#\ branch\.ab\ \+([0-9]+)\ -([0-9]+) ]]; then + if [[ "${BASH_REMATCH[1]}" -gt 0 || "${BASH_REMATCH[2]}" -gt 0 ]]; then + sync=" +${BASH_REMATCH[1]}/-${BASH_REMATCH[2]}" + fi + fi + fi + fi + + if [[ $timed_out -eq 1 ]]; then + _PROMPT_SEG="git:${branch}?" + else + _PROMPT_SEG="git:${branch}${dirty}${sync}" + fi +} + +_prompt_seg_conda() +{ + _PROMPT_SEG="" + [[ "${PROMPT_SHOW_CONDA:-1}" == "0" ]] && return 0 + [[ -z "${CONDA_DEFAULT_ENV:-}" ]] && return 0 + _PROMPT_SEG="conda:${CONDA_DEFAULT_ENV}" +} + +_prompt_seg_venv() +{ + _PROMPT_SEG="" + [[ "${PROMPT_SHOW_VENV:-1}" == "0" ]] && return 0 + [[ -n "${CONDA_DEFAULT_ENV:-}" ]] && return 0 + [[ -z "${VIRTUAL_ENV:-}" ]] && return 0 + _PROMPT_SEG="venv:${VIRTUAL_ENV##*/}" +} + +_prompt_seg_session() +{ + _PROMPT_SEG="" + [[ "${PROMPT_SHOW_SESSION:-1}" == "0" ]] && return 0 + + local -a tags=() + [[ -n "${SSH_CONNECTION:-}" ]] && tags+=("ssh") + [[ -n "${TMUX:-}" ]] && tags+=("tmux") + [[ -n "${STY:-}" ]] && tags+=("screen") + [[ ${#tags[@]} -eq 0 ]] && return 0 + + local out="${tags[0]}" i + for ((i=1; i<${#tags[@]}; ++i)); do + out+="+${tags[i]}" + done + _PROMPT_SEG="$out" +} + +# Append the current _PROMPT_SEG to the context text _PROMPT_CTX (" | " between +# segments). An "if" keeps the status at 0 when the segment is empty. +_prompt_ctx_add() +{ + if [[ -n "$_PROMPT_SEG" ]]; then + if [[ -n "$_PROMPT_CTX" ]]; then + _PROMPT_CTX+=" | $_PROMPT_SEG" + else + _PROMPT_CTX="$_PROMPT_SEG" + fi + fi } # ------------------------------------------------------------------------------ @@ -410,107 +600,17 @@ function timer_stop # command, the elapsed time of the last command, and the current user and host. set_prompt() { - local _prompt_git_segment - _prompt_git_segment() - { - # Fast path: skip git lookup when feature is disabled. - [[ "${PROMPT_SHOW_GIT:-1}" == "0" ]] && return 0 - - local branch - branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || \ - branch=$(git rev-parse --short HEAD 2>/dev/null) || return 0 - - local dirty="" sync="" timed_out=0 - local _git_timeout="${PROMPT_GIT_TIMEOUT:-2}" - - # Build a timeout wrapper if the 'timeout' command is available; - # fall back to direct execution on systems that lack it. - local -a _tw=() - command -v timeout >/dev/null 2>&1 && _tw=(timeout "$_git_timeout") - - if [[ "${PROMPT_SHOW_GIT_STATUS:-1}" != "0" ]]; then - local _ec - - # Dirty check — working tree - "${_tw[@]}" git diff --no-ext-diff --quiet --ignore-submodules -- 2>/dev/null - _ec=$? - if [[ $_ec -eq 124 ]]; then - timed_out=1 - elif [[ $_ec -ne 0 ]]; then - dirty="*" - else - # Dirty check — index - "${_tw[@]}" git diff --cached --no-ext-diff --quiet --ignore-submodules -- 2>/dev/null - _ec=$? - if [[ $_ec -eq 124 ]]; then - timed_out=1 - elif [[ $_ec -ne 0 ]]; then - dirty="*" - fi - fi - - if [[ $timed_out -eq 0 ]]; then - local counts ahead behind - counts=$("${_tw[@]}" git rev-list --left-right --count "@{upstream}...HEAD" 2>/dev/null) - _ec=$? - if [[ $_ec -eq 124 ]]; then - timed_out=1 - elif [[ "$counts" =~ ^([0-9]+)[[:space:]]+([0-9]+)$ ]]; then - behind="${BASH_REMATCH[1]}" - ahead="${BASH_REMATCH[2]}" - if [[ "$ahead" -gt 0 || "$behind" -gt 0 ]]; then - sync=" +${ahead}/-${behind}" - fi - fi - fi - fi - - if [[ $timed_out -eq 1 ]]; then - printf "%s" "git:${branch}?" - else - printf "%s" "git:${branch}${dirty}${sync}" - fi - } - - local _prompt_conda_env - _prompt_conda_env() - { - [[ "${PROMPT_SHOW_CONDA:-1}" == "0" ]] && return 0 - [[ -z "${CONDA_DEFAULT_ENV:-}" ]] && return 0 - printf "%s" "conda:${CONDA_DEFAULT_ENV}" - } - - local _prompt_venv_env - _prompt_venv_env() - { - [[ "${PROMPT_SHOW_VENV:-1}" == "0" ]] && return 0 - [[ -n "${CONDA_DEFAULT_ENV:-}" ]] && return 0 - [[ -z "${VIRTUAL_ENV:-}" ]] && return 0 - printf "%s" "venv:${VIRTUAL_ENV##*/}" - } - - local _prompt_session_markers - _prompt_session_markers() - { - [[ "${PROMPT_SHOW_SESSION:-1}" == "0" ]] && return 0 - - local -a tags=() - [[ -n "${SSH_CONNECTION:-}" ]] && tags+=("ssh") - [[ -n "${TMUX:-}" ]] && tags+=("tmux") - [[ -n "${STY:-}" ]] && tags+=("screen") - [[ ${#tags[@]} -eq 0 ]] && return 0 - - local out="${tags[0]}" i - for ((i=1; i<${#tags[@]}; ++i)); do - out+="+${tags[i]}" - done - printf "%s" "$out" - } - - local Last_Command=$? # Must come first! + local Last_Command=$? # Must come first: nothing may run before this line local FancyX='\342\234\227' local Checkmark='\342\234\223' + # The context segment is not copied into PS1. Bash expands PS1 when + # displaying it, including $(...) and `...`: a git branch (or conda + # environment) named 'x$(command)' would be executed. PS1 only contains the + # reference ${_PROMPT_CTX}, whose value is inserted as plain text, never + # re-scanned. This needs promptvars (the default). + shopt -q promptvars || shopt -s promptvars + # Resolve theme/config colours with hardcoded fallbacks local _time_fg="${PROMPT_COLOR_TIME_FG:-$Blue}" local _time_bg="${PROMPT_COLOR_TIME_BG:-$On_White}" @@ -533,20 +633,18 @@ set_prompt() # Add exit status of the last command. # If it was successful, print a green check mark. Otherwise, print a red X. + timer_stop if [[ $Last_Command == 0 ]]; then PS1+="\[${_ok_fg}${_bar_bg}\] [ $Last_Command " PS1+="\[${_ok_mark}${_bar_bg}\]${Checkmark} " # Add the elapsed time, then close the status section and return to bar bg. - timer_stop PS1+="($timer_show)\[${_ok_fg}${_bar_bg}\] ] " else PS1+="\[${_err_fg}${_err_bg}\] [ $Last_Command " PS1+="\[${_err_mark}${_err_bg}\]${FancyX} " - timer_stop PS1+="($timer_show)\[${_err_fg}${_err_bg}\] ] " fi - # If root, print the host in root colour. Otherwise use user colour. if [[ $EUID -eq 0 ]]; then PS1+="\[${_root_fg}${_bar_bg}\] \\u\[${_user_fg}${_bar_bg}\]@\\h" @@ -555,26 +653,14 @@ set_prompt() fi # Optional context segment appended at the end of the top bar. - local _git_seg _conda_env _venv_env _session_tags _ctx="" - _git_seg="$(_prompt_git_segment)" - _conda_env="$(_prompt_conda_env)" - _venv_env="$(_prompt_venv_env)" - _session_tags="$(_prompt_session_markers)" - - local _ctx_disp - _ctx_disp() - { - [[ -n "$1" ]] && { - [[ -n "$_ctx" ]] && _ctx+=" | $1" || _ctx="$1" - } - } - - _ctx_disp "$_git_seg" - _ctx_disp "$_conda_env" - _ctx_disp "$_venv_env" - _ctx_disp "$_session_tags" - if [[ -n "$_ctx" ]]; then - PS1+="\[${_ctx_fg}${_bar_bg}\] [ ${_ctx} ]" + _PROMPT_CTX="" + _prompt_seg_git; _prompt_ctx_add + _prompt_seg_conda; _prompt_ctx_add + _prompt_seg_venv; _prompt_ctx_add + _prompt_seg_session; _prompt_ctx_add + if [[ -n "$_PROMPT_CTX" ]]; then + # Single quotes: the reference must reach PS1 literally (see above) + PS1+="\[${_ctx_fg}${_bar_bg}\] [ "'${_PROMPT_CTX}'" ]" fi PS1+="\[\e[K\e[u\]\[$RESETCOL\]\n" @@ -591,15 +677,17 @@ set_prompt() # 2. Theme file (PROMPT_THEME key from [prompt] section) # 3. Individual PROMPT_COLOR_* overrides in [prompt] section # +# load_conf "prompt" (top of this file) has exported every key of the section, +# the theme file is loaded over them, and _prompt_apply_overrides then puts the +# PROMPT_COLOR_* keys of the section back on top (resolving $ColorName values). # CONF_prompt is already populated by parse_conf (run in profile.sh before -# modules are sourced). We extract PROMPT_THEME and PROMPT_THEME_DIR from the -# raw associative array now so load_theme can run before load_conf "prompt" -# exports remaining keys. That way any PROMPT_COLOR_* value set explicitly in -# [prompt] wins over the same variable set by the theme file. +# modules are sourced), which is where PROMPT_THEME and PROMPT_THEME_DIR are +# read from. _pt_theme="${CONF_prompt[PROMPT_THEME]:-}" _pt_dir="${CONF_prompt[PROMPT_THEME_DIR]:-}" [[ -n "$_pt_theme" ]] && load_theme "$_pt_theme" ${_pt_dir:+"$_pt_dir"} unset _pt_theme _pt_dir +_prompt_apply_overrides # ------------------------------------------------------------------------------ diff --git a/profile.sh b/profile.sh index efd52c2..d5395ed 100644 --- a/profile.sh +++ b/profile.sh @@ -278,10 +278,22 @@ parse_conf() value="${value//\`/}" value="${value//\$\(/}" - # Correctly interpretet internal variables (e.g. $HOME) + # Correctly interpretet internal variables (e.g. $HOME). In the + # [prompt] section only the variables that exist are substituted: + # envsubst would otherwise replace an unknown one by nothing, + # wiping out the $Blue-style colour references that are resolved + # later, when the colour variables exist (see prompt.sh). if [[ "$value" == *\$* ]]; then if command -v envsubst >/dev/null 2>&1; then - value=$(envsubst <<< "$value") + if [[ "$current_section" == "prompt" ]]; then + local _envnames="" _envname + for _envname in $(compgen -e); do + [[ "$_envname" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] && _envnames+="\${${_envname}}" + done + value=$(envsubst "$_envnames" <<< "$value") + else + value=$(envsubst <<< "$value") + fi fi # If envsubst is unavailable, $VAR references are left as-is. fi