509,99
Рейтинг
Mail.ru Group
Строим Интернет
1 июня 2012

Yet another cool story about bash prompt

Блог компании Mail.ru Group
Я программист. По крайней мере так написано в трудовой книжке. Почти всё своё рабочее время я провожу в консоли и текстовом редакторе. Мне очень нравится bash. Почти год я жил в zsh, прислушавшись к советам своих многочисленных коллег и знакомых, но в итоге я вернулся в bash и ни капельки об этом не жалею.



Zsh красив, приятен, чертовски функционален, но, признаюсь честно, я не смог совладать со всеми его многочисленными настройками. Я хочу работать, а не бороться со своим рабочим окружением. Простой пример: пару раз из-за автодополнения zsh я удалял все директории и файлы в текущей директории — zsh просто ставил пробел между автодополненной директорией и введённой мною звёзочкой (я хотел удалить всё в выбранной папке). Помните тот эпичный баг с пробелом и удалении директории /usr? У меня было то же самое. Спасибо гиту, выручил в который раз.

Впрочем, дело не в zsh — будь я чуточку умнее, я бы с ним обязательно справился бы, и всё было бы хорошо, но мы, суровые программисты, будем использовать bash и vim, а гламурные zsh и textmate оставим хипстерам и прочим модникам ;)

Я не напишу ничего оригинального и универсального решения я не приведу, но мне всегда нравилось читать конфиги и описания других людей, а если к ним были приложены интересные картинки, так я вообще перечитывал эти статьи несколько раз. Надеюсь, вам тоже будет интересно.

Если вдруг что-то из написанного мною можно решить проще, или в баше уже есть описанный функционал — напишите в комментариях. Ну и на всякий случай, моя где я живу:
GNU bash, version 4.2.28(2)-release (i386-apple-darwin11.3.0)



Добавляем перевод строки перед приглашением



Итак, первое, с чем я сталкиваюсь каждый день и что мне не нравится в баше — команды, которые не завершают свой вывод переводом строки при завершении. Вот простой пример (эмуляция подобного поведения):

Конечно, ничего страшного не произошло, но тот же zsh корректно обрабатывает эту ситуацию, научим же и баш такому трюку.

Для этого нам нужно при каждом выводе приглашения командной строки (PS1) смотреть на позицию курсора, и если курсор находится не на первом символе в строке — выводить перевод строки (символ "\n"). Позицию курсора можно определить с помощью escape-последоваельности:
echo -en "\033[6n" && read -sdR CURPOS

В результате в переменной CURPOS будет находиться что-то вроде этого: "^[[4;12R", где 4 — номер строки, а 12 — номер символа в строке. Добавляем соответствующий код в наш конфиг баша (~/.bashrc или ~/.bash_profile):
# setup color variables
color_is_on=
color_red=
color_green=
color_yellow=
color_blue=
color_white=
color_gray=
color_bg_red=
color_off=
if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then
	color_is_on=true
	color_red="\[$(/usr/bin/tput setaf 1)\]"
	color_green="\[$(/usr/bin/tput setaf 2)\]"
	color_yellow="\[$(/usr/bin/tput setaf 3)\]"
	color_blue="\[$(/usr/bin/tput setaf 6)\]"
	color_white="\[$(/usr/bin/tput setaf 7)\]"
	color_gray="\[$(/usr/bin/tput setaf 8)\]"
	color_off="\[$(/usr/bin/tput sgr0)\]"
	color_error="$(/usr/bin/tput setab 1)$(/usr/bin/tput setaf 7)"
	color_error_off="$(/usr/bin/tput sgr0)"
fi

