Первое, что мы сделали - ввели новые правила. Перед созданием ветки смотреть git status, чтобы не унаследовать чужое. Перед каждым коммитом проверять, на какой ветке стоит HEAD. Правила хорошие, они и сейчас в силе. Но по-настоящему проблему они не решают. Между проверкой и действием всегда есть окно. Агент посмотрел git status, увидел чистое дерево, начал работу, а через двадцать минут соседняя сессия сделала свой checkout. Дисциплина уменьшает вероятность, но не устраняет причину, что дерево одно, а писателей несколько.
Настоящее решение оказалось встроено в Git и называется worktree. Век живи - век учись, как говорится, даже не знал до этого момента о такой фиче гита.
git worktree add _wt/задача-сессии-N -b ветка-сессии-N origin/master
Она создаёт ещё одно рабочее дерево того же репозитория - в подпапке _wt/задача-сессии-N, с полным набором файлов проекта и, главное, со своим собственным HEAD. При этом никакого второго клона не появляется - история, объекты и все ветки остаются общими, физически одними. Новое дерево знает, где лежат эти общие данные, а они держат запись о каждом живом дереве. Занимает это примерно столько, сколько весят файлы проекта, то есть история не дублируется.
При этом переключение ветки в одном дереве больше не может повлиять на другое, потому что HEAD теперь у каждого свой. Каждая сессия сидит в своём каталоге и делает там что хочет. Главный чекаут остаётся стоять на мастере как справочная копия, и его никто не трогает.
Несколько практических нюансов, всплывших в процессе работы. Одну и ту же ветку нельзя чекаутить в двух деревьях сразу - Git прямо откажет и назовёт, какое дерево её держит: fatal: 'feature' is already used by worktree at '…'. Запрет можно снять флагом --force, и я специально попробовал, что тогда будет. Получается так. Коммит из первого дерева двигает ветку, второе дерево оказывается на новом коммите, но с файлами старого состояния, и его git status показывает чужой файл как удалённый. Следующий обычный коммит оттуда этот файл действительно удаляет. То есть без запрета вернулась бы та же гонка, только хуже. В исходной истории работа терялась и её можно было достать из коммита, а здесь она отменяется коммитом, который выглядит совершенно законным. То есть изоляция worktree - это про то, что у каждой ветки ровно один писатель.
Другой нюанс. Дерево после мержа надо убирать командой git worktree remove, а не просто удалять папку, так как иначе Git продолжит держать служебную запись о нём и считать ветку занятой, пока запись не почистить через git worktree prune - сам он до неё может добраться, но нескоро, там свой TTL (время жизни, срок устаревания). И ещё. В новом дереве нет ничего, чего нет в Git - ни .env, ни установленных зависимостей, ни виртуального окружения. Для питона мы обошлись относительным симлинком на окружение основного чекаута, для веба - общей папкой зависимостей. При этом, отсутствие .env неожиданно оказалось полезным - свежее дерево - это то, что видит CI, и однажды именно так мы воспроизвели падение, которое локально не воспроизводилось никак.
После перехода на рабочие деревья класс проблем "коммит ушёл не туда" исчез полностью. Даже не уменьшился, а именно, что исчез. В истории видно, как переключения веток в главном чекауте обрываются в один день, и дальше их просто нет. Мержится по-прежнему несколько десятков PR в день, и нужно понимать, что подход не ускоряет отдельно взятые операции, он делает параллельную работу надёжной, а значит устраняет временные потери на то, чтобы разобраться, почему всё идёт не так, как хочется. Скорость даёт параллельность, а worktree убирает аварии, которыми изначально мы с агентами за эту скорость расплачивалась.
А теперь главный урок всей истории. Worktree изолирует рабочее дерево, но не изолирует окружение. Файлы разъехались, а всё остальное, что сессии делят между собой, осталось общим. И следующие три инцидента пришли именно оттуда.