Терминальный рабочий процесс: tmux, оболочка, dotfiles, синхронизация окружения
Редактор — это одно окно. Работа программиста — это цикл: прочитать код, изменить, собрать, запустить, посмотреть логи, залезть в базу, перезапустить, повторить. Редактор занимает в этом цикле от силы половину времени, а всё остальное живёт в терминале — и настроено обычно существенно хуже, потому что «это же просто консоль».
Это дорогая ошибка. Настройка редактора выгорает вместе с модой на редакторы: вы вложились
в Vim, потом в VS Code, потом в Zed, и каждый раз начинали заново. А терминальный слой
переживает все эти миграции: zsh, tmux, ssh и набор утилит одинаково работают под
любым редактором, включая тот, который вы будете использовать через пять лет. Это самая
долгоиграющая инвестиция во всём треке.
Разбираться будем сверху вниз по слоям: что такое терминал на самом деле, чем оболочка отличается от языка скриптов, зачем мультиплексор, какие утилиты реально окупаются, а какие создают проблем больше, чем решают, — и как всё это упаковать в репозиторий, который разворачивается на чистой машине за понятное время. Живым примером, как и в остальных статьях трека, служит публичный репозиторий the-homeless-god/dotfiles: там есть и хорошие архитектурные решения, и типовые грабли, на которых удобно учиться.
1. Четыре слоя, которые все путают
Фраза «настроил терминал» скрывает четыре независимых компонента с разными зонами ответственности. Пока они в голове слиты в одно, любая проблема диагностируется гаданием.
стек)) Эмулятор терминала окно, шрифт, лигатуры цвета и тема задержка отрисовки escape-последовательности clipboard через OSC 52 Оболочка разбор командной строки PATH и переменные история и completion prompt алиасы и функции Мультиплексор сессии переживают разрыв окна и панели серверная модель состояния copy-mode Утилиты поиск ripgrep fd fzf просмотр bat eza delta данные jq yq измерение hyperfine Доставка конфигов git-репозиторий симлинки или шаблоны секреты отдельно проверка в CI
Практический смысл разделения виден на типичных багах:
- «Вместо иконок квадратики» — слой эмулятора (шрифт), а не оболочки.
- «
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. Оболочка: программа с бюджетом запуска
Матрица инициализации
Самый частый и самый недооценённый источник багов — то, какие файлы читает оболочка. Флагов два, они независимы, и комбинаций четыре.
Из матрицы следует правило, которое стоит записать и не нарушать:
~/.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 — это отдельный серверный процесс, который владеет псевдотерминалами и процессами, а ваш терминал только подключается к нему как дисплей.
Из этой модели напрямую следуют все полезные свойства: сессия переживает закрытие ноутбука и обрыв 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 — самая недооценённая строка в конфиге. Механизм:
(локально) participant S as sshd → tmux server
(удалённо) participant P as Панель
(vim, psql, логи) U->>T: prefix + [ , выделение, y T->>S: клавиши как обычные байты S->>P: copy-mode работает НА СЕРВЕРЕ P-->>S: выделенный текст Note over S: set-clipboard on:
tmux формирует
OSC 52 последовательность S-->>T: ESC ] 52 ; c ;
и кладёт в системный буфер T-->>U: Cmd+V вставляет в браузер Note over U,T: Мыши и X11-форвардинга
не потребовалось
Ограничения честные: терминал должен поддерживать 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) и
подключается к ней. Идея абсолютно правильная — окружение как код, а не как ритуал.
Но в реализации есть три поучительные проблемы, которые ловятся при первом же запуске на чистой машине:
- Сессия не создаётся. Скрипт делает
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. - Скрипт не идемпотентен. Второй запуск при уже существующей сессии добавит окна
поверх старых. Правильный паттерн — проверить
tmux has-sessionи просто подключиться. - Команды отправляются в непонятную панель.
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'
Каждый алиас по отдельности понятен, но у набора есть системная цена, и её надо знать заранее:
- Флаги несовместимы.
fd— неfind:find . -name '*.go' -exec grep X {} \;с алиасом просто не запустится.rgне понимаетgrep -E '...' fileв части опций и по умолчанию рекурсивен и уважает.gitignore— то есть молча пропускает файлы, которыеgrepбы нашёл. Это худший класс ошибок: не падение, а тихо неполный результат. - Копипаст из документации ломается. Инструкция из README проекта, статья, ответ на Stack Overflow — всё написано под стандартные команды.
- Мышечная память протухает. На проде, в контейнере, на чужой машине ваших алиасов
нет. Навык
grep/findпри этом атрофирован. alias cd='z'— самый рискованный:cdиспользуется внутри функций и в подстановках, аzoxideпри неоднозначности может увести не туда. У zoxide для этого есть штатный флаг--cmd cd, который делает подмену осознанной, но лучше оставитьzотдельной командой.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 % репозиториев ответ «не знаю».
пакетов?} B -->|нет| C[Установить: Homebrew /
apt / apk / pacman] B -->|да| D C --> D[Установить git и curl] D --> E[Клонировать dotfiles] E --> F[Прочитать декларативный
список: tools.json,
Brewfile, flake.nix] F --> G[Поставить инструменты] G --> H{Идемпотентно?} H -->|нет| H1[Повторный запуск
ломает состояние] --> X[Долг] H -->|да| I[Разложить конфиги:
симлинки или шаблоны] I --> J{Бэкап
существующих?} J -->|нет| J1[Молча затёрли
чужие файлы] --> X J -->|да| K[Подтянуть секреты
из менеджера паролей] K --> L[Проверка: shell стартует,
tmux поднимается,
редактор открывает файл] L --> M{Прогонялось
в CI на чистом
образе?} M -->|нет| M1[Работает только
на вашей машине] --> X M -->|да| N[Готовая среда] X -.-> N style N fill:#2f6a5e,stroke:#4f9c8c,color:#fff style X fill:#6b4a2e,stroke:#a3784f,color:#fff
Три развилки на схеме — это и есть три главных требования к установщику: идемпотентность, никакого молчаливого удаления и проверка в 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: критерии, таблица, сценарии выбора