Редакторы и IDE Терминальный рабочий процесс: tmux, оболочка, dotfiles, синхронизация окружения
0%

Терминальный рабочий процесс: tmux, оболочка, dotfiles, синхронизация окружения

Терминальный рабочий процесс: tmux, оболочка, dotfiles, синхронизация окружения

Редактор — это одно окно. Работа программиста — это цикл: прочитать код, изменить, собрать, запустить, посмотреть логи, залезть в базу, перезапустить, повторить. Редактор занимает в этом цикле от силы половину времени, а всё остальное живёт в терминале — и настроено обычно существенно хуже, потому что «это же просто консоль».

Это дорогая ошибка. Настройка редактора выгорает вместе с модой на редакторы: вы вложились в Vim, потом в VS Code, потом в Zed, и каждый раз начинали заново. А терминальный слой переживает все эти миграции: zsh, tmux, ssh и набор утилит одинаково работают под любым редактором, включая тот, который вы будете использовать через пять лет. Это самая долгоиграющая инвестиция во всём треке.

Разбираться будем сверху вниз по слоям: что такое терминал на самом деле, чем оболочка отличается от языка скриптов, зачем мультиплексор, какие утилиты реально окупаются, а какие создают проблем больше, чем решают, — и как всё это упаковать в репозиторий, который разворачивается на чистой машине за понятное время. Живым примером, как и в остальных статьях трека, служит публичный репозиторий the-homeless-god/dotfiles: там есть и хорошие архитектурные решения, и типовые грабли, на которых удобно учиться.

1. Четыре слоя, которые все путают

Фраза «настроил терминал» скрывает четыре независимых компонента с разными зонами ответственности. Пока они в голове слиты в одно, любая проблема диагностируется гаданием.

Практический смысл разделения виден на типичных багах:

  • «Вместо иконок квадратики» — слой эмулятора (шрифт), а не оболочки.
  • «command not found в панели tmux, но работает в новой вкладке» — слой оболочки (файлы инициализации), а не tmux.
  • «Цвета в Vim другие внутри tmux» — стык эмулятора и мультиплексора (TERM, terminfo).
  • «Ctrl-стрелки не работают по SSH» — слой эмулятора и terminfo на удалённой машине.
  • «Всё сломалось после переустановки ноутбука» — слой доставки конфигов.

2. Терминал — это протокол, а не приложение

Терминал возник как физическое устройство: телетайп, потом видеотерминал VT100. Программа писала в него байты, и часть байтов были не текстом, а командами: «переведи курсор в позицию 5,10», «сделай следующий текст жирным». Это и есть escape-последовательности — управляющие строки, начинающиеся с ESC (0x1b).

Физические терминалы исчезли, протокол остался. Современный «эмулятор терминала» — это программа, которая рисует окно и разговаривает с процессом по тому же протоколу через псевдотерминал (pty): пару файловых дескрипторов, где на одном конце ядро эмулирует устройство, а на другом сидит ваша оболочка. Именно поэтому ls | cat даёт другой вывод, чем просто ls: ls спрашивает у ядра isatty(1) и, не увидев терминала, отключает колонки и цвет.

Лучшее объяснение этого механизма — The TTY demystified Линуса Окессона; про то, как терминалы связаны с процессами и сигналами, есть отдельный разговор в треке по операционным системам.

terminfo и переменная TERM

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

# Что мы себя объявляем
echo $TERM                     # xterm-256color / tmux-256color / alacritty / xterm-ghostty

# Есть ли описание в базе на ЭТОЙ машине
infocmp "$TERM" > /dev/null && echo ok || echo "нет описания — будет ломаться"

# Поддерживается ли 24-битный цвет
tput colors                    # 256 — это НЕ truecolor, truecolor не отражается здесь
printf '\e[38;2;255;100;0mTRUECOLOR\e[0m\n'   # если оранжевый — 24 бита есть

Типичный сценарий поломки: локально стоит kitty или ghostty, вы заходите по SSH на сервер, где описания xterm-kitty в terminfo нет, и половина клавиш перестаёт работать. Два решения: kitten ssh / infocmp -x | ssh host 'tic -x -' (перенести описание) или честно ставить TERM=xterm-256color для чужих машин.

Выбор эмулятора: что реально различается

Эмулятор Рендер Сильная сторона Цена
Alacritty GPU предсказуемый, быстрый, конфиг в TOML нет вкладок и сплитов — нужен tmux
kitty GPU графика в терминале, kitten ssh, свои сплиты своё terminfo, нестандартный протокол клавиш
WezTerm GPU кроссплатформенный, конфиг на Lua, свой мультиплексор тяжелее, больше движущихся частей
Ghostty GPU нативный на macOS/Linux, «правильные» дефолты молодой, экосистема тонкая
iTerm2 CPU/Metal зрелость, интеграции с macOS только macOS, много настроек-ловушек
Windows Terminal GPU единственный вменяемый на Windows, WSL привязан к платформе
GNOME Terminal / Konsole CPU стоит из коробки, ничего не надо медленный скролл на больших выводах

Про задержку ввода стоит сказать честно. Разница между эмуляторами существует и измерена — у Дэна Лу есть замеры latency терминалов, где разброс доходит до десятков миллисекунд. Но в реальной работе она почти всегда меньше, чем эффект от двух вещей: escape-time в tmux (об этом ниже) и медленного prompt, который добавляет 100–300 мс перед каждой командой. Менять эмулятор ради миллисекунд, не измерив свой prompt, — классическая оптимизация не того.

3. Оболочка: программа с бюджетом запуска