function prompt_command {
	# get cursor position and add new line if we're not in first column
	exec < /dev/tty
	local OLDSTTY=$(stty -g)
	stty raw -echo min 0
	echo -en "\033[6n" > /dev/tty && read -sdR CURPOS
	stty $OLDSTTY
	[[ ${CURPOS##*;} -gt 1 ]] && echo "${color_error}↵${color_error_off}"
}
PROMPT_COMMAND=prompt_command

СМ. Update
PROMPT_COMMAND — это функция, которая вызывается при каждой отрисовке приглашения командной строки. Здесь был использован небольшой хак, подсмотренный мною в комментарии на stackoverflow, без этого хака значение переменной $CURPOS в некоторых случаях выводилось на экран. На кучу цветов не обращайте внимание — ниже они все нам пригодятся. Результат работы нашего конфига:

Красный фон был добавлен специально, чтобы отличать этот символ от того, что может вывести команда. И, да, на дворе 21 век, поэтому мы используем utf-ную локаль. В случае с устаревшими локалями. символ "↵", скорее всего, придётся заменить на что-нибудь попроще, например, символ "%", как в zsh.

Выводим состояние git-репозитория



При работе с гитом из консоли (только не нужно говорить про гуй — мы же суровые разработчики старой закалки!) удобно видеть текущую ветку гита и общее состояние репозитория — есть ли изменённые файлы, или всё закоммичено. Уже на этом этапе я пришёл к выводу, что мне будет удобней работать с приглашением командной строки, состоящим из двух строк — в первой строке выводится информация о текущем окружении (пользователь, сервер, рабочая директория, информация о репозитории и вообще всё, что мы пожелаем), а во второй строке — непосредственно команда, которую мы вводим. Первое время было непривычно, сейчас же я не готов возвращаться к прежней схеме.Для того, чтобы добавить информацию о гите, мы можем воспользоваться специально обученной функцией "__git_ps1", которая появляется вместе с bash-completion для гита:

или же написать свой «костыль». Я пошёл по второму пути, т.к. функция __git_ps1 меня не удовлетворила. Во-первых, мне хотелось видеть не только название ветки, но и состояние репозитория, ещё и подсвечивая это состояние разными цветами. Во-вторых, для синхронизации своих конфигов между разными машинами/серверами я использую гит-репозиторий, и состояние этого репозитория мне хочется видеть только в домашней директории, но не во всех вложенных папках независимо от их глубины.

Собственно, функция, вычитывающая состояние гита выглядит следующим образом:
# get git status
function parse_git_status {
	# clear git variables
	GIT_BRANCH=
	GIT_DIRTY=

	# exit if no git found in system
	local GIT_BIN=$(which git 2>/dev/null)
	[[ -z $GIT_BIN ]] && return

	# check we are in git repo
	local CUR_DIR=$PWD
	while [ ! -d ${CUR_DIR}/.git ] && [ ! $CUR_DIR = "/" ]; do CUR_DIR=${CUR_DIR%/*}; done
	[[ ! -d ${CUR_DIR}/.git ]] && return

	# 'git repo for dotfiles' fix: show git status only in home dir and other git repos
	[[ $CUR_DIR == $HOME ]] && [[ $PWD != $HOME ]] && return

	# get git branch
	GIT_BRANCH=$($GIT_BIN symbolic-ref HEAD 2>/dev/null)
	[[ -z $GIT_BRANCH ]] && return
	GIT_BRANCH=${GIT_BRANCH#refs/heads/}

	# get git status
	local GIT_STATUS=$($GIT_BIN status --porcelain 2>/dev/null)
	[[ -n $GIT_STATUS ]] && GIT_DIRTY=true
}

Раньше я ещё парсил и отдельно выводил изменённые (modified) файлы, файлы, находящиеся в индексе для коммита (cached), и файлы, не принадлежащие репозиторию (untracked), но со временем я понял, что это лишняя информация для меня. Собственно, функция простая: смотрим, что гит вообще стоит в системе, проверяем, что мы находимся в гитовом репозитории, рекурсивно обходя все директории наверх до корня файловой системы и ища папку ".git", получаем название текущей ветки и смотрим, есть ли хоть какие-нибудь незакоммиченные файлы. Добавляем вызов этой функции в нашу prompt_command и строим приглашение:
function prompt_command {
	local PS1_GIT=
	local PWDNAME=$PWD

	...

	# beautify working firectory name
	if [ $HOME == $PWD ]; then
		PWDNAME="~"
	elif [ $HOME ==  ${PWD:0:${#HOME}} ]; then
		PWDNAME="~${PWD:${#HOME}}"
	fi

	# parse git status and get git variables
	parse_git_status

	# build b/w prompt for git
	[[ ! -z $GIT_BRANCH ]] && PS1_GIT=" (git: ${GIT_BRANCH})"

	local color_user=
	if $color_is_on; then
		# set user color
		case `id -u` in
			0) color_user=$color_red ;;
			*) color_user=$color_green ;;
		esac

		# build git status for prompt
		if [ ! -z $GIT_BRANCH ]; then
			if [ -z $GIT_DIRTY ]; then
				PS1_GIT=" (git: ${color_green}${GIT_BRANCH}${color_off})"
			else
				PS1_GIT=" (git: ${color_red}${GIT_BRANCH}${color_off})"
			fi
		fi
	fi

	# set new color prompt
	PS1="${color_user}${USER}${color_off}@${color_yellow}${HOSTNAME}${color_off}:${color_white}${PWDNAME}${color_off}${PS1_GIT}\n➜ "
}

Вот как это выглядит в итоге:

Пара слов про переменную PWDNAME. Да, я знаю, что можно написать "\w" и всё будет так же, но у меня в функции «prompt_command» ещё и выставляется заголовок терминала:
# set title
echo -ne "\033]0;${USER}@${HOSTNAME}:${PWDNAME}"; echo -ne "\007"

а вот там уже "\w" не работает.

Показываем название виртуального окружения python



Последнее время основным языком, на котором я пишу, является Python. Для него есть очень удобная штука под названием virtualenv. Не буду вдаваться в подробности — это тема отдельной статьи, но видеть текущее виртуальное окружение в консоли баша тоже крайне удобно.

На самом деле скрипт virtualenv добавляет название текущего venv в приглаашение баша, но уж очень некрасиво это выглядит:

Запрещаем vertualenv'у вмешиваться в наше приглашение командной строки:
export VIRTUAL_ENV_DISABLE_PROMPT=1

И выводим название venv сами, так, как нам нравится:
function prompt_command {
	local PS1_VENV=

	...

	[[ ! -z $VIRTUAL_ENV ]] && PS1_VENV=" (venv: ${VIRTUAL_ENV#$WORKON_HOME})"

	if $color_is_on; then
		...

		# build python venv status for prompt
		[[ ! -z $VIRTUAL_ENV ]] && PS1_VENV=" (venv: ${color_blue}${VIRTUAL_ENV#$WORKON_HOME}${color_off})"
	fi

	# set new color prompt
	PS1="${color_user}${USER}${color_off}@${color_yellow}${HOSTNAME}${color_off}:${color_white}\w${color_off}${PS1_GIT}${PS1_VENV}\n➜ "
}


Собственно, тут всё достаточно банально.

Отделяем визуально команды друг от друга



При достаточно активной работе с терминалом вводимые команды и результат их выполнения сливаются друг с другом, особенно когда в выводе присутствует цвет:

Конечно, пример не совсем показателен, но все, кто работал в консоли, понимают, о чём я.

Я решил выводить горизонтальную черту в каждом приглашении на всю ширину терминала и эта идея себя оправдала — пользоваться консолью стало гораздо удобней:

Вот как я это делаю:
function prompt_command {
	...

	# build b/w prompt for git and vertial env
	[[ ! -z $GIT_BRANCH ]] && PS1_GIT=" (git: ${GIT_BRANCH})"
	[[ ! -z $VIRTUAL_ENV ]] && PS1_VENV=" (venv: ${VIRTUAL_ENV#$WORKON_HOME})"

	# calculate fillsize
	local fillsize=$(($COLUMNS-$(printf "${USER}@${HOSTNAME}:${PWDNAME}${PS1_GIT}${PS1_VENV} " | wc -c | tr -d " ")))

	local FILL=$color_gray
	while [ $fillsize -gt 0 ]; do FILL="${FILL}─"; fillsize=$(($fillsize-1)); done
	FILL="${FILL}${color_off}"

	...

	# set new color prompt
	PS1="${color_user}${USER}${color_off}@${color_yellow}${HOSTNAME}${color_off}:${color_white}${PWDNAME}${color_off}${PS1_GIT}${PS1_VENV} ${FILL}\n➜ "
}

Сначала находим длину строки приглашения без цвета, вычитаем её из ширины терминала (переменная $COLUMNS) и делаем строку такой длины, состоящую из символа ASCII-графики "─" (опять же, если локаль не юникодная — можно использовать любой другой символ).

Больше цветов


Если терминал поддерживает 256 цветов, не обязательно ограничиваться стандартными:
color_pink="\[$(/usr/bin/tput setaf 99)\]"


Но для совместимости с устаревшими терминалами лучше этого избегать.

Послесловие или «Cool story, bro!»



Рабочее окружение должно быть удобным, неважно, чем ты пользуешься. Bash может быть уютненьким — нужно только постараться. Кстати, ещё одной причиной, почему я не использую zsh — его отсутствие на некоторых серверах, на которые я хожу и невозможность его туда поставить.

Очень хочется услышать комментарии, а так же примеры настройки вашего терминала, несмотря на то, что подобной информации в интернете — вагон, и каждый, кто открывает для себя различные шеллы в первую (ну ладно, во вторую) очередь лезет настраивать себе приглашение командной строки. И, да, ШГ, знаю ;) Терминуса под мак нормального я так и не нашёл =( Печаль..

Для экономии места я не привожу весь свой конфиг целиком — желащие могут посмотреть на него тут: github.com/dreadatour/dotfiles (файл .bash_profile).

UPD: Вот этот код:
function prompt_command {
	# get cursor position and add new line if we're not in first column
	exec < /dev/tty
	local OLDSTTY=$(stty -g)
	stty raw -echo min 0
	echo -en "\033[6n" > /dev/tty && read -sdR CURPOS
	stty $OLDSTTY
	[[ ${CURPOS##*;} -gt 1 ]] && echo "${color_error}↵${color_error_off}"
}
PROMPT_COMMAND=prompt_command

ломает ssh. Вернулся на старый вариант, без хака, ищу, в чём проблема:
function prompt_command {
	# get cursor position and add new line if we're not in first column
	echo -en "\033[6n" && read -sdR CURPOS
	[[ ${CURPOS##*;} -gt 1 ]] && echo "${color_error}↵${color_error_off}"
}
PROMPT_COMMAND=prompt_command


UPD2: Спасибо всем за замечания, — исправил баги, улучшил код. Теперь функция prompt_command выглядит так:
Скрытый текст
# setup color variables
color_is_on=
color_red=
color_green=
color_yellow=
color_blue=
color_white=
color_gray=
color_bg_red=
color_off=
color_user=
if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then
	color_is_on=true
	color_red="\[$(/usr/bin/tput setaf 1)\]"
	color_green="\[$(/usr/bin/tput setaf 2)\]"
	color_yellow="\[$(/usr/bin/tput setaf 3)\]"
	color_blue="\[$(/usr/bin/tput setaf 6)\]"
	color_white="\[$(/usr/bin/tput setaf 7)\]"
	color_gray="\[$(/usr/bin/tput setaf 8)\]"
	color_off="\[$(/usr/bin/tput sgr0)\]"

	color_error="$(/usr/bin/tput setab 1)$(/usr/bin/tput setaf 7)"
	color_error_off="$(/usr/bin/tput sgr0)"

	# set user color
	case `id -u` in
		0) color_user=$color_red ;;
		*) color_user=$color_green ;;
	esac
fi

# some kind of optimization - check if git installed only on config load
PS1_GIT_BIN=$(which git 2>/dev/null)

function prompt_command {
	local PS1_GIT=
	local PS1_VENV=
	local GIT_BRANCH=
	local GIT_DIRTY=
	local PWDNAME=$PWD

	# beautify working directory name
	if [[ "${HOME}" == "${PWD}" ]]; then
		PWDNAME="~"
	elif [[ "${HOME}" == "${PWD:0:${#HOME}}" ]]; then
		PWDNAME="~${PWD:${#HOME}}"
	fi

	# parse git status and get git variables
	if [[ ! -z $PS1_GIT_BIN ]]; then
		# check we are in git repo
		local CUR_DIR=$PWD
		while [[ ! -d "${CUR_DIR}/.git" ]] && [[ ! "${CUR_DIR}" == "/" ]] && [[ ! "${CUR_DIR}" == "~" ]] && [[ ! "${CUR_DIR}" == "" ]]; do CUR_DIR=${CUR_DIR%/*}; done
		if [[ -d "${CUR_DIR}/.git" ]]; then
			# 'git repo for dotfiles' fix: show git status only in home dir and other git repos
			if [[ "${CUR_DIR}" != "${HOME}" ]] || [[ "${PWD}" == "${HOME}" ]]; then
				# get git branch
				GIT_BRANCH=$($PS1_GIT_BIN symbolic-ref HEAD 2>/dev/null)
				if [[ ! -z $GIT_BRANCH ]]; then
					GIT_BRANCH=${GIT_BRANCH#refs/heads/}

					# get git status
					local GIT_STATUS=$($PS1_GIT_BIN status --porcelain 2>/dev/null)
					[[ -n $GIT_STATUS ]] && GIT_DIRTY=1
				fi
			fi
		fi
	fi

	# build b/w prompt for git and virtual env
	[[ ! -z $GIT_BRANCH ]] && PS1_GIT=" (git: ${GIT_BRANCH})"
	[[ ! -z $VIRTUAL_ENV ]] && PS1_VENV=" (venv: ${VIRTUAL_ENV#$WORKON_HOME})"

	# calculate prompt length
	local PS1_length=$((${#USER}+${#HOSTNAME}+${#PWDNAME}+${#PS1_GIT}+${#PS1_VENV}+3))
	local FILL=

	# if length is greater, than terminal width
	if [[ $PS1_length -gt $COLUMNS ]]; then
		# strip working directory name
		PWDNAME="...${PWDNAME:$(($PS1_length-$COLUMNS+3))}"
	else
		# else calculate fillsize
		local fillsize=$(($COLUMNS-$PS1_length))
		FILL=$color_gray
		while [[ $fillsize -gt 0 ]]; do FILL="${FILL}─"; fillsize=$(($fillsize-1)); done
		FILL="${FILL}${color_off}"
	fi

	if $color_is_on; then
		# build git status for prompt
		if [[ ! -z $GIT_BRANCH ]]; then
			if [[ -z $GIT_DIRTY ]]; then
				PS1_GIT=" (git: ${color_green}${GIT_BRANCH}${color_off})"
			else
				PS1_GIT=" (git: ${color_red}${GIT_BRANCH}${color_off})"
			fi
		fi

		# build python venv status for prompt
		[[ ! -z $VIRTUAL_ENV ]] && PS1_VENV=" (venv: ${color_blue}${VIRTUAL_ENV#$WORKON_HOME}${color_off})"
	fi

	# set new color prompt
	PS1="${color_user}${USER}${color_off}@${color_yellow}${HOSTNAME}${color_off}:${color_white}${PWDNAME}${color_off}${PS1_GIT}${PS1_VENV} ${FILL}\n➜ "

	# get cursor position and add new line if we're not in first column
	# cool'n'dirty trick (http://stackoverflow.com/a/2575525/1164595)
	# XXX FIXME: this hack broke ssh =(
#	exec < /dev/tty
#	local OLDSTTY=$(stty -g)
#	stty raw -echo min 0
#	echo -en "\033[6n" > /dev/tty && read -sdR CURPOS
#	stty $OLDSTTY
	echo -en "\033[6n" && read -sdR CURPOS
	[[ ${CURPOS##*;} -gt 1 ]] && echo "${color_error}↵${color_error_off}"

	# set title
	echo -ne "\033]0;${USER}@${HOSTNAME}:${PWDNAME}"; echo -ne "\007"
}

# set prompt command (title update and color prompt)
PROMPT_COMMAND=prompt_command
# set new b/w prompt (will be overwritten in 'prompt_command' later for color prompt)
PS1='\u@\h:\w\$ '


Теги:linuxbashshellpromptps1cool story bro
Хабы: Блог компании Mail.ru Group
+157
35,4k 534
Комментарии 143
▇▅▄▅▅▄ ▇▄▅