git-status-prompt.sh 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. #!/bin/bash
  2. # git-status-prompt.sh - pretty format git sync and dirty status for shell prompt
  3. # Copyright 2013-2020, 2022 bill-auger <http://github.com/bill-auger/git-status-prompt/issues>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU Affero General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU Affero General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU Affero General Public License
  16. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  17. # FORMAT:
  18. # (branch-name status-indicators [divergence]) last-commit-date last-commit-message
  19. # where:
  20. # '*' character indicates that the working tree differs from HEAD (per .gitignore)
  21. # '!' character indicates that some tracked files have changed
  22. # '?' character indicates that some new or untracked files exist
  23. # '+' character indicates that some changes are staged for commit
  24. # '$' character indicates that a stash exists
  25. # [n<-->n] indicates the number of commits behind and ahead of upstream
  26. #
  27. # USAGE:
  28. # source /path/to/git-status-prompt/git-status-prompt.sh
  29. # PS1="\$(GitStatusPrompt)"
  30. # this script can be sluggish in very large repos
  31. CFG_IGNORED_DIRS=( $(grep -v ^# "$(dirname ${BASH_SOURCE})/ignore_dirs" 2> /dev/null) )
  32. readonly RED='\033[1;31m'
  33. readonly YELLOW='\033[01;33m'
  34. readonly GREEN='\033[00;32m'
  35. readonly LIME='\033[01;32m'
  36. readonly PURPLE='\033[00;35m'
  37. readonly BLUE='\033[00;34m'
  38. readonly AQUA='\033[00;36m'
  39. readonly CYAN='\033[01;36m'
  40. readonly CEND='\033[00m'
  41. readonly DIRTY_CHAR="*"
  42. readonly TRACKED_CHAR="!"
  43. readonly UNTRACKED_CHAR="?"
  44. readonly STAGED_CHAR="+"
  45. readonly STASHED_CHAR="$"
  46. readonly GIT_CLEAN_MSG_REGEX="nothing to commit,? (?working directory clean)?"
  47. readonly ROOT_COLOR=${RED}
  48. readonly USER_COLOR=${PURPLE}
  49. readonly PWD_COLOR=${AQUA}
  50. readonly CLEAN_COLOR=${GREEN}
  51. readonly DIRTY_COLOR=${YELLOW}
  52. readonly UNO_COLOR=${LIME}
  53. readonly TRACKED_COLOR=${YELLOW}
  54. readonly UNTRACKED_COLOR=${RED}
  55. readonly STAGED_COLOR=${GREEN}
  56. readonly STASHED_COLOR=${LIME}
  57. readonly BEHIND_COLOR=${RED}
  58. readonly AHEAD_COLOR=${YELLOW}
  59. readonly EVEN_COLOR=${GREEN}
  60. readonly DATE_COLOR=${BLUE}
  61. readonly LOGIN=$(whoami)
  62. readonly ANSI_FILTER_REGEX="s|\\\033\[([0-9]{1,2}(;[0-9]{1,2})?)?m||g"
  63. readonly TIMESTAMP_LEN=10
  64. ## debugging ##
  65. Dbg() { (>&2 echo -e "[GitStatusPrompt]: $@") ; }
  66. DbgTruncateToWidth()
  67. {
  68. Dbg "(login_host_len=${#1}) + (current_dir_len=${#2}) + (status_msg_len=${#3}) = (prompt_len=$prompt_len)"
  69. Dbg "(current_tty_w=$current_tty_w) - (prompt_mod=$prompt_mod) = (truncate_len=$truncate_len)"
  70. Dbg "(min_len=$min_len) < (truncate_len=$truncate_len) < (max_len=$max_len)"
  71. Dbg 123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_....
  72. }
  73. DbgGitStatusAssertions()
  74. {
  75. Dbg "AssertIsValidRepo=$( AssertIsValidRepo && echo 'true' || echo 'false - bailing')"
  76. Dbg "AssertIsNotBareRepo=$( AssertIsNotBareRepo && echo 'true' || echo 'false - bailing')"
  77. Dbg "AssertHasCommits=$( AssertHasCommits && echo 'true' || echo 'false - bailing')"
  78. Dbg "AssertIsNotIgnoredDir=$(AssertIsNotIgnoredDir && echo 'true' || echo 'false' )"
  79. }
  80. DbgGitStatusState()
  81. {
  82. Dbg "current_branch=${current_branch}"
  83. Dbg "git_dir=${git_dir}"
  84. Dbg "is_valid_git_dir=$( [[ -n "${git_dir}" ]] && echo 'true' || echo 'false - bailing')"
  85. Dbg "is_valid_current_branch=$([[ -n "${current_branch}" ]] && echo 'true' || echo 'false - bailing')"
  86. Dbg "is_unsafe=$( [[ -n "${unsafe_msg}" ]] && echo 'true' || echo 'false' )"
  87. Dbg "is_detached=$( [[ -n "${detached_msg}" ]] && echo 'true' || echo 'false' )"
  88. Dbg "is_local_branch=$( IsLocalBranch ${current_branch} && echo 'true' || echo 'false - bailing')"
  89. }
  90. DbgGitStatusChars()
  91. {
  92. Dbg "tracked=$tracked untracked=$untracked staged=$staged stashed=$stashed\n"
  93. }
  94. DbgGitStatusPrompt()
  95. {
  96. Dbg "login_host=${login_host} pwd_path=${pwd_path} git_status=${git_status} prompt_tail=${prompt_tail}"
  97. }
  98. DbgSourced() { Dbg "sourced" ; }
  99. ## helpers ##
  100. AssertIsValidRepo()
  101. {
  102. [[ "$(git rev-parse --is-inside-work-tree 2> /dev/null)" == 'true' ]] || \
  103. [[ "$(git rev-parse --is-bare-repository 2> /dev/null)" == 'true' ]]
  104. }
  105. AssertIsNotBareRepo()
  106. {
  107. [[ "$(git rev-parse --is-bare-repository 2> /dev/null)" != 'true' ]]
  108. }
  109. AssertHasCommits()
  110. {
  111. # TODO: does this fail if detached HEAD ?
  112. [[ -n "$(git cat-file -t HEAD 2> /dev/null)" ]]
  113. }
  114. AssertIsNotIgnoredDir()
  115. {
  116. local ignored_dir
  117. for ignored_dir in ${CFG_IGNORED_DIRS[@]} ${GSP_IGNORED_DIRS[@]}
  118. do [[ "$(pwd)/" =~ ^${ignored_dir} ]] && return 1
  119. done
  120. return 0
  121. }
  122. GitDir() { echo "$(git rev-parse --show-toplevel 2> /dev/null)/.git" ; }
  123. CurrentBranch() { git rev-parse --abbrev-ref HEAD 2> /dev/null ; }
  124. UnsafeMsg()
  125. {
  126. local git_status="$(git status 2>&1 1>/dev/null | sed '/^$/d')"
  127. local my_advice="\nor, simply white-list them all:\n\tgit config --global --add safe.directory *"
  128. [[ "${git_status}" =~ ^'fatal: unsafe repository ' ]] && echo -e "${git_status}${my_advice}"
  129. }
  130. DetachedMsg() # (git_dir current_branch)
  131. {
  132. local git_dir=$1
  133. local current_branch=$2
  134. [[ -n "${git_dir}" && -n "${current_branch}" ]] || return
  135. if [[ -f "${git_dir}/MERGE_HEAD" && ! -z "$(cat ${git_dir}/MERGE_MSG | grep -E '^Merge')" ]]
  136. then local merge_msg=$(cat ${git_dir}/MERGE_MSG | grep -E "^Merge (.*)(branch|tag|commit) '" | \
  137. sed -e "s/^Merge \(.*\)\(branch\|tag\|commit\) '\(.*\)' \(of .* \)\?\(into .*\)\?$/\1\2 \3 \4\5/")
  138. echo "${UNTRACKED_COLOR}$(TruncateToWidth "" "(merging ${merge_msg})")${CEND}"
  139. elif [[ -d "${git_dir}/rebase-apply/" || -d "${git_dir}/rebase-merge/" ]]
  140. then local rebase_dir=$( ls -d ${git_dir}/rebase-* | sed -e "s|^\$\(git_dir\)/rebase-\(.*\)$|\$\(git_dir\)/rebase-\1|")
  141. local this_branch=$( cat ${rebase_dir}/head-name | sed -e "s|^refs/heads/\(.*\)$|\1|" )
  142. local their_commit=$(cat ${rebase_dir}/onto )
  143. local at_commit=$( git log -n1 --oneline $(cat ${rebase_dir}/stopped-sha 2> /dev/null))
  144. local msg="(rebasing ${this_branch} onto ${their_commit::7} - at ${at_commit})"
  145. echo "${UNTRACKED_COLOR}$(TruncateToWidth "" "${msg}" )${CEND}"
  146. elif [[ "${current_branch}" == "HEAD" ]]
  147. then echo "${UNTRACKED_COLOR}$(TruncateToWidth "" "(detached)")${CEND}"
  148. fi
  149. }
  150. IsLocalBranch() # (branch_name)
  151. {
  152. local branch=$1
  153. [[ -n "$(git branch -a 2> /dev/null | grep -E "^.* $branch$")" ]]
  154. }
  155. HasAnyChanges()
  156. {
  157. ! [[ "$(git status 2> /dev/null | tail -n1 | grep -E "${GIT_CLEAN_MSG_REGEX}")" ]]
  158. }
  159. HasTrackedChanges()
  160. {
  161. ! git diff --no-ext-diff --quiet --exit-code
  162. }
  163. HasUntrackedChanges()
  164. {
  165. [[ -n "$(git ls-files --others --exclude-standard 2> /dev/null)" ]]
  166. }
  167. HasStagedChanges()
  168. {
  169. ! git diff-index --cached --quiet HEAD --
  170. }
  171. HasStashedChanges()
  172. {
  173. git rev-parse --verify refs/stash > /dev/null 2>&1
  174. }
  175. AnyChanges() { HasAnyChanges && echo -n "${DIRTY_CHAR}" ; }
  176. TrackedChanges() { HasTrackedChanges && echo -n "${TRACKED_CHAR}" ; }
  177. UntrackedChanges() { HasUntrackedChanges && echo -n "${UNTRACKED_CHAR}" ; }
  178. StagedChanges() { HasStagedChanges && echo -n "${STAGED_CHAR}" ; }
  179. StashedChanges() { HasStashedChanges && echo -n "${STASHED_CHAR}" ; }
  180. SyncStatus() # (local_branch remote_branch status)
  181. {
  182. local local_branch=$1
  183. local remote_branch=$2
  184. local status=$(git rev-list --left-right ${local_branch}...${remote_branch} -- 2>/dev/null)
  185. [[ $(( $? )) -eq 0 ]] && echo ${status}
  186. }
  187. LoginColor() { (( ${EUID} )) && echo ${USER_COLOR} || echo ${ROOT_COLOR} ; }
  188. LoginHost()
  189. {
  190. [[ -z "${STY}" ]] && echo "${USER}@${HOSTNAME}${CEND}:" || \
  191. echo "[${USER}@${HOSTNAME}]${CEND}:" # GNU screen
  192. }
  193. CurrentDir() { local pwd="${PWD}/" ; echo "${pwd/\/\//\/}" ; }
  194. TruncateToWidth() # (fixed_len_prefix truncate_msg)
  195. {
  196. local fixed_len_prefix=$( [[ "$1" ]] && echo "$1 " )
  197. local truncate_msg=$2
  198. # trunctuate to console width
  199. local login_host=$( echo $(LoginHost) | sed -r ${ANSI_FILTER_REGEX} --)
  200. local current_dir=$(CurrentDir )
  201. local status_msg=$( echo "${fixed_len_prefix}" | sed -r ${ANSI_FILTER_REGEX} --)
  202. local current_tty_w=$(( $(stty -F /dev/tty size | cut -d ' ' -f2) ))
  203. local prompt_len=$(( ${#login_host} + ${#current_dir} + ${#status_msg} ))
  204. local prompt_mod=$(( ${prompt_len} % ${current_tty_w} ))
  205. local truncate_len=$(( ${current_tty_w} - ${prompt_mod} ))
  206. local min_len=${TIMESTAMP_LEN}
  207. local max_len=${current_tty_w}
  208. [[ ${truncate_len} -lt ${min_len} || ${truncate_len} -gt ${max_len} ]] && truncate_len=0
  209. # DbgTruncateToWidth
  210. echo "${truncate_msg:0:truncate_len}"
  211. }
  212. ## business ##
  213. GitStatus()
  214. {
  215. # DbgGitStatusAssertions
  216. # get current state
  217. local unsafe_msg="$( UnsafeMsg )"
  218. local git_dir="$( GitDir )"
  219. local current_branch=$(CurrentBranch )
  220. local detached_msg="$( DetachedMsg "${git_dir}" "${current_branch}")"
  221. # DbgGitStatusState
  222. # validate current state
  223. [[ -z "${unsafe_msg}" ]] || ! echo "${unsafe_msg}" >&2 || return
  224. [[ -n "${git_dir}" && -n "${current_branch}" ]] || return
  225. [[ -z "${detached_msg}" ]] || ! echo "${detached_msg}" || return
  226. IsLocalBranch ${current_branch} || return
  227. # ensure we are in a valid, non-bare git repository, with commits, and not blacklisted
  228. AssertIsValidRepo || return
  229. AssertIsNotBareRepo || ! echo "$(TruncateToWidth "" "(bare repo)" )" || return
  230. AssertHasCommits || ! echo "$(TruncateToWidth "" "(no commits)" )" || return
  231. AssertIsNotIgnoredDir || ! echo "$(TruncateToWidth "" "(${current_branch})")" || return
  232. # get remote tracking branch
  233. local local_branch
  234. local remote_branch
  235. local should_count_divergences
  236. while read local_branch remote_branch
  237. do [[ "${current_branch}" == "${local_branch}" ]] && \
  238. should_count_divergences=$([[ -n "${remote_branch}" ]] && echo 1 || echo 0) && \
  239. break
  240. done < <(git for-each-ref --format="%(refname:short) %(upstream:short)" refs/heads)
  241. # set branch color based on dirty status
  242. local branch_color=$( ( HasTrackedChanges && echo -n ${DIRTY_COLOR} ) ||
  243. ( HasAnyChanges && echo -n ${UNO_COLOR} ) ||
  244. echo -n ${CLEAN_COLOR} )
  245. # get sync status
  246. if (( ${should_count_divergences} ))
  247. then local status=$( SyncStatus ${current_branch} ${remote_branch} )
  248. local n_behind=$( echo "${status}" | tr " " "\n" | grep -c '^>' )
  249. local n_ahead=$( echo "${status}" | tr " " "\n" | grep -c '^<' )
  250. local behind_color=$( [[ "${n_behind}" -ne 0 ]] && echo ${BEHIND_COLOR} || echo ${EVEN_COLOR} )
  251. local ahead_color=$( [[ "${n_ahead}" -ne 0 ]] && echo ${AHEAD_COLOR} || echo ${EVEN_COLOR} )
  252. fi
  253. # get tracked status
  254. local tracked=$(TrackedChanges)
  255. # get untracked status
  256. local untracked=$(UntrackedChanges)
  257. # get staged status
  258. local staged=$(StagedChanges)
  259. # get stashed status
  260. local stashed=$(StashedChanges)
  261. # DbgGitStatusChars
  262. # build output
  263. local open_paren="${branch_color}(${CEND}"
  264. local close_paren="${branch_color})${CEND}"
  265. local open_bracket="${branch_color}[${CEND}"
  266. local close_bracket="${branch_color}]${CEND}"
  267. local tracked_msg=${TRACKED_COLOR}${tracked}${CEND}
  268. local untracked_msg=${UNTRACKED_COLOR}${untracked}${CEND}
  269. local staged_msg=${STAGED_COLOR}${staged}${CEND}
  270. local stashed_msg=${STASHED_COLOR}${stashed}${CEND}
  271. local branch_msg=${branch_color}${current_branch}${CEND}
  272. local status_msg=${stashed_msg}${untracked_msg}${tracked_msg}${staged_msg}
  273. if (( ${should_count_divergences} ))
  274. then local behind_msg="${behind_color}${n_behind}<-${CEND}"
  275. local ahead_msg="${ahead_color}->${n_ahead}${CEND}"
  276. local upstream_msg="${open_bracket}${behind_msg}${ahead_msg}${close_bracket}"
  277. fi
  278. local branch_status_msg="${open_paren}${branch_msg}${status_msg}${upstream_msg}${close_paren}"
  279. # append last commit message
  280. local author_date=$(git log --max-count=1 --format=format:"%ai" 2> /dev/null )
  281. local commit_log=$( git log --max-count=1 --format=format:\"%s\" | sed -r "s|\"||g")
  282. [[ -n "${commit_log}" ]] || commit_log='<EMPTY>'
  283. local commit_msg="${author_date:0:TIMESTAMP_LEN} ${commit_log}"
  284. commit_msg="$(TruncateToWidth "${branch_status_msg}" "${commit_msg}")"
  285. commit_msg="${DATE_COLOR}${commit_msg:0:TIMESTAMP_LEN}${CEND} ${commit_msg:(TIMESTAMP_LEN + 1)}"
  286. echo "${branch_status_msg} ${commit_msg}"
  287. }
  288. ## main entry ##
  289. GitStatusPrompt()
  290. {
  291. local login_host="$(LoginColor)$(LoginHost)${CEND}"
  292. local pwd_path="${PWD_COLOR}$(CurrentDir)${CEND}"
  293. local git_status="$(GitStatus)"
  294. local prompt_tail='\n$ '
  295. # DbgGitStatusPrompt
  296. echo -e "${login_host}${pwd_path}${git_status}${prompt_tail}"
  297. }
  298. # DbgSourced