Матрица инициализации

Самый частый и самый недооценённый источник багов — то, какие файлы читает оболочка. Флагов два, они независимы, и комбинаций четыре.

Какие файлы читает оболочка: матрица login и interactive

Из матрицы следует правило, которое стоит записать и не нарушать:

  • ~/.zprofile / ~/.bash_profile — только то, что должно наследоваться дочерними процессами: PATH, EDITOR, LANG, XDG_*. Выполняется один раз за сессию входа.
  • ~/.zshrc / ~/.bashrc — всё интерактивное: алиасы, функции, prompt, completion, биндинги клавиш, история. Выполняется на каждую новую панель tmux и вкладку.
  • ~/.zshenv — читается всегда, включая неинтерактивные скрипты. Поэтому туда можно класть только очень дешёвые присваивания и ничего печатающего в stdout: вывод из .zshenv ломает scp, rsync и git по SSH.

Проверить, что реально читается, можно так:

# zsh: трассировка source-цепочки (zsh 5.4+)
zsh -o sourcetrace -i -c exit 2>&1 | head -40

# bash: то же самое грубее
bash -lixc exit 2>&1 | grep -E '^\+\+? source|^\+\+? \.'

Бюджет запуска и как его измерить

Оболочка стартует десятки раз в день: каждая панель tmux, каждый git commit с хуком, каждая вкладка. 800 мс запуска — это не «почти мгновенно», это ощутимый лаг на каждое открытие панели.

# Честный замер: 10 холодных запусков интерактивной оболочки
hyperfine --warmup 3 'zsh -i -c exit' 'bash -i -c exit'

# Без hyperfine
for i in $(seq 1 10); do /usr/bin/time -f %e zsh -i -c exit; done 2>&1 | sort -n

Если получилось много, профилируем. В zsh для этого есть штатный модуль:

# ПЕРВАЯ строка ~/.zshrc
zmodload zsh/zprof

# ... весь конфиг ...

# ПОСЛЕДНЯЯ строка ~/.zshrc
zprof

Вывод отсортирован по суммарному времени. В 90 % случаев виноваты одни и те же три вещи.

Первое — менеджеры версий. nvm.sh — это ~1000 строк shell, которые парсятся при каждом запуске; он один спокойно даёт 300–600 мс. В разобранных dotfiles строка source $(brew --prefix nvm)/nvm.sh присутствует, причём с brew --prefix — то есть запуском внешнего процесса Homebrew при каждом старте оболочки. Это ещё 100–200 мс сверху, и лечится тривиально.

# Плохо: nvm парсится всегда, brew запускается всегда
# source $(brew --prefix nvm)/nvm.sh

