Улучшаем поиск в оболочке
Страшно вспомнить, сколько часов в день я проводил, работая в терминалах Unix и выполняя команды оболочки. По какой-то причине у разных людей сноровка при работе с оболочкой разительно отличается. Я знаю тех, кто даст мне в этом сто очков вперёд. В то же время, попадался мне один профессионал на зарплате, не знавший, что достаточно нажать клавишу «вверх», чтобы выяснить предыдущую команду.
Я неслучайно привёл такой пример: те команды, которые нам, как правило, приходится выполнять в оболочке, то и дело повторяются. Мне обычно доводится иметь дело с 50-100 уникальными (т.e., синтаксически неидентичными) командами оболочки в типичный рабочий день. Но среди этих команд можно выделить и крошечную подгруппу (напр., cargo test) таких, которыми я пользуюсь сотни раз в день.
Например, именно эту статистику я могу узнать при помощи команды fc, но есть некоторые кроссплатформенные неудобства, связанные с форматированием дат. Чтобы быстро проанализировать историю оболочки, я пытался в разных вариантах применять следующий код Python:
from datetime import datetime
days = {}
for l in open(".zsh_history"):
if len(l.split(":")) < 3: continue
_, t, cmd = [x.strip() for x in l.split(":", 2)]
d = datetime.fromtimestamp(int(t)).strftime("%Y-%m-%d")
cmd = cmd.split(";")[1]
cmds = days[d] = days.get(d, set())
cmds.add(cmd)
print(len(days["2025-03-19"]))
Поскольку у многих инструментов командной строки предусмотрены опции, запомнить которые не так просто, есть один приём, позволяющий не только сэкономить кучу времени, но и допускать гораздо меньше ошибок. Нужно научиться искать информацию в истории работы с оболочкой, чтобы быстро сверяться, как именно была сформулирована команда, когда мы вызывали её в прошлый раз. В этом посте я покажу, как освоить это искусство почти без труда.
Поиск в истории оболочки
В сравнительно крупных оболочках для Unix, например в Bash, пользователи давно уже могут просматривать собственную историю работы с оболочкой, нажав Ctrl-r
и введя интересующую их подстроку. Если я (именно в таком порядке) выполнил команды cat /etc/motd
, затем cat /etc/rc.conf
, затем Ctrl-r
и потом “cat”
, то первым совпадением при поиске будет cat /etc/rc.conf
. Далее, если снова нажать Ctrl-r
, то цикл отмотается до следующего ближайшего совпадения, а именно cat /etc/motd. Я этой возможностью почти не пользовался, так как поиск совпадения по подстроке — слишком грубый метод. Например, я могу знать, что мне нужна команда cat
, и при этом меня интересует лист motd
, но в каком каталоге их искать — я не помню. В таком случае поиск совпадения по подстроке мне не поможет. Напротив, я то и дело ищу что-нибудь в моём файле с историей оболочки, и для этого пользуюсь grep
(с джокерными символами).
Для меня всё изменилось, как только я научился сочетать Ctrl-r
с fzf, что сразу привело к двум важным изменениям. Во-первых, чёткое совпадение теперь не требуется, поэтому я могу ввести “c mo”
– и совпадение cat /etc/motd
будет найдено. Во-вторых, если подходящих вариантов несколько, то теперь все они выводятся сразу. Я ввёл “cat”
и получил несколько команд на cat, из которых смог выбрать вариант, сформулированный лучше всего (кстати, это может оказаться не самый свежий вариант).
По-моему, сложно переоценить всю силу этой функции. Мало что помогает настолько, как просто нажать ctrl-R
, затем ввести “l1”
– и у вас в командной строке уже записана последовательность из ста символов, запускающая сложный отладочный инструмент, в котором уже правильно расставлены многочисленные переменные окружения. Вывод этой команды сразу идёт в /tmp/l1
и появляется у меня в терминале.
Вооружившись Ctrl-r
и fzf, я буквально сразу стал работать с оболочкой вдвое эффективнее. Интересно, что в долгосрочной перспективе эффект ещё значительнее: я стал более увлечённо пользоваться командами оболочки, поскольку fzf серьёзно разгружает мне голову. Например, поскольку теперь стало гораздо проще «припоминать» использованные ранее команды, я больше не устанавливаю глобальных переменных окружения — а ведь раньше, если я забывал о них, это больно аукалось. В самом деле, если у кого-то оболочка начинает «чудить», я первым делом советую человеку проверить, какие глобальные переменные окружения у него установлены.
Теперь я устанавливаю переменные окружения для каждой команды отдельно, так как их легко «вспомнить» при помощи Ctrl-r
и fzf.
Много лет я предпочитал работать с оболочкой zsh. Позже я перешёл от zsh к fish, и первым делом в новой оболочке я сконфигурировал Ctrl-r
и fzf. Затем, когда я вновь вернулся к zsh и восстанавливал конфигурацию с нуля, я опять в первую очередь наладил Ctrl-r и fzf (и вскоре после них настроил автозавершения).
Мне нравится, как fish работает из коробки, но пришлось расстаться с ней по двум причинам. Во-первых, её отличия от оболочки POSIX явно не пошли ей на пользу, но при работе с ней приходилось переключать в голове тумблер, чтобы писать «обычные» скрипты. Во-вторых, некоторые настройки, заданные в fish по умолчанию (например, корректировка путей) оказываются глобальными и перманентными. Есть люди, которых я уговорил поработать с fish, и все они до единого (без преувеличения) начинали допускать всё те же небезопасные ошибки, что и я. Поэтому я больше никому её не рекомендую, а в какой-то момент задумался, зачем сам её до сих пор использую. Такова жизнь.
В общем, если из этого поста вы усвоите хотя бы то, что Ctrl-r
и fzf значительно повышают производительность труда при работе с Unix — значит, я написал этот пост не зря.
Разумеется, идеальных инструментов не бывает. Несколько месяцев назад я наткнулся на skim, fzf-подобную штуку, которая, как по мне, из коробки подходит мне чуть лучше, чем fzf. Отличия минимальны, и оба эти инструмента не дадут вам наделать серьёзных ошибок. При этом, как мне кажется, в skim поиск по шаблону чаще и быстрее находит именно те команды, что мне нужны. Ещё мне нравится пользовательский интерфейс skim и я полагаю, что skim проще устанавливать на произвольном системнике. Преимущества не так велики, но мне их хватило, чтобы перейти на этот инструмент.
Как сделать ещё лучше
Найдя Skim, я вдохновился и решил осмотреться в этой нише – может быть, здесь есть инструменты, которые позволят работать ещё продуктивнее. Вскоре мне попался Atuin, в котором для записи истории оболочки предусмотрен гораздо более филигранный механизм. В видеоролике, который служит его заставкой, демонстрируется гораздо более приятный UI для сопоставления, чем я вообще мог себе представить.
Тем не менее, я быстро осознал, что Atuin не для меня или, как минимум, что он тяжело мне даётся. В настоящее время я то и дело подключаюсь к разным серверам через ssh: со временем я отсёк от моей конфигурации всё лишнее и свёл её к единственному файлу .zshrc. Его легко передать на новую машину при помощи scp — естественно, я сразу начинаю работать быстрее. Atuin – кстати, я его не критикую, ведь это более мощный инструмент — сложнее устанавливать и настраивать. Также не уверен, что в обработке «нечёткой» информации Atuin может сравниться. Должен признать, что я с порога удивился, насколько же длинный установочный скрипт в Atuin. Сейчас в Atuin также отсутствует порт для подключения к порту OpenBSD (т.e., соответствующий «пакет»). Последний момент — не по вине Atuin, и, определённо, он погоды не делает. Позже я сам написал порт OpenBSD для Skim. Но, когда требуется быстро поэкспериментировать с новой программой, это заметная преграда. Сетевые аспекты Atuin также бросают меня в дрожь, но, как говорится, на вкус и цвет…
Я всё-таки полагаю, что некоторые читатели наверняка захотят присмотреться к этому инструменту и, возможно, найдут его полезным.
Однако уже после видео-презентации Atuin я сразу осознал, насколько было бы здорово, если бы мой механизм нечётких сопоставлений информативнее рассказывал мне о тех командах, которые подбирает.
В частности, и fzf, и skim по умолчанию показывают мне (мне!) бессмысленное целое число, идущее перед искомой командой. От этого мне всегда было немного неуютно, но я так и не собрался выяснить, что означает такое число. Например, я использую zsh + fzf + Ctrl-r и вижу:
Что здесь означает 5408, и почему под него отводится полезная площадь экрана? Skim пытается быть чуть аккуратнее: он покажет 5408 today'21:26
, но на это уходит ещё больше экранной площади!
Адаптация zsh и fzf/skim
К счастью, оказывается, что не составляет труда доработать функциональность Ctrl-r и пользовательский интерфейс fzf/skim. Чтобы не расходовать область видимости под бессмысленное (для меня) число, я теперь вижу следующее (где 11d
означает «отмотать на 11 дней назад» и т.д.):
Покажу вам, как я приспособил для этого zsh и skim. Полагаю, без особенных ухищрений можно откорректировать таким же образом и другие оболочки (а чтобы адаптировать эту конфигурацию под fzf, обычно хватает просто заменить команду sk
на fzf
).
Впервые мне это понадобилось, чтобы записать zsh в процессе выполнения команд. Я добавил в каталог ~/.zshrc
следующий код:
setopt EXTENDED_HISTORY
setopt inc_append_history_time
Здесь EXTENDED_HISTORY
заставляет .zsh_history
записать, когда (в секундах со времени начала эпохи Unix) была выполнена команда, и (при добавлении inc_append_history_time
) как долго она выполнялась. Плюс в том, что эти опции естественным образом позволяют перенести файлы с историей «отформатированными в традиционном виде»: любые нерасширенные команды для работы с историей будут принимать актуальную дату, поэтому вся .zsh_history
будет записана в одном и том же формате.
Затем потребовалось разобраться, как же zsh «выуживает» эту историю и как её отображает, когда я нажимаю Ctrl-r
. Здесь что fzf, что skim используют почти один и тот же код: покажу для примера, как в skim организована привязка функциональных клавиш zsh. В сущности, оба инструмента определяют функцию history-widget
, для которой затем назначается комбинация Ctrl-r
:
history-widget() { ... }
zle -N history-widget
bindkey '^R' history-widget
Можно переопределить версию, задав свою вместо той, что предоставляют fzf и skim. Для этого вышеприведённый код нужно положить в ваш каталог ~/.zshrc
сразу после той точки, в которой вы импортируете обычные комбинации горячих клавиш.
Рассмотрим, как в skim обстоит ситуация с history-widget
:
skim-history-widget() {
local selected num
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases 2> /dev/null
local awk_filter='{ cmd=$0; sub(/^\s*[0-9]+\**\s+/, "", cmd); if (!seen[cmd]++) print $0 }' # отфильтровываем дубли
local n=2 fc_opts=''
if [[ -o extended_history ]]; then
local today=$(date +%Y-%m-%d)
# Для сегодняшних команд заменим дату ($2) на "today", в противном случае удаляем время ($3).
# Отфильтровываем дубли.
awk_filter='{
if ($2 == "'$today'") sub($2 " ", "today'\''")
else sub($3, "")
line=$0; $1=""; $2=""; $3=""
if (!seen[$0]++) print line
}'
fc_opts='-i'
n=3
fi
selected=( $(fc -rl $fc_opts 1 | awk "$awk_filter" |
SKIM_DEFAULT_OPTIONS="--height ${SKIM_TMUX_HEIGHT:-40%} $SKIM_DEFAULT_OPTIONS -n$n..,.. --bind=ctrl-r:toggle-sort $SKIM_CTRL_R_OPTS --query=${(qqq)LBUFFER} --no-multi" $(__skimcmd)) )
...
Первым делом отмечу, что – благодаря EXTENDED_HISTORY
– в описанном здесь контексте проверка -o extended_history
всегда результирует в true
, поэтому тело оператора if всегда выполняется.
Далее перейдём немного вперёд: при помощи fc -rli 1
мы заставим zsh выводить историю оболочки в более удобоваримой форме, чем получается напрямую через .zsh_history
:
$ fc -rli 1
4 2025-02-07 15:05 pizauth status
3 2025-02-07 15:03 cargo run --release server
2 2025-02-07 15:03 email quick
1 2025-02-07 14:59 rsync_cmd bencher16 ./build.sh cargo test nested_tracing
Теперь также становится понятен смысл тех таинственных чисел, о которых шла речь раньше: это номера строк из fc
, где 1 – это самая старая команда у меня в ~/.zsh_history
! Бывает так, что они используются в качестве идентификаторов, поскольку можно приказать zsh «верни мне команду 5408».
Код awk выдаёт этот вывод в потоковом режиме, заменяя сегодняшнюю дату строковым литералом today
. Из предыдущих дней он удаляет информацию о часах и минутах, а также избавляется от дублей.
Пусть это и легко упустить, обращу ваше внимание на последнюю строку кода: -n$n..,..
. Она сообщает skim, какие столбцы, разделённые пробелами, подпадают под нечёткое сопоставление, а затем на вывод.
На данном этапе мы уже должны решить, как приспособить систему под наши цели. Первым делом нужно разобраться с выводом fc
и преобразовать время в количество секунд, истекших с начала эпохи Unix. Можно заставить fc сделать это, выставив -t '%s'
. Теперь вместо вывода 2025-03-21 22:10 мы получим 1742595052. Обратите внимание: на месте двух полей осталось одно! Команда fc добавляет ведущий пробел к номерам строк, а мы от него избавимся, конвейеризовав вывод fc
через sed -E "s/^ *//"
.
Потом мне потребовалось решить, в каком формате выразить идею «насколько давно выполнялась данная команда». Методом проб и ошибок я пришёл к выводу, что будет хорошо указывать абсолютное время hour:minute
для команд, выполнявшихся в последние 20 часов, и 1d
, 2d
(т.д.) для команд, выполнявшихся на 1 или более дней ранее. Почему именно 20 часов? Оказывается, что, если я приступаю к работе в 08:00, нажимаю Ctrl-r и вижу запись за 08:01, то даже не осознаю, что она была сделана вчера в 08:01. До сегодняшних 08:01 остаётся ещё около 60 секунд. Выбирая 20-часовой интервал, мы решаем эту проблему: тогда в 08:00 команды, выполненные вчера после обеда, отображаются, например, как 16:33, а вчерашние утренние команды – как 1d.
Теперь нужно переключиться на awk. Признаюсь, что поначалу я воздерживался от awk, так как ранее с этим языком никогда не работал. Быстро посмотрел, какие есть альтернативы, а потом осознал, почему в коде используется awk: просто awk установлен на любой машине с Unix. Для тех, кто не знаком с awk, поясню: программа, которую мы пишем, перебирает весь ввод строку за строкой, делит строки от пробела к пробелу, а получившиеся в результате разделения поля помещает в переменные $1
, $2
(т.д.). Из предыдущего кода на awk мы оставим функцию обнаружения дублей, но почти весь остальной код изменим.
Вawk нам первым делом требуется для конкретной команды преобразовать время в формате эпохи Unix (вида поле/переменная $2
) в целое число и высчитываем, сколько секунд назад она выполнялась. Это делается при помощи systime
(возвращающей текущее время относительно эпохи Unix):
ts = int($2)
delta = systime() - ts
Далее можно преобразовать delta секунд в дни, разделив значение на 86 400 (24ч 60 мин 60 сек == 86400 сек). Далее идёт простая последовательность условий if/else, чтобы аккуратно всё это отформатировать. При этом держим в уме, что:
1. 20ч == 72 000 сек
2. Конкатенация строк и преобразование целого числа в строку в awk осуществляется неявно
Преобразование выглядит так:
delta_days = int(delta / 86400)
if (delta_days < 1 && delta < 72000) { $2=strftime("%H:%M", ts) }
else if (delta_days == 0) { $2="1d" }
else { $2=delta_days "d" }
Конечно, можно попробовать продолжить эту схему дробления. Например, можно обозначать команды старше недели как “1w
” и т.д.: пока я не удосужился этим заняться.
Правда, здесь есть маленькая ложечка дёгтя: рассинхронизация часов. Из-за этого может казаться, что какие-то команды выполняются в будущем. Пока я не встречал такого на практике, но не раз обжигался, имея дело с компьютерами и их часами. Поэтому я подстраховываюсь на случай, что это неизбежно произойдёт, и как раз для этого пользуюсь префиксом +
:
delta_days = int(delta / 86400)
if (delta < 0) { $2="+" (-delta_days) "d" }
else ...
Обратите внимание, что мне пришлось заключить (-delta_days)
в скобки, так как если этого не сделать — по причинам, которые мне слишком лень изучать – awk не сцепляет целое число так, как я хочу.
Поскольку у нас стало на одно поле меньше, чем было, можем немного упростить наш вывод:
line=$0; $1=""; $2=""
if (!seen[$0]++) print line
С кодом awk всё готово. Далее нужно ещё немного откорректировать строку с selected=...
, поменять -n$n..,..
на --with-nth $n..
. Так мы приказываем fzf и skim не отображать в выводе номера строк, и также не учитывать эту информацию при нечётком сопоставлении.
Резюмируя, имеем, что в обновлённом виде фрагмент с history-widget
будет выглядеть так (полностью код приведён здесь):
local n=1 fc_opts=''
if [[ -o extended_history ]]; then
awk_filter='
{
ts = int($2)
delta = systime() - ts
delta_days = int(delta / 86400)
if (delta < 0) { $2="+" (-delta_days) "d" }
else if (delta_days < 1 && delta < 72000) { $2=strftime("%H:%M", ts) }
else if (delta_days == 0) { $2="1d" }
else { $2=delta_days "d" }
line=$0; $1=""; $2=""
if (!seen[$0]++) print line
}'
fc_opts='-i'
n=2
fi
selected=( $(fc -rl $fc_opts -t '%s' 1 | sed -E "s/^ *//" | awk "$awk_filter" |
SKIM_DEFAULT_OPTIONS="--height ${SKIM_TMUX_HEIGHT:-40%} $SKIM_DEFAULT_OPTIONS --with-nth $n.. --bind=ctrl-r:toggle-sort $SKIM_CTRL_R_OPTS --query=${(qqq)LBUFFER} --no-multi" $(__skimcmd)) )
Этих простых изменений достаточно, чтобы я получил следующий вывод, как только нажму Ctrl-r
и начну вводить символы:
Итоги
Я поработал с такой изменённой конфигурацией около полутора месяцев и обнаружил, что с ней получается гораздо эффективнее. Оказывается, я довольно много могу вспомнить о нужной мне команде, как только увижу, когда она использовалась. Например, если я вижу совпадение с отметкой «1d
» или «7d
», мне этого хватает, чтобы исключить её или не просматривать до конца. Бывает, что меня интересует дельта времени как таковая. Если я начинаю искать совпадения с «2d
», то, разумеется, fzf или skim учитывают именно команды двухдневной давности.
Но, возможно, из этой статьи можно сделать и более глобальный вывод. Если вы, как и я, значительную часть жизни посвятили работе с командной строкой Unix, вполне возможно, что вы переизобрели такие приёмы, которые будут вполне узнаваемы и для пользователей, работавших с оболочкой ещё в 1970-е. Но можно не просто сделать лучше — это ещё и не так сложно, а ещё вы сами заметите, насколько эффективнее стали работать!
Спасибо Эдду Барретту за комментарии к этой статье.