Очистка истории: удалить файл/секрет, filter-repo

Большой бинарник или утёкший пароль попал в репозиторий — учимся вычищать его не из одного коммита, а из всей истории целиком.

Удалить файл одним коммитом мало: он остаётся во всех прошлых коммитах. Полная очистка требует переписать историю инструментами вроде git filter-repo или BFG.

Это самая мощная и самая опасная операция в книге. Она нужна в двух ситуациях: репозиторий распух из-за давно закоммиченного большого файла, либо в историю утёк секрет (пароль, токен, приватный ключ). Обычное git rm тут бессильно.

Почему обычное удаление не помогает

Удалив файл и закоммитив, вы убираете его лишь из будущего. Git хранит снимок состояния в каждом коммите, поэтому файл целиком лежит во всех прежних коммитах — его легко достать из истории:

git rm secret.env
git commit -m "Удалить секрет"
git show HEAD~5:secret.env   # секрет по-прежнему доступен из старого коммита

Для большого файла это значит, что вес репозитория не уменьшится: каждый git clone по-прежнему тащит весь старый бинарник. Для секрета — что он по-прежнему скомпрометирован и доступен любому, у кого есть история.

Сначала — экстренные меры для секрета

Если утёк живой секрет — первым делом отзовите и смените его, и только потом чистите историю.

Это критично: пока вы переписываете коммиты, секрет уже мог быть скачан. Очистка истории не отменяет компрометацию. Поэтому порядок такой: ротация ключа/пароля → инвалидация токена на стороне сервиса → и лишь затем зачистка репозитория, чтобы не светить мёртвый секрет дальше.

git filter-repo — рекомендуемый инструмент

Раньше для этого использовали встроенный git filter-branch, но он медленный и капризный; официально рекомендуют отдельную утилиту git-filter-repo (ставится через pip install git-filter-repo или менеджер пакетов). Перед запуском сделайте резервную копию или работайте на свежем клоне — операция необратима.

Удалить файл из всей истории

git filter-repo --path secret.env --invert-paths

--path задаёт цель, --invert-paths переворачивает смысл: «оставить всё, кроме указанного». Можно удалить целую папку (--path build/ --invert-paths) или по маске.

Вырезать большой файл по размеру

git filter-repo --strip-blobs-bigger-than 10M

Уберёт из истории все blob'ы тяжелее 10 МБ — удобно, когда вы не знаете точное имя распухшего файла.

Заменить текст секрета

Если нужно не удалить файл, а затереть утёкшую строку в нём по всей истории, готовим файл с правилами замены и скармливаем его:

git filter-repo --replace-text secrets.txt
# содержимое secrets.txt:  ghp_REALTOKEN123==>REMOVED

BFG Repo-Cleaner — быстрая альтернатива

BFG — отдельная (Java) утилита, заточенная под две частые задачи: удалить большие файлы и почистить секреты. Она проще filter-repo для типовых случаев и заметно быстрее:

bfg --delete-files secret.env       # удалить файл по имени из истории
bfg --strip-blobs-bigger-than 10M   # выкинуть крупные blob'ы
bfg --replace-text passwords.txt    # заменить секреты на ***REMOVED***

BFG не трогает текущий коммит (HEAD) — предполагается, что в нём вы файл уже удалили обычным способом.

Как это работает под капотом

Любой такой инструмент делает одно: проходит по всем коммитам и пересоздаёт каждый уже без нежелательного содержимого. Раз меняется содержимое — меняется и хеш коммита, а раз меняется коммит — меняются и все его потомки. Иными словами, переписывается вся история, начиная с первого затронутого коммита. Старые объекты остаются в базе как «висячие», пока их не выметет сборщик мусора (его можно поторопить: git reflog expire --expire=now --all && git gc --prune=now).

Публикация: force-push и --force-with-lease

История переписана локально — теперь её надо отправить, перезаписав старую на сервере. Обычный push будет отвергнут (истории разошлись), нужен принудительный. Но --force опасен: он затрёт удалёнку безусловно, в том числе чужие коммиты, которые вы могли не успеть забрать. Поэтому используют предохранитель:

git push --force-with-lease

--force-with-lease отправит, только если удалённая ветка с момента вашего последнего fetch не изменилась. Кто-то успел запушить — push отклонят, и вы не сотрёте чужую работу.

Когда история общая

Переписав опубликованную историю, вы нарушаете золотое правило умышленно — другого пути вычистить секрет нет. Поэтому предупредите команду заранее. После вашего force-push всем коллегам придётся синхронизировать локальные клоны (как правило, заново клонировать или аккуратно сделать rebase поверх новой истории), иначе они вернут удалённый файл обратно. На GitHub помните: данные могли осесть в форках, кешах и Pull Request'ах — для утёкшего секрета ротация важнее любой зачистки.

Частые ошибки

  • Чистят историю, но не меняют секрет. Самая опасная иллюзия безопасности: удаление из репозитория не делает утёкший токен снова секретным. Сначала ротация.
  • Запускают filter-repo без бэкапа. Операция необратима локально. Делайте копию каталога или гоняйте на свежем клоне.
  • Толкают результат через --force вместо --force-with-lease. Голый force легко затирает чужие коммиты на общей ветке.
  • Удалили большой файл, но репозиторий не похудел. Старые объекты ещё висят — нужен прогон сборщика мусора (git gc --prune=now) и время на стороне хостинга.
  • Забывают про форки и кеши. На публичном GitHub секрет мог разойтись по форкам и кешу PR; единственная надёжная защита — отозвать сам секрет.

Итоги

  • git rm убирает файл только из будущего — в прошлых коммитах он остаётся целиком.
  • Полная очистка переписывает всю историю; рекомендуемый инструмент — git filter-repo, быстрая альтернатива для типовых задач — BFG.
  • Для утёкшего секрета порядок строгий: сперва отозвать и сменить секрет, и только потом чистить историю.
  • Перед операцией — резервная копия; после — отправка через git push --force-with-lease, не --force.
  • Перезапись общей истории требует предупредить команду и пересинхронизировать клоны коллег.
Проверьте себя
1. Почему git rm secret.env с последующим коммитом не убирает секрет из репозитория полностью?
Agit rm не удаляет файлы вообще
BФайл остаётся во всех прошлых коммитах и доступен из истории
CНужно ещё нажать кнопку на GitHub
DСекрет переносится в .gitignore
2. Что нужно сделать ПЕРВЫМ делом, если в историю утёк живой секрет?
AЗапустить git filter-repo
BСделать force-push
CОтозвать и сменить сам секрет (ротация)
DУдалить репозиторий
3. Чем git push --force-with-lease безопаснее обычного --force?
AОн работает быстрее
BОн отправит, только если удалённая ветка не менялась с вашего последнего fetch, и не затрёт чужие коммиты
CОн не переписывает историю
DОн не требует прав на запись
4. Какой инструмент сегодня рекомендуют для удаления файла из всей истории git?
Agit rm --cached
Bgit filter-repo (или BFG как быстрая альтернатива)
Cgit reset --hard
Dgit stash drop