# Хорошо: путь зафиксирован, nvm грузится при первом обращении.
# Заглушки подменяют себя настоящими функциями и повторно вызывают команду.
export NVM_DIR="$HOME/.nvm"
_load_nvm() {
  unset -f nvm node npm npx 2>/dev/null
  [ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh"
  [ -s "$NVM_DIR/bash_completion" ] && source "$NVM_DIR/bash_completion"
}
nvm()  { _load_nvm; nvm "$@"; }
node() { _load_nvm; node "$@"; }
npm()  { _load_nvm; npm "$@"; }
npx()  { _load_nvm; npx "$@"; }

Ещё честнее — заменить nvm на fnm или mise: они написаны на Rust, инициализируются за единицы миллисекунд и умеют то же автопереключение по .nvmrc.

Второе — compinit. Пересборка кэша автодополнений и проверка прав на каталоги $fpath стоит 100–300 мс.

# Полная проверка не чаще раза в сутки, в остальные разы — быстрый путь -C
autoload -Uz compinit
if [[ -n ${ZDOTDIR:-$HOME}/.zcompdump(#qN.mh+24) ]]; then
  compinit
else
  compinit -C
fi

Третье — всё, что дёргает сеть или подпроцессы при старте. npm root -g в конфиге (тоже встречается в разобранном .zshrc) — это запуск Node на каждую панель. Проверки обновлений, kubectl completion, gcloud init — туда же. Правило простое: в .zshrc не должно быть ни одного вызова внешней программы, кроме дешёвых eval "$(tool init)" у Rust-утилит.

Отдельно про prompt. powerlevel10k с instant prompt — честно быстрое решение: он рисует приглашение из кэша до того, как конфиг догрузится. starship проще и кроссшелльный, но его модули (git status на большом репозитории, версии языков) считаются синхронно; на монорепозитории это заметно. Если вам важна каждая миллисекунда — самый быстрый prompt это тот, который вы написали сами из трёх переменных.

Какую оболочку выбрать

bash zsh fish nushell
POSIX-совместимость да почти нет нет
Есть везде по умолчанию да macOS, часть Linux нет нет
Автодополнение из коробки слабое сильное (нужна настройка) лучшее сильное
Подсказки по истории плагин плагин из коробки из коробки
Структурированные данные нет нет нет да, таблицы
Копипаст команд из интернета работает работает часто ломается ломается
Скорость старта (типично) 20–50 мс 40–400 мс 30–80 мс 40–100 мс

Честный вывод: fish и nushell лучше как интерактивная среда, zsh — разумный компромисс, bash — лингва франка для скриптов. Компромисс, который реально работает на практике: интерактивно — что угодно, но в скриптах всегда #!/usr/bin/env bash и никогда не рассчитывать на алиасы и функции из своего конфига. Если выбираете fish — держите в голове, что каждая вторая инструкция в интернете написана для POSIX-оболочек, и половина export FOO=bar в ней не сработает.

История, которую не жалко

Настройка истории — это пять строк, которые почему-то есть у одного разработчика из десяти.

HISTFILE=~/.zsh_history
HISTSIZE=200000            # в памяти
SAVEHIST=200000            # на диске
setopt EXTENDED_HISTORY    # сохранять таймстемпы: потом видно, когда именно ты это делал
setopt INC_APPEND_HISTORY  # писать сразу, а не при выходе (иначе теряется при kill -9)
setopt SHARE_HISTORY       # общая история между панелями tmux
setopt HIST_IGNORE_SPACE   # команда с ведущим пробелом не сохраняется — для секретов
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_REDUCE_BLANKS
setopt HIST_VERIFY         # подставить команду из истории, но НЕ выполнять сразу

# Поиск по истории с учётом уже набранного префикса — самая полезная привязка вообще
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
zle -N up-line-or-beginning-search
zle -N down-line-or-beginning-search
bindkey '^[[A' up-line-or-beginning-search
bindkey '^[[B' down-line-or-beginning-search

HIST_IGNORE_SPACE — не косметика, а гигиена безопасности: команду с токеном набирают с ведущим пробелом, и она не попадает в файл, который потом синхронизируется и бэкапится.

4. tmux: сервер, который держит ваше состояние

Модель, из которой следует всё остальное

Главное недоразумение вокруг tmux — считать его «вкладками для терминала». Вкладки есть в любом эмуляторе, и ради них tmux не нужен. tmux — это отдельный серверный процесс, который владеет псевдотерминалами и процессами, а ваш терминал только подключается к нему как дисплей.

Объектная модель tmux: сервер, сессии, окна, панели, клиенты

Из этой модели напрямую следуют все полезные свойства: сессия переживает закрытие ноутбука и обрыв SSH; к одной сессии можно подключиться с двух машин; долгая миграция базы не умрёт от того, что Wi-Fi моргнул. Жизненный цикл сессии выглядит так:

Разница с альтернативами:

Способ Переживает обрыв SSH Переживает перезагрузку Сплиты Цена
Вкладки эмулятора нет нет у некоторых нулевая
GNU Screen да нет примитивные старый, беднее API
tmux да с tmux-resurrect/continuum да, скриптуемые prefix-клавиша, свой словарь
Zellij да да, встроенные layout да новый, свои клавиши, WASM-плагины
Мультиплексор внутри WezTerm/kitty частично нет да привязка к эмулятору
nohup / systemd-run да да нет нет интерактивности

Zellij стоит упомянуть отдельно: у него дружелюбные дефолты, подсказки клавиш внизу экрана и декларативные layout-файлы из коробки — новичку в нём объективно легче. Цена — он менее распространён, и на чужом сервере его почти наверняка нет, а tmux есть везде.

Рабочий .tmux.conf с объяснениями

# ---------- база ----------
# escape-time: сколько tmux ждёт продолжения после ESC, чтобы отличить
# "нажали Escape" от "пришла escape-последовательность". До tmux 3.4 дефолт
# был 500 мс — и это ровно та задержка, из-за которой Vim "тормозит" в tmux.
# С 3.4 дефолт 10 мс, но 0 всё равно ставят: локально терминал не отстаёт.
set -sg escape-time 0

# Индексы с единицы: клавиша 1 = первое окно. Мелочь, экономящая тысячи промахов.
set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on          # закрыл окно 2 — остальные сдвинулись, без дыр

set -g history-limit 50000          # буфер прокрутки на панель; 2000 по умолчанию мало
set -g mouse on                     # скролл, выбор панели, ресайз мышью
set -g focus-events on              # чтобы автосохранение в Vim/Neovim работало
setw -g aggressive-resize on        # размер по активному клиенту, а не по минимальному

# ---------- цвет ----------
# Внутри tmux TERM должен быть tmux-256color (не screen-256color: он не знает
# про italic и часть возможностей). terminal-overrides добавляет truecolor
# и undercurl, которые внешний терминал умеет, а terminfo tmux не декларирует.
set -g default-terminal "tmux-256color"
set -ga terminal-overrides ",*256col*:Tc"
set -ga terminal-overrides ",*:Smulx=\\E[4::%p1%dm"   # undercurl для LSP-диагностик
set -ga terminal-overrides ",*:Setulc=\\E[58::2::%p1%{65536}%/%d::%p1%{256}%/%{255}%&%d::%p1%{255}%&%d%;m"

# ---------- префикс ----------
# C-b конфликтует с "страница назад" в Vim/less. C-a конфликтует с "в начало
# строки" в readline. Компромисс: C-a как префикс + двойное C-a шлёт настоящий C-a.
unbind C-b
set -g prefix C-a
bind C-a send-prefix

# ---------- навигация ----------
# Сплиты по логике символа: | вертикально, - горизонтально. Дефолтные % и "
# запомнить невозможно. -c сохраняет текущий каталог — иначе новая панель
# открывается в $HOME, и это бесит каждый день.
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"

# hjkl между панелями, HJKL для ресайза с повтором (-r работает 500 мс подряд)
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
bind -r H resize-pane -L 5
bind -r J resize-pane -D 5
bind -r K resize-pane -U 5
bind -r L resize-pane -R 5

# Перезагрузка конфига без перезапуска сервера
bind r source-file ~/.config/tmux/tmux.conf \; display "конфиг перечитан"

# ---------- copy-mode ----------
setw -g mode-keys vi
bind Enter copy-mode
bind -T copy-mode-vi v send -X begin-selection
bind -T copy-mode-vi y send -X copy-pipe-and-cancel 'pbcopy'   # Linux: wl-copy / xclip -sel c
bind -T copy-mode-vi Escape send -X cancel

# OSC 52: tmux отправляет скопированное в буфер САМОГО терминала
# escape-последовательностью. Работает даже через несколько SSH-хопов —
# это единственный способ нормально копировать с удалённой машины.
set -g set-clipboard on

# ---------- сессии ----------
# Переключатель сессий через fzf — быстрее, чем prefix+s со списком
bind S display-popup -E "tmux list-sessions -F '#{session_name}' | fzf --reverse | xargs -r tmux switch-client -t"

Расшифровка #{pane_current_path} и всего остального формата — в man tmux, раздел FORMATS. Это единственная документация, которую стоит читать; статьи в интернете почти всегда копируют чужие конфиги, включая ошибки.

Буфер обмена через SSH: почему это работает

set -g set-clipboard on — самая недооценённая строка в конфиге. Механизм:

Ограничения честные: терминал должен поддерживать OSC 52 (умеют kitty, WezTerm, Ghostty, foot, iTerm2, Windows Terminal; в Alacritty включается настройкой), а объём данных ограничен — гигабайт лога так не скопировать. Зато работает через любое число промежуточных хостов, потому что это просто байты в потоке.

Скриптование раскладок

Ручное раскладывание окон каждое утро — ровно та работа, которую надо один раз записать. Разберём подход из разбираемых dotfiles: там есть configs/tmux.sh, который поднимает сессию work с окнами development (vim), infos (dust, duf+ps), browser (ping), downloads, containers (podman info) и подключается к ней. Идея абсолютно правильная — окружение как код, а не как ритуал.

Но в реализации есть три поучительные проблемы, которые ловятся при первом же запуске на чистой машине:

  1. Сессия не создаётся. Скрипт делает tmux start-server, а затем сразу tmux new-window -t work:0. start-server поднимает сервер, но не создаёт сессию, поэтому new-window -t work:0 завершится с can't find session: work. Нужен явный tmux new-session -d -s work.
  2. Скрипт не идемпотентен. Второй запуск при уже существующей сессии добавит окна поверх старых. Правильный паттерн — проверить tmux has-session и просто подключиться.
  3. Команды отправляются в непонятную панель. tmux send-keys "cd ..." C-m без -t шлёт в текущую активную панель, а какая она в момент выполнения — зависит от гонки между созданием окна и запуском команды. Плюс tmux send-keys "ps" Enter C-m шлёт перевод строки дважды.

Как это выглядит в исправленном виде:

#!/usr/bin/env bash
# ~/bin/dev — идемпотентный запуск рабочей раскладки.
set -euo pipefail

SESSION="work"
PROJECTS="$HOME/projects"

# Уже есть — просто подключаемся. Никаких дублирующихся окон.
if tmux has-session -t "$SESSION" 2>/dev/null; then
  exec tmux attach-session -t "$SESSION"
fi

# -d: создать в фоне, чтобы дальше настраивать без attach.
# -c: рабочий каталог. -n: имя первого окна.
tmux new-session -d -s "$SESSION" -c "$PROJECTS" -n editor
tmux send-keys -t "$SESSION:editor" 'nvim .' C-m

# Окно с логами: две панели, каждая адресуется явно по индексу.
tmux new-window  -t "$SESSION" -c "$PROJECTS" -n logs
tmux send-keys   -t "$SESSION:logs.1" 'docker compose logs -f' C-m
tmux split-window -t "$SESSION:logs" -h -c "$PROJECTS"
tmux send-keys   -t "$SESSION:logs.2" 'btop' C-m

tmux new-window -t "$SESSION" -c "$PROJECTS" -n db
tmux send-keys  -t "$SESSION:db" 'psql "$DATABASE_URL"' C-m

tmux select-window -t "$SESSION:editor"
exec tmux attach-session -t "$SESSION"

Если раскладок больше двух-трёх, скрипты превращаются в свой маленький фреймворк — и лучше взять готовый декларативный: tmuxp (YAML) или tmuxinator (Ruby). Тот же результат в десяти строках YAML вместо сорока строк bash.

resurrect и continuum: что они правда умеют

В разбираемом конфиге подключены tmux-resurrect и tmux-continuum с @continuum-restore 'on' — сессии восстанавливаются после перезагрузки. Полезно, но важно понимать границы: плагины сохраняют раскладку окон и панелей, рабочие каталоги и имена сессий, а также умеют перезапускать явно разрешённый список программ (@resurrect-processes). Они не восстанавливают состояние процессов: незакоммиченный буфер, открытую транзакцию psql, позицию в логе. Это снапшот мебели, а не памяти.

Второе наблюдение по тому же конфигу: там одновременно подключены tmux-tilit и tmux-tilish — два тайлинг-плагина, реализующих одну и ту же идею (i3-подобное управление окнами) и вешающих биндинги на пересекающиеся клавиши. Побеждает тот, что загрузился последним, но предсказать поведение по конфигу нельзя. Это ровно та же болезнь, что и четыре движка автодополнения в .vimrc из статьи про Neovim: инструмент добавили, старый не убрали.

5. Утилиты: что окупается, а что создаёт долг

Волна Rust-утилит действительно улучшила терминал. Но вокруг неё сложилась вредная практика — подменять алиасами стандартные команды, — и её стоит разобрать честно.

Замена Кого Что даёт Стоит ли
ripgrep (rg) grep -r на порядок быстрее, уважает .gitignore, UTF-8 да, безусловно
fd find человеческий синтаксис, параллельность да
fzf интерактивный фильтр для чего угодно да, главная утилита списка
bat cat подсветка, номера строк, пейджер да, но см. ниже про алиас
eza ls иконки, git-статус, дерево да, вкусовое
zoxide cd переход по частоте, z proj да, но см. ниже
delta git diff side-by-side, подсветка внутри строк да, для чтения ревью
jq / yq обработка JSON/YAML да, обязательно
hyperfine time статистика, прогрев, сравнение да, для любых замеров
dust, duf, procs, gping du, df, ps, ping красивее и нагляднее осторожно

Про алиасы поверх стандартных команд

В разбираемом .zshrc есть блок, который встречается в сотнях dotfiles:

alias cat='bat -pp --theme=Dracula'
alias find='fd'
alias grep='rg'
alias du='dust'
alias df='duf'
alias ps='procs'
alias cd='z'
alias docker='podman'

Каждый алиас по отдельности понятен, но у набора есть системная цена, и её надо знать заранее:

  1. Флаги несовместимы. fd — не find: find . -name '*.go' -exec grep X {} \; с алиасом просто не запустится. rg не понимает grep -E '...' file в части опций и по умолчанию рекурсивен и уважает .gitignore — то есть молча пропускает файлы, которые grep бы нашёл. Это худший класс ошибок: не падение, а тихо неполный результат.
  2. Копипаст из документации ломается. Инструкция из README проекта, статья, ответ на Stack Overflow — всё написано под стандартные команды.
  3. Мышечная память протухает. На проде, в контейнере, на чужой машине ваших алиасов нет. Навык grep/find при этом атрофирован.
  4. alias cd='z' — самый рискованный: cd используется внутри функций и в подстановках, а zoxide при неоднозначности может увести не туда. У zoxide для этого есть штатный флаг --cmd cd, который делает подмену осознанной, но лучше оставить z отдельной командой.
  5. alias docker='podman' прячет реальное различие движков: сети, тома, rootless-режим, docker compose против podman-compose ведут себя по-разному. Когда что-то сломается, диагностика начнётся с ложной посылки.

Алиасы в интерактивной оболочке не наследуются скриптами, так что прод они не сломают напрямую. Ломается другое — ваша модель мира. Компромисс, который сохраняет и удобство, и переносимость:

# 1. Улучшения — под своими именами. Ничего не подменяем.
alias ll='eza -la --icons --group-directories-first --git'
alias lt='eza --tree --level=2'
alias b='bat'
alias g='rg'

# 2. Если очень хочется подменить — только с сохранением оригинала под явным именем.
alias cat='bat --paging=never --style=plain'
alias rcat='command cat'    # `command` обходит алиас и функцию

# 3. Флаги дефолтов — через переменные окружения, а не алиасы:
#    так они действуют и в скриптах, и в вызовах из других программ.
export BAT_THEME="ansi"
export RIPGREP_CONFIG_PATH="$HOME/.config/ripgrep/config"
export FZF_DEFAULT_COMMAND='fd --type f --hidden --exclude .git'
export FZF_DEFAULT_OPTS='--height 40% --layout=reverse --border'

fzf как клей

Отдельного разговора заслуживает fzf — не потому, что он «ещё одна утилита», а потому, что это универсальный интерактивный фильтр, который склеивает всё остальное. Две штатные привязки (Ctrl-R для истории, Ctrl-T для файлов) окупают установку сразу, а дальше пишутся свои. В разбираемом репозитории есть scripts/customs/rgfzf.sh — ровно эта идея; вот минимальная переносимая версия:

#!/usr/bin/env bash
# rgf — живой grep по проекту с превью и открытием в редакторе.
# Логика: ripgrep перезапускается на каждое нажатие клавиши (--bind change:reload),
# fzf работает только как UI. Это важно: фильтровать миллион строк на стороне fzf медленно.
set -uo pipefail

RG='rg --column --line-number --no-heading --color=always --smart-case'

selected=$(
  FZF_DEFAULT_COMMAND="$RG -- '' " \
  fzf --ansi --disabled --query "${1:-}" \
      --bind "change:reload:$RG -- {q} || true" \
      --delimiter : \
      --preview 'bat --color=always --highlight-line {2} --line-range :500 {1}' \
      --preview-window 'right,60%,+{2}+3/3'
) || exit 0

file=${selected%%:*}
rest=${selected#*:}
line=${rest%%:*}
[ -n "$file" ] && exec "${EDITOR:-vim}" "+$line" "$file"

Оценка стоимости: ripgrep перезапускается на каждый символ, но это O(размер проекта) на нажатие с очень маленькой константой — на репозитории в сотни тысяч строк отклик остаётся в десятках миллисекунд, потому что rg параллелится по файлам и отсекает .gitignore. Фильтрация же на стороне fzf — O(строк на экране), потому что fzf дочитывает поток лениво.

6. Dotfiles: пять способов доставки

Конфиги написаны — их надо доставлять на машины. Способов принципиально пять, и выбор между ними — это выбор точки на кривой «сложность против воспроизводимости».

Способ Как работает Плюсы Минусы
Скрипт симлинков ln -s из репозитория в $HOME прозрачно, читаемо, ноль зависимостей нет шаблонов, нет секретов, писать самому
GNU Stow симлинки по структуре каталогов нулевая конфигурация, стандартный инструмент только симлинки, нет условной логики
Bare git repo $HOME — рабочее дерево, .git вынесен нет симлинков вообще, git status по всему $HOME опасно при git add -A, нужен showUntrackedFiles no
chezmoi шаблоны Go + генерация файлов в $HOME шаблоны по OS/hostname, интеграция с менеджерами паролей свой словарь команд, файлы генерируются, а не линкуются
Nix + home-manager декларативное описание всего окружения полная воспроизводимость, откаты, одинаковые версии самый высокий порог входа, свой язык, вес

Разбираемый репозиторий выбрал шестой, гибридный вариант: configs/ + императивный установщик scripts/install-tools.sh (~1000 строк bash) + декларативный каталог инструментов configs/tools.json. Отдельно стоит отметить два решения, которые сделаны правильно и которых нет у большинства:

Список инструментов вынесен в данные. tools.json описывает категории (essential, development, file_management, media) с локализованными названиями, а скрипт читает JSON и строит интерактивное меню. Список меняется часто, логика — редко; разделение верное. Это тот же принцип, что у Brewfile и у Nix.

Установка кроссплатформенная. Функция install_package перебирает brew, apt-get, apk, yum, dnf, pacman — то есть репозиторий не притворяется, что мир состоит только из macOS.

Чего не хватает — фиксации версий. brew install ripgrep через год поставит другой ripgrep, и «воспроизводимость» здесь означает «поставится что-то похожее». Для императивного подхода потолок такой; строгую воспроизводимость даёт только Nix или контейнер.

Bare-репозиторий: рецепт целиком

Если хочется минимума механики, самый компактный рабочий способ — bare-репозиторий. Никаких симлинков: $HOME и есть рабочее дерево.

# Инициализация один раз
git init --bare "$HOME/.dotfiles"
alias dot='git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
# Критически важно: иначе `dot status` покажет весь домашний каталог
dot config --local status.showUntrackedFiles no
dot remote add origin git@github.com:user/dotfiles.git

dot add ~/.zshrc ~/.config/tmux/tmux.conf ~/.gitconfig
dot commit -m "начальные конфиги"
dot push -u origin main

# Развёртывание на новой машине
git clone --bare git@github.com:user/dotfiles.git "$HOME/.dotfiles"
git --git-dir="$HOME/.dotfiles" --work-tree="$HOME" checkout   # перезапишет существующие!

Единственная реальная опасность — последний checkout молча перезапишет уже существующие файлы. Поэтому в bootstrap-скрипте перед ним всегда делают резервную копию конфликтующих путей (список даёт сам git в тексте ошибки).

Гетерогенность машин: где ломаются все

Личный ноутбук на macOS ARM, рабочий на macOS Intel, сервер на Debian, контейнер на Alpine — и один репозиторий на всех. Ровно здесь живёт самый частый класс багов, и в разбираемом .zshrc он присутствует в чистом виде:

export USER_HOME_DIR="/Users/developer"
export ZSH="$USER_HOME_DIR/.oh-my-zsh"
export PATH="$USER_HOME_DIR/dotfiles/scripts/customs:$PATH"

Домашний каталог зашит константой. Конфиг работает у пользователя developer на macOS и молча ломается у всех остальных: $ZSH указывает в никуда, source $ZSH/oh-my-zsh.sh падает, PATH получает несуществующие пути. Причём в том же файле ниже уже используется корректный $HOME — то есть это накопившееся расхождение, а не осознанное решение. Лечится одной строкой: USER_HOME_DIR="$HOME". Смежная деталь — export PATH="$PATH:/root/.cargo/bin": путь от root, оставшийся от отладки в Docker.

Правильная форма условной логики:

# ~/.zshrc — единый файл на все машины, различия через явные проверки

# 1. Платформа
case "$OSTYPE" in
  darwin*)
    # Homebrew на ARM и Intel живёт по РАЗНЫМ путям — не хардкодим
    if [[ -x /opt/homebrew/bin/brew ]]; then
      eval "$(/opt/homebrew/bin/brew shellenv)"      # Apple Silicon
    elif [[ -x /usr/local/bin/brew ]]; then
      eval "$(/usr/local/bin/brew shellenv)"          # Intel
    fi
    alias copy='pbcopy'
    ;;
  linux*)
    alias copy='wl-copy 2>/dev/null || xclip -selection clipboard'
    ;;
esac

# 2. Наличие инструмента, а не предположение о нём
command -v zoxide  >/dev/null && eval "$(zoxide init zsh)"
command -v mise    >/dev/null && eval "$(mise activate zsh)"
command -v fzf     >/dev/null && source <(fzf --zsh)

# 3. Класс машины: на сервере не нужны GUI-алиасы и тяжёлый prompt
if [[ -n "${SSH_CONNECTION:-}" ]]; then
  export EDITOR=vim
  PROMPT='%F{red}%m%f:%~ %# '     # имя хоста красным — чтобы не спутать прод с локалью
fi

# 4. Машинно-специфичное и секретное — вне репозитория
[[ -f ~/.zshrc.local ]] && source ~/.zshrc.local

Секреты

Правило одно: в репозитории dotfiles не должно быть ни одного секрета, даже в приватном. Приватные репозитории становятся публичными, попадают в бэкапы и клонируются на чужие машины. Три рабочих механизма:

# 1. Разделение конфигов git по каталогу — рабочая почта отдельно от личной
# ~/.gitconfig (в репозитории):
[user]
    name = Имя Фамилия
    email = personal@example.com
[includeIf "gitdir:~/work/"]
    path = ~/.gitconfig-work        # НЕ в репозитории: рабочий email, ключ подписи
[include]
    path = ~/.gitconfig.local       # НЕ в репозитории: всё машинно-специфичное
# 2. Шифрование в репозитории через age/sops — если хранить всё-таки надо
age-keygen -o ~/.config/age/key.txt              # ключ живёт вне репозитория
age -R ~/.config/age/recipients.txt -o secrets/env.age secrets/env
# при развёртывании:
age -d -i ~/.config/age/key.txt secrets/env.age > ~/.env.local
# 3. Лучший вариант: секретов нет вовсе, они подтягиваются из менеджера паролей
export GITHUB_TOKEN="$(op read 'op://Private/GitHub/token')"   # 1Password CLI
# Ленивая версия — не дёргать менеджер при каждом старте оболочки:
gh_token() { op read 'op://Private/GitHub/token'; }

Плюс базовая гигиена: .gitignore с *.local, *.env, id_*, и хук pre-commit с gitleaks — он ловит случайно закоммиченные токены до того, как они уедут на сервер.

7. Bootstrap: от пустой машины до рабочей

Ключевой вопрос к любым dotfiles: сколько времени и команд от свежей ОС до готовой среды, и проверялось ли это хоть раз? У 95 % репозиториев ответ «не знаю».

Три развилки на схеме — это и есть три главных требования к установщику: идемпотентность, никакого молчаливого удаления и проверка в CI на чистом образе.

Третий пункт — как раз то, что в разбираемом репозитории сделано хорошо и чего почти нигде нет: там есть Dockerfile, docker-compose.yml, ci/test-install.sh и GitHub Actions, собирающий образ и публикующий его в ghcr.io. То есть установщик реально запускается на пустой системе при каждом пуше. Плюс make test-dry-run и make lint (bash -n) — дешёвая проверка синтаксиса, которая ловит опечатки до запуска.

Минимальный вариант такого CI для своих dotfiles:

# .github/workflows/bootstrap.yml — проверяем установку на чистых образах
name: bootstrap
on: [push, pull_request]

jobs:
  install:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        image: ["ubuntu:24.04", "debian:12", "archlinux:latest"]
    container: ${{ matrix.image }}
    steps:
      - uses: actions/checkout@v4
      - name: Установка с нуля
        run: ./install.sh --non-interactive
      - name: Повторный запуск должен быть безвредным
        run: ./install.sh --non-interactive     # проверка идемпотентности
      - name: Оболочка стартует и укладывается в бюджет
        run: |
          zsh -i -c 'exit'
          start=$(date +%s%N); zsh -i -c exit; end=$(date +%s%N)
          ms=$(( (end - start) / 1000000 ))
          echo "старт zsh: ${ms} мс"
          [ "$ms" -lt 500 ] || { echo "бюджет 500 мс превышен"; exit 1; }
      - name: tmux поднимает сессию
        run: |
          tmux new-session -d -s ci && tmux has-session -t ci

Такой пайплайн отвечает на вопрос «а мой setup вообще ставится?» — автоматически и навсегда.

8. Удалённая работа: где держать сессию

Работа на удалённой машине ставит один архитектурный вопрос: где живёт состояние? Ответов три, и они не эквивалентны.

Модель Состояние Задержка ввода Когда подходит
SSH + tmux на сервере на сервере сеть на каждый символ серверы, прод, долгие задачи
SSH + mosh на сервере локальное эхо, предсказание плохой канал, мобильный интернет
VS Code Remote / devcontainer код на сервере, UI локально UI локальный ежедневная разработка на удалённой машине
Синхронизация файлов (rsync/mutagen) копия локально нулевая когда нужен локальный тулчейн

Про VS Code Remote и devcontainers подробно — в статье о VS Code; здесь важно другое: тот же tmux остаётся полезным внутри всех этих моделей, потому что процесс сборки или миграции всё равно должен переживать разрыв.

Что реально стоит настроить в SSH — переиспользование соединений. Это ускоряет вторую и последующие сессии в разы, потому что TCP-рукопожатие и аутентификация не повторяются.

# ~/.ssh/config
Host *
    # Мультиплексирование: одно TCP-соединение на все сессии к хосту.
    # git push, scp, вторая панель tmux — всё едет через уже открытый канал.
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

    # Быстрое обнаружение обрыва вместо зависания на 15 минут
    ServerAliveInterval 20
    ServerAliveCountMax 3

    # Не отдавать все ключи подряд каждому серверу
    IdentitiesOnly yes
    AddKeysToAgent yes

Host prod-*
    User deploy
    # Проброс агента ТОЛЬКО туда, где вы доверяете root-у машины:
    # админ хоста может использовать ваш агент, пока сессия открыта.
    ForwardAgent yes
    # Автоматически заходить сразу в tmux
    RequestTTY yes
    RemoteCommand tmux new-session -A -s main

Host bastion-target
    HostName 10.0.1.15
    ProxyJump bastion.example.com

tmux new-session -A -s main — самая полезная форма команды: подключиться к сессии main, а если её нет — создать. Одна команда вместо связки has-session || new-session.

Вложенные tmux (локальный + удалённый) — отдельная боль. Рабочий приём: на удалённых машинах ставить другой префикс (например, C-b, если локально C-a), либо использовать prefix + prefix для передачи наружу через bind C-a send-prefix, что уже есть в конфиге выше.

9. Типичные ошибки

  • Оптимизация не того слоя. Меняют эмулятор ради миллисекунд, не измерив prompt, который стоит 300 мс на каждой команде. Сначала hyperfine и zprof, потом решения.
  • Тяжёлый .zshrc. nvm.sh, brew --prefix, npm root -g, проверки обновлений — каждый пункт умножается на число открываемых панелей за день.
  • Переменные в .zshrc, а не в .zprofile. Работает, пока не запустится неинтерактивная оболочка. Потом «в CI не находит бинарник».
  • Подмена стандартных команд алиасами без сохранения оригинала: молча неполные результаты rg вместо grep, сломанный копипаст, атрофия навыка на чужих машинах.
  • Хардкод путей. /Users/developer, /opt/homebrew без проверки, /root/.cargo/bin — конфиг перестаёт быть переносимым, но узнаёте вы об этом в худший момент.
  • Дублирующиеся плагины. Два тайлинг-плагина tmux, два движка автодополнения — недетерминированное поведение и потерянное время на диагностику.
  • Секреты в репозитории. Даже приватном. Особенно в истории коммитов, откуда их не убрать без переписывания.
  • Установщик, который никто не запускал на чистой машине. Обнаруживается ровно тогда, когда ноутбук умер и настроить новый нужно за час.
  • Неидемпотентные скрипты раскладки. Второй запуск dev создаёт дубликаты окон или падает.
  • Конфиг без комментариев. Через полгода вы не помните, зачем строка, и боитесь её удалить — так и накапливается тысяча строк, где половина мертва.

10. Когда всё это не нужно

Честности ради: терминальный workflow — не универсальная ценность.

Если вы пишете на C# в Rider под Windows, работаете в одной среде с корпоративными политиками и меняете машину раз в четыре года — вложение в tmux и dotfiles окупится плохо. Если основной инструмент — VS Code с devcontainers, окружение уже описано в devcontainer.json, и это правильный уровень абстракции; дублировать его личными dotfiles смысла мало.

Терминальный слой окупается там, где есть хотя бы одно из: несколько машин, регулярная работа по SSH, долгие процессы, которые нельзя терять, частая смена окружений (контейнеры, CI, чужие серверы), или работа в командах, где среду надо воспроизводить для других людей. Если ничего из этого нет — достаточно настроить историю, prompt и не подменять команды алиасами. Это займёт полчаса и даст 80 % пользы.

Мини-итог

  • Терминал — это протокол: pty плюс escape-последовательности плюс terminfo. Отсюда и TERM, и truecolor, и сломанные клавиши по SSH.
  • Оболочка читает разные файлы в зависимости от двух независимых флагов — login и interactive. Переменные в .zprofile, интерактив в .zshrc, ничего печатающего в .zshenv.
  • Запуск оболочки — это бюджет, который тратится десятки раз в день. Измеряйте (hyperfine, zprof), ленивая загрузка менеджеров версий даёт самый большой выигрыш.
  • tmux — сервер состояния, а не вкладки. Из серверной модели следуют и живучесть при обрыве, и attach с двух машин, и скриптуемые раскладки. set-clipboard on решает проблему копирования через SSH через OSC 52.
  • Rust-утилиты стоят своих денег, но подменять ими стандартные команды через алиасы — скрытый долг: несовместимые флаги, сломанный копипаст, атрофия навыка. Новые имена или command для оригинала.
  • Способ доставки dotfiles — точка на кривой «магия против воспроизводимости»: от симлинков до Nix. Требования к любому: идемпотентность, бэкап вместо удаления, секреты снаружи, проверка в CI на чистом образе.
  • Разбор реального репозитория показал типичный набор: правильная архитектура (данные отдельно от логики, кроссплатформенный установщик, Docker-CI) и накопившиеся дефекты (хардкод $HOME, дублирующиеся плагины, неидемпотентный скрипт сессии, тяжёлый старт оболочки). Оба списка полезны одинаково.

Источники

  • The TTY demystified — Линус Окессон, лучшее объяснение того, как устроены терминалы, pty и сигналы.
  • man tmux — единственный первоисточник по конфигурации; разделы FORMATS и KEY BINDINGS.
  • tmux/tmux CHANGES — история изменений, включая смену дефолта escape-time на 10 мс в 3.4.
  • tmux-plugins/tpm, tmux-resurrect, tmux-continuum — менеджер плагинов и сохранение сессий.
  • tmuxp — декларативные раскладки сессий в YAML.
  • Zellij — альтернативный мультиплексор с дружелюбными дефолтами.
  • Zsh Manual: Startup/Shutdown Files и Bash Reference: Startup Files — нормативное описание матрицы инициализации.
  • danluu.com/term-latency — измерения задержки эмуляторов терминала.
  • junegunn/fzf — документация и рецепты интеграции.
  • BurntSushi/ripgrep FAQ — в том числе про отличия от grep и почему они значимы.
  • chezmoi.io, GNU Stow, nix-community/home-manager — способы доставки конфигов.
  • dotfiles.github.io — каталог практик и примеров.
  • OpenSSH ssh_config(5)ControlMaster, ProxyJump, RemoteCommand; mosh.org — альтернатива для плохого канала.
  • gitleaks и FiloSottile/age — защита от утечки секретов и шифрование в репозитории.
  • the-homeless-god/dotfiles — публичный репозиторий, разбираемый в статье: configs/.zshrc, configs/.config/tmux/.tmux.conf, configs/tmux.sh, configs/tools.json, scripts/install-tools.sh, Makefile и workflow в .github/workflows/.

Что дальше

Мы прошли весь трек: от модальности vi до JetBrains, от Emacs до Zed, и закончили слоем, который переживает любую смену редактора. Осталось свести всё вместе — не в виде «топа», а в виде честной таблицы критериев, где видно, за что именно платят каждым инструментом и какой сценарий выбора куда ведёт.

Сравнение всех редакторов и IDE: критерии, таблица, сценарии выбора

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов