Merge branch 'ah/git-prompt-portability'

The command line prompt support used to be littered with bash-isms,
which has been corrected to work with more shells.

* ah/git-prompt-portability:
  git-prompt: support custom 0-width PS1 markers
  git-prompt: ta-da! document usage in other shells
  git-prompt: don't use shell $'...'
  git-prompt: add some missing quotes
  git-prompt: replace [[...]] with standard code
  git-prompt: don't use shell arrays
  git-prompt: fix uninitialized variable
  git-prompt: use here-doc instead of here-string
This commit is contained in:
Junio C Hamano
2024-08-28 10:31:28 -07:00

View File

@ -8,8 +8,8 @@
# To enable: # To enable:
# #
# 1) Copy this file to somewhere (e.g. ~/.git-prompt.sh). # 1) Copy this file to somewhere (e.g. ~/.git-prompt.sh).
# 2) Add the following line to your .bashrc/.zshrc: # 2) Add the following line to your .bashrc/.zshrc/.profile:
# source ~/.git-prompt.sh # . ~/.git-prompt.sh # dot path/to/this-file
# 3a) Change your PS1 to call __git_ps1 as # 3a) Change your PS1 to call __git_ps1 as
# command-substitution: # command-substitution:
# Bash: PS1='[\u@\h \W$(__git_ps1 " (%s)")]\$ ' # Bash: PS1='[\u@\h \W$(__git_ps1 " (%s)")]\$ '
@ -30,6 +30,8 @@
# Optionally, you can supply a third argument with a printf # Optionally, you can supply a third argument with a printf
# format string to finetune the output of the branch status # format string to finetune the output of the branch status
# #
# See notes below about compatibility with other shells.
#
# The repository status will be displayed only if you are currently in a # The repository status will be displayed only if you are currently in a
# git repository. The %s token is the placeholder for the shown status. # git repository. The %s token is the placeholder for the shown status.
# #
@ -106,38 +108,78 @@
# directory is set up to be ignored by git, then set # directory is set up to be ignored by git, then set
# GIT_PS1_HIDE_IF_PWD_IGNORED to a nonempty value. Override this on the # GIT_PS1_HIDE_IF_PWD_IGNORED to a nonempty value. Override this on the
# repository level by setting bash.hideIfPwdIgnored to "false". # repository level by setting bash.hideIfPwdIgnored to "false".
#
# Compatibility with other shells (beyond bash/zsh):
#
# We require posix-ish shell plus "local" support, which is most
# shells (even pdksh), but excluding ksh93 (because no "local").
#
# Prompt integration might differ between shells, but the gist is
# to load it once on shell init with '. path/to/git-prompt.sh',
# set GIT_PS1* vars once as needed, and either place $(__git_ps1..)
# inside PS1 once (0/1 args), or, before each prompt is displayed,
# call __git_ps1 (2/3 args) which sets PS1 with the status embedded.
#
# Many shells support the 1st method of command substitution,
# though some might need to first enable cmd substitution in PS1.
#
# When using colors, each escape sequence is wrapped between byte
# values 1 and 2 (control chars SOH, STX, respectively), which are
# invisible at the output, but for bash/readline they mark 0-width
# strings (SGR color sequences) when calculating the on-screen
# prompt width, to maintain correct input editing at the prompt.
#
# To replace or disable the 0-width markers, set GIT_PS1_COLOR_PRE
# and GIT_PS1_COLOR_POST to other markers, or empty (nul) to not
# use markers. For instance, some shells support '\[' and '\]' as
# start/end markers in PS1 - when invoking __git_ps1 with 3/4 args,
# but it may or may not work in command substitution mode. YMMV.
#
# If the shell doesn't support 0-width markers and editing behaves
# incorrectly when using colors in __git_ps1, then, other than
# disabling color, it might be solved using multi-line prompt,
# where the git status is not at the last line, e.g.:
# PS1='\n\w \u@\h$(__git_ps1 " (%s)")\n\$ '
# check whether printf supports -v # check whether printf supports -v
__git_printf_supports_v= __git_printf_supports_v=
printf -v __git_printf_supports_v -- '%s' yes >/dev/null 2>&1 printf -v __git_printf_supports_v -- '%s' yes >/dev/null 2>&1
# like __git_SOH=$'\001' etc but works also in shells without $'...'
eval "$(printf '
__git_SOH="\001" __git_STX="\002" __git_ESC="\033"
__git_LF="\n" __git_CRLF="\r\n"
')"
# stores the divergence from upstream in $p # stores the divergence from upstream in $p
# used by GIT_PS1_SHOWUPSTREAM # used by GIT_PS1_SHOWUPSTREAM
__git_ps1_show_upstream () __git_ps1_show_upstream ()
{ {
local key value local key value
local svn_remote svn_url_pattern count n local svn_remotes="" svn_url_pattern="" count n
local upstream_type=git legacy="" verbose="" name="" local upstream_type=git legacy="" verbose="" name=""
local LF="$__git_LF"
svn_remote=()
# get some config options from git-config # get some config options from git-config
local output="$(git config -z --get-regexp '^(svn-remote\..*\.url|bash\.showupstream)$' 2>/dev/null | tr '\0\n' '\n ')" local output="$(git config -z --get-regexp '^(svn-remote\..*\.url|bash\.showupstream)$' 2>/dev/null | tr '\0\n' '\n ')"
while read -r key value; do while read -r key value; do
case "$key" in case "$key" in
bash.showupstream) bash.showupstream)
GIT_PS1_SHOWUPSTREAM="$value" GIT_PS1_SHOWUPSTREAM="$value"
if [[ -z "${GIT_PS1_SHOWUPSTREAM}" ]]; then if [ -z "${GIT_PS1_SHOWUPSTREAM}" ]; then
p="" p=""
return return
fi fi
;; ;;
svn-remote.*.url) svn-remote.*.url)
svn_remote[$((${#svn_remote[@]} + 1))]="$value" svn_remotes=${svn_remotes}${value}${LF} # URI\nURI\n...
svn_url_pattern="$svn_url_pattern\\|$value" svn_url_pattern="$svn_url_pattern\\|$value"
upstream_type=svn+git # default upstream type is SVN if available, else git upstream_type=svn+git # default upstream type is SVN if available, else git
;; ;;
esac esac
done <<< "$output" done <<-OUTPUT
$output
OUTPUT
# parse configuration values # parse configuration values
local option local option
@ -154,33 +196,45 @@ __git_ps1_show_upstream ()
case "$upstream_type" in case "$upstream_type" in
git) upstream_type="@{upstream}" ;; git) upstream_type="@{upstream}" ;;
svn*) svn*)
# get the upstream from the "git-svn-id: ..." in a commit message # successful svn-upstream resolution:
# (git-svn uses essentially the same procedure internally) # - get the list of configured svn-remotes ($svn_remotes set above)
local -a svn_upstream # - get the last commit which seems from one of our svn-remotes
svn_upstream=($(git log --first-parent -1 \ # - confirm that it is from one of the svn-remotes
--grep="^git-svn-id: \(${svn_url_pattern#??}\)" 2>/dev/null)) # - use $GIT_SVN_ID if set, else "git-svn"
if [[ 0 -ne ${#svn_upstream[@]} ]]; then
svn_upstream=${svn_upstream[${#svn_upstream[@]} - 2]}
svn_upstream=${svn_upstream%@*}
local n_stop="${#svn_remote[@]}"
for ((n=1; n <= n_stop; n++)); do
svn_upstream=${svn_upstream#${svn_remote[$n]}}
done
if [[ -z "$svn_upstream" ]]; then # get upstream from "git-svn-id: UPSTRM@N HASH" in a commit message
# (git-svn uses essentially the same procedure internally)
local svn_upstream="$(
git log --first-parent -1 \
--grep="^git-svn-id: \(${svn_url_pattern#??}\)" 2>/dev/null
)"
if [ -n "$svn_upstream" ]; then
# extract the URI, assuming --grep matched the last line
svn_upstream=${svn_upstream##*$LF} # last line
svn_upstream=${svn_upstream#*: } # UPSTRM@N HASH
svn_upstream=${svn_upstream%@*} # UPSTRM
case ${LF}${svn_remotes} in
*"${LF}${svn_upstream}${LF}"*)
# grep indeed matched the last line - it's our remote
# default branch name for checkouts with no layout: # default branch name for checkouts with no layout:
upstream_type=${GIT_SVN_ID:-git-svn} upstream_type=${GIT_SVN_ID:-git-svn}
else ;;
*)
# the commit message includes one of our remotes, but
# it's not at the last line. is $svn_upstream junk?
upstream_type=${svn_upstream#/} upstream_type=${svn_upstream#/}
fi ;;
elif [[ "svn+git" = "$upstream_type" ]]; then esac
elif [ "svn+git" = "$upstream_type" ]; then
upstream_type="@{upstream}" upstream_type="@{upstream}"
fi fi
;; ;;
esac esac
# Find how many commits we are ahead/behind our upstream # Find how many commits we are ahead/behind our upstream
if [[ -z "$legacy" ]]; then if [ -z "$legacy" ]; then
count="$(git rev-list --count --left-right \ count="$(git rev-list --count --left-right \
"$upstream_type"...HEAD 2>/dev/null)" "$upstream_type"...HEAD 2>/dev/null)"
else else
@ -192,8 +246,8 @@ __git_ps1_show_upstream ()
for commit in $commits for commit in $commits
do do
case "$commit" in case "$commit" in
"<"*) ((behind++)) ;; "<"*) behind=$((behind+1)) ;;
*) ((ahead++)) ;; *) ahead=$((ahead+1)) ;;
esac esac
done done
count="$behind $ahead" count="$behind $ahead"
@ -203,7 +257,7 @@ __git_ps1_show_upstream ()
fi fi
# calculate the result # calculate the result
if [[ -z "$verbose" ]]; then if [ -z "$verbose" ]; then
case "$count" in case "$count" in
"") # no upstream "") # no upstream
p="" ;; p="" ;;
@ -229,10 +283,10 @@ __git_ps1_show_upstream ()
*) # diverged from upstream *) # diverged from upstream
upstream="|u+${count#* }-${count% *}" ;; upstream="|u+${count#* }-${count% *}" ;;
esac esac
if [[ -n "$count" && -n "$name" ]]; then if [ -n "$count" ] && [ -n "$name" ]; then
__git_ps1_upstream_name=$(git rev-parse \ __git_ps1_upstream_name=$(git rev-parse \
--abbrev-ref "$upstream_type" 2>/dev/null) --abbrev-ref "$upstream_type" 2>/dev/null)
if [ $pcmode = yes ] && [ $ps1_expanded = yes ]; then if [ "$pcmode" = yes ] && [ "$ps1_expanded" = yes ]; then
upstream="$upstream \${__git_ps1_upstream_name}" upstream="$upstream \${__git_ps1_upstream_name}"
else else
upstream="$upstream ${__git_ps1_upstream_name}" upstream="$upstream ${__git_ps1_upstream_name}"
@ -251,25 +305,29 @@ __git_ps1_show_upstream ()
# their own color. # their own color.
__git_ps1_colorize_gitstring () __git_ps1_colorize_gitstring ()
{ {
if [[ -n ${ZSH_VERSION-} ]]; then if [ -n "${ZSH_VERSION-}" ]; then
local c_red='%F{red}' local c_red='%F{red}'
local c_green='%F{green}' local c_green='%F{green}'
local c_lblue='%F{blue}' local c_lblue='%F{blue}'
local c_clear='%f' local c_clear='%f'
else else
# Using \001 and \002 around colors is necessary to prevent # \001 (SOH) and \002 (STX) are 0-width substring markers
# issues with command line editing/browsing/completion! # which bash/readline identify while calculating the prompt
local c_red=$'\001\e[31m\002' # on-screen width - to exclude 0-screen-width esc sequences.
local c_green=$'\001\e[32m\002' local c_pre="${GIT_PS1_COLOR_PRE-$__git_SOH}${__git_ESC}["
local c_lblue=$'\001\e[1;34m\002' local c_post="m${GIT_PS1_COLOR_POST-$__git_STX}"
local c_clear=$'\001\e[0m\002'
local c_red="${c_pre}31${c_post}"
local c_green="${c_pre}32${c_post}"
local c_lblue="${c_pre}1;34${c_post}"
local c_clear="${c_pre}0${c_post}"
fi fi
local bad_color=$c_red local bad_color="$c_red"
local ok_color=$c_green local ok_color="$c_green"
local flags_color="$c_lblue" local flags_color="$c_lblue"
local branch_color="" local branch_color=""
if [ $detached = no ]; then if [ "$detached" = no ]; then
branch_color="$ok_color" branch_color="$ok_color"
else else
branch_color="$bad_color" branch_color="$bad_color"
@ -298,7 +356,7 @@ __git_ps1_colorize_gitstring ()
# variable, in that order. # variable, in that order.
__git_eread () __git_eread ()
{ {
test -r "$1" && IFS=$'\r\n' read -r "$2" <"$1" test -r "$1" && IFS=$__git_CRLF read -r "$2" <"$1"
} }
# see if a cherry-pick or revert is in progress, if the user has committed a # see if a cherry-pick or revert is in progress, if the user has committed a
@ -346,7 +404,7 @@ __git_sequencer_status ()
__git_ps1 () __git_ps1 ()
{ {
# preserve exit status # preserve exit status
local exit=$? local exit="$?"
local pcmode=no local pcmode=no
local detached=no local detached=no
local ps1pc_start='\u@\h:\w ' local ps1pc_start='\u@\h:\w '
@ -365,7 +423,7 @@ __git_ps1 ()
;; ;;
0|1) printf_format="${1:-$printf_format}" 0|1) printf_format="${1:-$printf_format}"
;; ;;
*) return $exit *) return "$exit"
;; ;;
esac esac
@ -403,7 +461,7 @@ __git_ps1 ()
# incorrect.) # incorrect.)
# #
local ps1_expanded=yes local ps1_expanded=yes
[ -z "${ZSH_VERSION-}" ] || [[ -o PROMPT_SUBST ]] || ps1_expanded=no [ -z "${ZSH_VERSION-}" ] || eval '[[ -o PROMPT_SUBST ]]' || ps1_expanded=no
[ -z "${BASH_VERSION-}" ] || shopt -q promptvars || ps1_expanded=no [ -z "${BASH_VERSION-}" ] || shopt -q promptvars || ps1_expanded=no
local repo_info rev_parse_exit_code local repo_info rev_parse_exit_code
@ -413,29 +471,30 @@ __git_ps1 ()
rev_parse_exit_code="$?" rev_parse_exit_code="$?"
if [ -z "$repo_info" ]; then if [ -z "$repo_info" ]; then
return $exit return "$exit"
fi fi
local LF="$__git_LF"
local short_sha="" local short_sha=""
if [ "$rev_parse_exit_code" = "0" ]; then if [ "$rev_parse_exit_code" = "0" ]; then
short_sha="${repo_info##*$'\n'}" short_sha="${repo_info##*$LF}"
repo_info="${repo_info%$'\n'*}" repo_info="${repo_info%$LF*}"
fi fi
local ref_format="${repo_info##*$'\n'}" local ref_format="${repo_info##*$LF}"
repo_info="${repo_info%$'\n'*}" repo_info="${repo_info%$LF*}"
local inside_worktree="${repo_info##*$'\n'}" local inside_worktree="${repo_info##*$LF}"
repo_info="${repo_info%$'\n'*}" repo_info="${repo_info%$LF*}"
local bare_repo="${repo_info##*$'\n'}" local bare_repo="${repo_info##*$LF}"
repo_info="${repo_info%$'\n'*}" repo_info="${repo_info%$LF*}"
local inside_gitdir="${repo_info##*$'\n'}" local inside_gitdir="${repo_info##*$LF}"
local g="${repo_info%$'\n'*}" local g="${repo_info%$LF*}"
if [ "true" = "$inside_worktree" ] && if [ "true" = "$inside_worktree" ] &&
[ -n "${GIT_PS1_HIDE_IF_PWD_IGNORED-}" ] && [ -n "${GIT_PS1_HIDE_IF_PWD_IGNORED-}" ] &&
[ "$(git config --bool bash.hideIfPwdIgnored)" != "false" ] && [ "$(git config --bool bash.hideIfPwdIgnored)" != "false" ] &&
git check-ignore -q . git check-ignore -q .
then then
return $exit return "$exit"
fi fi
local sparse="" local sparse=""
@ -485,14 +544,16 @@ __git_ps1 ()
case "$ref_format" in case "$ref_format" in
files) files)
if ! __git_eread "$g/HEAD" head; then if ! __git_eread "$g/HEAD" head; then
return $exit return "$exit"
fi fi
if [[ $head == "ref: "* ]]; then case $head in
"ref: "*)
head="${head#ref: }" head="${head#ref: }"
else ;;
*)
head="" head=""
fi esac
;; ;;
*) *)
head="$(git symbolic-ref HEAD 2>/dev/null)" head="$(git symbolic-ref HEAD 2>/dev/null)"
@ -528,8 +589,8 @@ __git_ps1 ()
fi fi
local conflict="" # state indicator for unresolved conflicts local conflict="" # state indicator for unresolved conflicts
if [[ "${GIT_PS1_SHOWCONFLICTSTATE-}" == "yes" ]] && if [ "${GIT_PS1_SHOWCONFLICTSTATE-}" = "yes" ] &&
[[ $(git ls-files --unmerged 2>/dev/null) ]]; then [ "$(git ls-files --unmerged 2>/dev/null)" ]; then
conflict="|CONFLICT" conflict="|CONFLICT"
fi fi
@ -581,10 +642,10 @@ __git_ps1 ()
fi fi
fi fi
local z="${GIT_PS1_STATESEPARATOR-" "}" local z="${GIT_PS1_STATESEPARATOR- }"
b=${b##refs/heads/} b=${b##refs/heads/}
if [ $pcmode = yes ] && [ $ps1_expanded = yes ]; then if [ "$pcmode" = yes ] && [ "$ps1_expanded" = yes ]; then
__git_ps1_branch_name=$b __git_ps1_branch_name=$b
b="\${__git_ps1_branch_name}" b="\${__git_ps1_branch_name}"
fi fi
@ -596,7 +657,7 @@ __git_ps1 ()
local f="$h$w$i$s$u$p" local f="$h$w$i$s$u$p"
local gitstring="$c$b${f:+$z$f}${sparse}$r${upstream}${conflict}" local gitstring="$c$b${f:+$z$f}${sparse}$r${upstream}${conflict}"
if [ $pcmode = yes ]; then if [ "$pcmode" = yes ]; then
if [ "${__git_printf_supports_v-}" != yes ]; then if [ "${__git_printf_supports_v-}" != yes ]; then
gitstring=$(printf -- "$printf_format" "$gitstring") gitstring=$(printf -- "$printf_format" "$gitstring")
else else
@ -607,5 +668,5 @@ __git_ps1 ()
printf -- "$printf_format" "$gitstring" printf -- "$printf_format" "$gitstring"
fi fi
return $exit return "$exit"
} }