Я опубликовал статью о том, как пережить перенос на новую машину. Неделю спустя выяснилось, что моя собственная настройка уже дала сбой, а починка потребовала git, симлинков, launchd и одного отрезвляющего вечера.
Неделю назад я опубликовал статью про архитектуру рабочего пространства.
Тезис звучал уверенно:
Если моя машина умрёт сегодня, я должен суметь восстановить всё из одной-единственной директории.
Всё под ~/Developer/. Репозитории. Инфраструктура. Данные. Одна директория, полностью переносимая.
Я был этим вполне доволен.
Потом, через несколько дней, я попросил своего AI-ассистента забэкапить папку со скиллами Claude Code в новую структуру.
Он вернулся с тем, чего я не ожидал:
ls: /Users/paurushrai/.claude/skills/: No such file or directoryПапки не существовало.
Но скиллы у меня были. Один самописный, под названием graphify, я его построил, использовал каждый день, и мой глобальный конфиг всё ещё на него ссылался. Так где же он?
/Users/paurushrai/Downloads/mac-restore/dot-claude/skills/graphify/Вот он где. В папке Downloads. Внутри директории под названием mac-restore.
Лежал там с того самого дня, когда я переносился на новую машину. Никогда не восстановленный. Забытый.

Я написал целую статью именно об этом типе сбоя, а моя собственная машина тихо жила в нём всё это время.
🔍 Слепое пятно
Вот что я упустил.
Моя архитектура организовывала всё внутри ~/Developer/. Но конфигурация, из-за которой машина ощущается моей, живёт не там:
- Identity и поведение git,
~/.gitconfig - Настройка shell,
~/.zshrc,~/.zprofile - Глобальные игноры,
~/.gitignore_global - Правила для AI-кодинга,
~/.claude/CLAUDE.md - Кастомные AI-скиллы,
~/.claude/skills/ - И самое незаметное из всего: список установленных пакетов, который не хранится нигде
Всё это разбросано по $HOME. Без версионирования. Без бэкапа. Невидимо для моей прекрасной структуры директорий.
Когда я проверил ~/.gitconfig, чтобы оценить масштаб ущерба, оказалось хуже, чем я думал:
cat ~/.gitconfig
# → no such fileФайла не существовало. На старой машине у меня была настроена маршрутизация почты, личный email для моих проектов, рабочий, для репозиториев компании. Ничего из этого не пережило перенос. Я месяцами коммитил, не замечая этого.
Код мигрировал. Конфигурация, нет.
🔗 Самый старый трюк в Unix
Первым инстинктом было очевидное: создать папку для бэкапа, скопировать туда dotfiles, запушить на GitHub.
Но копии расходятся. В момент, когда появляются две версии .gitconfig, одна из них, ложь, и вы не узнаете, какая именно, пока что-нибудь не сломается.
Решение старше меня самого: симлинки.
Существует только один файл. Он живёт в репозитории. В домашней директории, только указатель на него:
~/.gitconfig ──symlink──▶ ~/Developer/infra/dotfiles/.gitconfig
└── ЕДИНСТВЕННЫЙ настоящий файл
Когда я спросил себя, не придётся ли теперь вручную синхронизировать изменения обратно, ответ оказался тем, на чём держится весь замысел:
Запись идёт через симлинк.
Выполните git config --global, чтобы поменять настройку, git пойдёт по указателю и запишет прямо в файл репозитория. Отредактируйте ~/.zshrc, вы редактируете файл репозитория. Синхронизировать нечего, потому что синхронизировать не из чего. Один файл, два адреса.
Так что именно это я и построил. Репозиторий dotfiles внутри моего инфраструктурного слоя, хранящий настоящие файлы, с идемпотентным install.sh, который восстанавливает каждый симлинк на свежей машине, сперва бэкапя всё, что найдёт, безопасно запускать хоть дважды.
Конфиг git, конфиг shell, глобальные игноры, мои AI-правила, спасённый скилл graphify. Закоммичено. Запушено. Навсегда.
Одно правило, выученное при осмотре того, что чуть не попало в репозиторий: никогда не коммитить секреты. Мой .zshrc теперь подключает ~/.zshrc.local, который git никогда не видит. Репозиторий получает структуру; машина хранит свои секреты.

