Как стать автором
Обновить

Улучшаем поиск в оболочке

Время на прочтение11 мин
Количество просмотров1.3K
Автор оригинала: Lawrence Tratt

Страшно вспомнить, сколько часов в день я проводил, работая в терминалах 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 часов, и 1d2d (т.д.) для команд, выполнявшихся на 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-е. Но можно не просто сделать лучше — это ещё и не так сложно, а ещё вы сами заметите, насколько эффективнее стали работать!

Спасибо Эдду Барретту за комментарии к этой статье.

Теги:
Хабы:
+13
Комментарии3

Публикации

Работа

Data Scientist
46 вакансий

Ближайшие события