Yet another cool story about bash prompt

в 22:31, , рубрики: bash, linux, prompt, shell

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

Yet another cool story about bash prompt

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

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

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

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

GNU bash, version 4.2.28(2)-release (i386-apple-darwin11.3.0)

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

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

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

echo -en "33[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 "33[6n" > /dev/tty && read -sdR CURPOS
	stty $OLDSTTY
	[[ ${CURPOS##*;} -gt 1 ]] && echo "${color_error}↵${color_error_off}"
}
PROMPT_COMMAND=prompt_command

PROMPT_COMMAND — это функция, которая вызывается при каждой отрисовке приглашения командной строки. Здесь был использован небольшой хак, подсмотренный мною в комментарии на stackoverflow, без этого хака значение переменной $CURPOS в некоторых случаях выводилось на экран. На кучу цветов не обращайте внимание — ниже они все нам пригодятся. Результат работы нашего конфига:
Yet another cool story about bash prompt
Красный фон был добавлен специально, чтобы отличать этот символ от того, что может вывести команда. И, да, на дворе 21 век, поэтому мы используем utf-ную локаль. В случае с устаревшими локалями. символ "↵", скорее всего, придётся заменить на что-нибудь попроще, например, символ "%", как в zsh.

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

При работе с гитом из консоли (только не нужно говорить про гуй — мы же суровые разработчики старой закалки!) удобно видеть текущую ветку гита и общее состояние репозитория — есть ли изменённые файлы, или всё закоммичено. Уже на этом этапе я пришёл к выводу, что мне будет удобней работать с приглашением командной строки, состоящим из двух строк — в первой строке выводится информация о текущем окружении (пользователь, сервер, рабочая директория, информация о репозитории и вообще всё, что мы пожелаем), а во второй строке — непосредственно команда, которую мы вводим. Первое время было непривычно, сейчас же я не готов возвращаться к прежней схеме.Для того, чтобы добавить информацию о гите, мы можем воспользоваться специально обученной функцией "__git_ps1", которая появляется вместе с bash-completion для гита:
Yet another cool story about bash prompt
или же написать свой «костыль». Я пошёл по второму пути, т.к. функция __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➜ "
}

Вот как это выглядит в итоге:
Yet another cool story about bash prompt
Пара слов про переменную PWDNAME. Да, я знаю, что можно написать "w" и всё будет так же, но у меня в функции «prompt_command» ещё и выставляется заголовок терминала:

# set title
echo -ne "33]0;${USER}@${HOSTNAME}:${PWDNAME}"; echo -ne "07"

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

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

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

На самом деле скрипт virtualenv добавляет название текущего venv в приглаашение баша, но уж очень некрасиво это выглядит:
Yet another cool story about bash prompt
Запрещаем 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➜ "
}

Yet another cool story about bash prompt
Собственно, тут всё достаточно банально.

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

При достаточно активной работе с терминалом вводимые команды и результат их выполнения сливаются друг с другом, особенно когда мы в выводе присутствует цвет:
Yet another cool story about bash prompt
Конечно, пример не совсем показателен, но все, кто работал в консоли, понимают, о чём я.

Я решил выводить горизонтальную черту в каждом приглашении на всю ширину терминала и эта идея себя оправдала — пользоваться консолью стало гораздо удобней:
Yet another cool story about bash prompt
Вот как я это делаю:

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)]"

Yet another cool story about bash prompt
Но для совместимости с устаревшими терминалами лучше этого избегать.

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

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

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

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

Автор: Dreadatour

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js