Я думал, что закончил.
🍺 Homebrew не ведёт дневник
Затем я попытался ответить на простой вопрос: если эта машина умрёт, что мне придётся ставить заново?
У конфига git есть прекрасное свойство, инструмент сам пишет в свой конфигурационный файл, поэтому репозиторий всегда знает правду. У Homebrew таких манер нет. brew install jq устанавливает пакет и не записывает это решение нигде. Список пакетов существует лишь неявно, в виде папок в /opt/homebrew/Cellar.
Homebrew может выгрузить манифест по запросу:
brew bundle dump --global --force
# → writes .Brewfile: brew "gh", brew "git", brew "tree"...Так что я симлинкнул .Brewfile в репозиторий, как и всё остальное. Но была загвоздка, и она не давала покоя:
Brewfile обновляется, только когда вы не забываете его выгрузить.
Вся моя схема строилась на предпосылке, что я забываю. Именно ради этого и существуют симлинки. Манифест, зависящий от моей дисциплины, это манифест, которому три месяца от роду к тому моменту, когда он понадобится.
🧪 Моя первая попытка была ошибочной
Первое решение казалось изящным: обернуть команду brew в shell-функцию. Перехватывать каждую установку и удаление, перевыгружать манифест, авто-коммитить, авто-пушить.
Я это построил. Заработало с первой попытки:
$ brew install jq
...
$ git log --oneline -1
df053cb chore(brew): install jqПрекрасно. Сообщение коммита называет пакет по имени и всё такое.
А потом я посмотрел на это ещё раз и увидел дыру.
Обёртка над shell ловит только команды, набранные в моей интерактивной оболочке. Скрипт установки, ставящий зависимости? Невидим. AI-агент, запускающий brew внутри сессии? Невидим. Любой контекст, не являющийся моим zsh с моим загруженным конфигом? Невидим.
Я автоматизировал интерфейс, когда нужно было автоматизировать событие.
Сама установка пакета и есть событие, и происходит она в файловой системе, а не в моей оболочке.
⚙️ У ОС уже был ответ
Я задался вопросом: можно ли использовать для этого launchd?
launchd, это менеджер служб macOS, та самая штука, что запускает каждый демон в системе, ответ Apple на systemd. Каждый Mac-разработчик его видел. Почти никто не пользуется его лучшей возможностью:
WatchPaths, launchd будет следить за путём на уровне ОС и запустит вашу задачу в тот же момент, когда путь изменится.
<key>WatchPaths</key>
<array>
<string>/opt/homebrew/Cellar</string>
<string>/opt/homebrew/Caskroom</string>
</array>Теперь неважно, кто устанавливает пакет, моя оболочка, скрипт, агент, что угодно. Cellar меняется, ОС замечает, мой скрипт синхронизации запускается:
# sync.sh, весь движок целиком
brew bundle dump --global --force # snapshot packages
git add -A # stage any dotfile drift
git diff --cached --quiet && exit 0 # nothing changed? exit silently
git commit -q -m "chore: auto-sync ${CHANGED}"
git push -q # offline? commits locally, pushes next timeПлюс ежедневный запуск в полдень как подстраховка, и 60-секундная задержка, чтобы установка пяти пакетов давала один коммит вместо пяти.
Затем тест. Установить пакет, ничего больше не трогать, наблюдать:
$ brew install jq
... 8 seconds later, zero shell involvement:
67ba27e chore: auto-sync .Brewfile
→ pushed to GitHub ✅Восемь секунд. Машина сама задокументировала своё изменение, закоммитила его и запушила, пока я просто сидел рядом.

Я удалил shell-обёртку в ту же минуту. Никаких сторонних инструментов, никакого поллинга через cron по таймеру, никакого демона для установки. ОС всё это время уже поставляла готовый ответ.
Как только это заработало для пакетов, следующий шаг стал очевиден: следить и за самими dotfiles. Теперь редактирование .zshrc или моих AI-правил авто-коммитится в течение минуты, с именем файла в сообщении коммита.
🔁 Ловушка, которую я чуть не построил
Одно решение здесь укусит любого, кто станет это строить, так что стоит признаться.
Ленивая версия, следить за корневой директорией репозитория, одна запись слежения покрывает всё.
Но каждый git-коммит изменяет .git/. Следите за корнем репозитория, и вы построили уроборос: изменение запускает агента → агент коммитит → коммит меняет .git/ → что снова запускает агента → бесконечно.
Поэтому агент следит за отдельными файлами, никогда за директорией, содержащей .git. Компромисс: совершенно новый файл в глубокой поддиректории ловится не мгновенно, его подхватывает ежедневный прогон. Это задокументировано в README, и цена честная.
Автоматизация, запускающая саму себя, это тот тип бага, который находят в два часа ночи.
🔔 Учим её говорить
Осталась одна проблема, и она была связана с доверием.
Агент теперь коммитил от моего имени, молча. Молчаливый демон, это демон, которому перестаёшь доверять, или того хуже, о котором забываешь.
Скрипт получил две строчки нативного уведомления macOS:
osascript -e 'display notification "Committed & pushed: .Brewfile" with title "dotfiles sync"'Первый тест: я его вообще не увидел. Баннеры угасают за пять секунд, а я в тот момент смотрел в другую сторону.
Решение оказалось не в коде, а в одной настройке. У уведомлений macOS есть два стиля, и почти никто не меняет тот, что по умолчанию: System Settings → Notifications → Script Editor → Alerts. Уведомления в стиле «Alert» остаются на экране, пока их не закроешь вручную.
Теперь каждая синхронизация ждёт в углу, пока я её не подтвержу, с изменёнными именами файлов в сообщении. История конфигурации моей машины, доставляемая в виде чеков.
И она говорит, только когда что-то реально изменилось, синхронизация без изменений проходит молча. Уведомление, которое приходит каждую минуту, это уведомление, которое ты уже научился игнорировать.
🎭 Параноидальный вопрос
Как раз когда всё заработало, я подумал о вопросе, который ломает большинство подобных настроек:
Что произойдёт, когда я залогинюсь в GitHub под рабочим аккаунтом?
Я покопался и обнаружил, что «залогинен в GitHub» на самом деле состоит из трёх независимых слоёв, которые почти никто никогда не разделял:
Слой 1, кто написал коммит? Решает конфиг git, и точка. Коммит, это локальная запись в файл, GitHub даже не задействован. Моё правило includeIf направляет корпоративные репозитории на рабочий email, всё остальное, на личный. CLI-логины на это никак не влияют.
Слой 2, кому разрешено пушить? Решает учётная запись в связке ключей macOS. И вот в чём хрупкое место: связка хранит credentials по хосту. Два аккаунта, один github.com, второй логин может незаметно перезаписать первый, и внезапно мой агент синхронизации пушит не под тем именем и собирает 403-е.
Слой 3, от чьего имени действует CLI gh? От имени того аккаунта, который сейчас активен. Не имеет отношения к моей синхронизации, которая говорит на чистом git.

Слой 2 был реальной уязвимостью, и решение, одна строка:
git remote set-url origin https://paurushrai@github.com/paurushrai/dotfiles.gitЭто paurushrai@, не email, это синтаксис URL, тот же user@host, что и в ssh. С именем пользователя в URL связка ключей хранит credentials по паре хост + имя пользователя. Отдельные аккаунты, отдельные ящики, перезапись невозможна.
Принцип тот же, что стоит за маршрутизацией почты, и его стоит сформулировать:
Identity должна быть свойством репозитория, а не глобального изменяемого состояния.
🚀 Машина за десять минут
Вот к чему в итоге приходит эта история.
В следующий раз, когда я распакую новый Mac, вся процедура выглядит так:
# 1. Homebrew, then the two bootstrap tools
brew install gh git
gh auth login# 2. Clone the config, link everything, reinstall every package
gh repo clone paurushrai/dotfiles ~/Developer/infra/dotfiles
~/Developer/infra/dotfiles/install.sh
brew bundle --global# 3. Re-authenticate, credentials never migrate, by design
claude loginИ часть, которая меня искренне радует: install.sh не просто восстанавливает конфигурацию. Он же разворачивает и агента launchd.
Новая машина получает не снимок моей настройки. Она получает систему, которая поддерживает мою настройку, и начинает поддерживать сама себя с первой же минуты.
То, что я намеренно не стал сохранять, важно не меньше:
- Credentials, бэкап для секрета, это залогиниться заново. Десять секунд, и так и задумано.
- Кэши, состояние сессий, артефакты сборки, пересобираются естественным образом. Бэкапить их, это накопительство.
- Слой данных, тома Postgres и Redis, это большие, живые, изменяющиеся бинарники. Git, не тот инструмент, а копирование работающей базы данных даёт битый мусор. Данным нужны настоящие снимки-бэкапы, это тема следующей статьи.
🎯 Что я на самом деле усвоил
Урок предыдущей статьи был: ваше рабочее место, это система, спроектируйте её.
Эта неделя научила меня следствию: система, поддерживаемая дисциплиной, деградирует. Система, поддерживающая себя сама, накапливает эффект.
Посмотрите на форму каждого решения в этой истории:
- Я забыл бы скопировать файлы конфигурации → поэтому существует один файл, симлинкнутый, без копий
- Я забыл бы обновить манифест пакетов → поэтому за файловой системой следит ОС
- Я забыл бы закоммитить → поэтому коммитит агент
- Я пропустил бы молчаливые сбои → поэтому уведомления ждут, пока я их не закрою
- Я забыл бы настроить всё это заново в следующий раз → поэтому инсталлятор переустанавливает саму автоматизацию
Каждое решение заменяет «не забыть» на «оно просто делает само».
Тот файл скилла пролежал мёртвым в моей папке Downloads месяцами, потому что его восстановление зависело от того, вспомню ли я о его существовании. Теперь ничто в моей машине не зависит от моей памяти.
И, честно говоря, это лучшая функция, которую я когда-либо выпускал для самого себя.
Как выглядит ваш слой конфигурации?
Репозитории dotfiles, идея не новая. Но большинство из них останавливаются на «файлы в репозитории плюс инсталлятор», а самое интересное начинается там, где нужно, чтобы репозиторий оставался правдой и после первого дня.
Инструменты будут продолжать меняться. Архитектура должна их пережить.


