Массовое переписывание истории: git filter-repo и git replace

Рейтинг: 59.6% · 10 голосов
Самый подробный курс по Git - от первого коммита до объектной базы и packfiles. Терминал и первый репозиторий, ветки и слияние, конфликты, удалённая работа и форджи (GitHub, GitLab), rebase и переписывание истории, спасение кода через reflog и fsck, stash и worktree, bisect и blame, подмодули и монорепо, хуки и подпись коммитов, внутреннее устройство и масштаб. Для новичков и профи. Актуально на 2026 (Git 2.54, дорожная карта 3.0).
Ответить
Аватара пользователя
Pavel_Git
Сообщения: 52
Зарегистрирован: 11 май 2026, 05:31

Массовое переписывание истории: git filter-repo и git replace

Сообщение Pavel_Git »

Оглавление курса (52)
  1. Что такое контроль версий и почему победил Git
  2. Установка Git на Windows, macOS и Linux и первая настройка
  3. Терминал и как спрашивать у Git: выживание в командной строке
  4. Ментальная модель Git: три состояния, коммит-снимок и граф
  5. Первый репозиторий: init, add, commit, status
  6. Просмотр изменений: status, diff и индексация по частям
  7. История проекта: git log, диапазоны и поиск
  8. .gitignore: что не должно попасть в репозиторий
  9. .gitattributes: окончания строк, бинарники и атрибуты
  10. Ветки как указатели: branch, switch и detached HEAD
  11. Слияние веток: fast-forward против трёхстороннего merge
  12. Конфликты слияния: анатомия и уверенное разрешение
  13. Практикум: твой первый день с Git от нуля до push
  14. Удалённые репозитории: remote, fetch, базовый pull и refspec
  15. git push: безопасная отправка и защита от перезаписи
  16. Клонирование: shallow, partial clone, bundle и backfill
  17. GitHub, GitLab и форки: pull request, rulesets и защита веток
  18. Рабочие процессы команды: GitFlow, GitHub Flow, trunk-based
  19. Merge queue и merge trains: безопасное вливание в trunk
  20. Культура code review и Conventional Commits
  21. git reset: три дерева и отмена индексации и коммитов
  22. git revert: безопасная отмена в общей истории
  23. Правка последнего коммита: git commit --amend
  24. git rebase: пересадка истории и золотое правило
  25. Интерактивный rebase: переписать серию коммитов
  26. git cherry-pick: перенос отдельных коммитов
  27. Stacked diffs: цепочка зависимых PR
  28. Массовое переписывание истории: git filter-repo и git replace (вы здесь)
  29. git reflog: журнал, который помнит всё
  30. Восстановление утерянных коммитов, веток и файлов
  31. Откат опасных операций: reset --hard, сломанный rebase, плохой merge
  32. git stash: тайник для незавершённой работы
  33. git worktree: несколько рабочих деревьев одного репозитория
  34. git bisect: бинарный поиск регрессии
  35. git blame, mailmap и археология кода
  36. Теги, релизы, archive и bundle
  37. Подмодули: внешние репозитории внутри проекта
  38. Subtree, монорепозитории и sparse-checkout
  39. Git LFS и большие файлы
  40. Вспомогательные команды: clean, mv, rm, restore
  41. Псевдонимы и кастомизация .gitconfig
  42. Хуки Git: автоматизация на клиенте и сервере
  43. Подпись коммитов и тегов: SSH, GPG и Sigstore
  44. Внутреннее устройство I: объектная база Git
  45. Внутреннее устройство II: ссылки, HEAD, notes и reftable
  46. Внутреннее устройство III: индекс, packfiles, gc и обслуживание
  47. От SHA-1 к SHA-256: переход на новый хеш
  48. Огромные репозитории: partial clone, FSMonitor, Scalar
  49. Git в CI/CD: shallow, кеш, merge_group и безопасность
  50. Куда движется Git: дорожная карта 3.0 и экосистема
  51. Профессиональный setup: собираем рабочее окружение Git
  52. 30+ типичных ошибок Git и как из них выбираться
Боль, которую мы лечим

Представь утро. Кто-то закоммитил в репозиторий файл config/secrets.yml с боевым паролем от базы. Заметили через неделю. За эту неделю было сорок коммитов, три релиза, репо склонировали двадцать человек. Ты удаляешь файл одним коммитом - и думаешь, что закрыл вопрос. Не закрыл. Файл с паролем по-прежнему живёт в истории: git log сорок коммитов назад покажет его целиком, git checkout того коммита вернёт его на диск. История в Git неизменяема по дизайну, и удаление в новом коммите ничего из прошлого не стирает.

Вторая классика. Репозиторий весит 4 гигабайта, клонируется десять минут. Лезешь в потроха и находишь, что год назад кто-то закоммитил дамп базы на 800 мегабайт, потом удалил. Но blob этого дампа намертво вшит в один из старых коммитов, и каждый клон тащит его целиком.

И третья. Юридический отдел просит сменить email автора во всех коммитах: человек коммитил с личной почты, а надо с корпоративной. Или ты хочешь выдрать подкаталог frontend/ в отдельный репозиторий, сохранив только его историю.

Все четыре задачи - это массовое переписывание истории. Не один коммит, а пересборка всей цепочки от корня. Дальше - как это делать правильно инструментом git filter-repo, чем плох старый filter-branch, что такое git replace как безопасная альтернатива, и почему без ротации секрета вся затея бесполезна.

Изображение

Почему git filter-repo, а не filter-branch

Исторически штатным инструментом был git filter-branch. На 2026 он официально не рекомендован. Это не вкусовщина - это позиция самого проекта Git. В документации прямым текстом: безопасность и производительность filter-branch нельзя исправить обратно совместимо, используйте альтернативу вроде git filter-repo. На пути к Git 3.0 команду прячут за флаг готовности к удалению. Уже сейчас при запуске она печатает большое предупреждение и заставляет ставить переменную окружения, чтобы согласиться продолжить.

Что с ним не так на практике. Во-первых, дико медленно: filter-branch на каждый коммит форкает шелл и запускает внешние процессы, на репо в десятки тысяч коммитов это часы. Во-вторых, ловушки на каждом шагу: он не чистит рефлог и оригинальные рефы автоматически (оставляет их в refs/original/), легко получить полупереписанную историю и думать, что всё готово. В-третьих, дефолты небезопасны.

git filter-repo - это сторонний инструмент, один Python-скрипт за авторством Элайджи Ньюрена (он же мейнтейнер Git). Важно: в сам Git он не входит, ставится отдельно. Но именно его рекомендует апстрим. Он на порядки быстрее (один проход через быстрый поток fast-export/fast-import), по умолчанию делает безопасные вещи: чистит оригинальные рефы, прогоняет gc, и - ключевое - он заточен под свежий клон и сопротивляется запуску на репозитории, где есть незакоммиченное или нестандартная конфигурация, чтобы ты не выстрелил себе в ногу.

Установка и первый запуск

filter-repo - один файл, но проще через пакетный менеджер.

Код: Выделить всё

# macOS
brew install git-filter-repo

# Debian/Ubuntu
sudo apt install git-filter-repo

# через pip в любой системе
pip install git-filter-repo

# проверка
git filter-repo --version
После установки он доступен как подкоманда git, то есть пишешь git filter-repo, а не git-filter-repo. Требует Python 3.6+ и Git 2.36+.

Правило номер один, которое инструмент навязывает сам: работай на свежем клоне. filter-repo по умолчанию откажется запускаться, если репозиторий не выглядит как чистый клон, потому что после переписывания старая история не восстанавливается. Делай так:

Код: Выделить всё

git clone --no-local /path/to/repo repo-clean
cd repo-clean
Если очень надо запустить на существующем рабочем репо - есть флаг --force, но это осознанный риск. Лучше клон.

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

Самая частая задача - удалить секрет из git или выкинуть раздутый файл. Делается через --path и --invert-paths.

--path выбирает пути, --invert-paths инвертирует выбор: вместо "оставить только это" получается "удалить это, оставить всё остальное".

Код: Выделить всё

# выкинуть config/secrets.yml из ВСЕЙ истории
git filter-repo --path config/secrets.yml --invert-paths
Вывод на реальном репо выглядит так:

Код: Выделить всё

Parsed 312 commits
New history written in 1.84 seconds; now repacking/cleaning...
Repacking your repo and cleaning out old unneeded objects
HEAD is now at a1f3c8e Fix login redirect
Completely finished after 3.12 seconds.
Разбор. Parsed 312 commits - он прошёл всю историю. New history written - построил новую цепочку коммитов, где этого файла нет ни в одном дереве. Repacking - сразу упаковал и выкинул осиротевшие объекты. Обрати внимание на хеш HEAD: a1f3c8e. Это новый хеш. Раньше тот же коммит имел другой SHA, потому что изменился весь корень дерева, а значит изменились хеши всех коммитов от точки правки до вершины. Это и есть переписывание - старые объекты осиротели, новые получили новые имена.

Несколько путей за раз - повторяй --path:

Код: Выделить всё

git filter-repo --path dumps/ --path build/artifacts.bin --invert-paths
Выделить подкаталог в отдельный репо - наоборот, без инверсии, плюс --path-rename, чтобы поднять содержимое в корень:

Код: Выделить всё

# оставить только frontend/, поднять его в корень нового репо
git filter-repo --path frontend/ --path-rename frontend/:
После этого в репозитории останутся только коммиты, которые трогали frontend/, а сам каталог станет корнем. История папки сохранена, остальное отрезано.

Замена текста и смена автора через mailmap

Иногда секрет нельзя просто удалить файл целиком - пароль зашит в строке конфига, который нужен. Тогда --replace-text заменяет содержимое во всех версиях всех файлов. Готовишь файл правил:

Код: Выделить всё

# expressions.txt
supersecretpassword==>***REMOVED***
regex:AKIA[0-9A-Z]{16}==>AWS_KEY_REMOVED
Слева - что искать (literal или regex:...), справа после ==> - чем заменить.

Код: Выделить всё

git filter-repo --replace-text expressions.txt
Теперь смена email автора. git filter-repo понимает формат mailmap - тот же, что git log и git shortlog. Готовишь .mailmap:

Код: Выделить всё

# .mailmap: Правильное Имя <new@corp.com> Старое Имя <old@personal.com>
Ivan Petrov <ivan@corp.com> <ivan.personal@gmail.com>
Ivan Petrov <ivan@corp.com> ipetrov <ipetrov@laptop.local>
Применяешь:

Код: Выделить всё

git filter-repo --mailmap .mailmap
Он пройдёт все коммиты и перепишет поля author и committer по правилам. Удобно, что тот же файл потом коммитишь в репо - и git shortlog уважает его без переписывания. Для разовой подмены без файла есть --email-callback и более общий --commit-callback, куда можно подсунуть кусок Python и менять что угодно: даты, сообщения, авторов.

BFG Repo-Cleaner как альтернатива

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

Код: Выделить всё

# выкинуть все блобы тяжелее 50 мегабайт
bfg --strip-blobs-bigger-than 50M repo.git

# заменить тексты-секреты из файла
bfg --replace-text passwords.txt repo.git

# после BFG обязательно дочистить:
cd repo.git
git reflog expire --expire=now --all
git gc --prune=now --aggressive
Ключевое отличие в философии: BFG принципиально не трогает последний коммит (защищает твою текущую вершину HEAD), считая, что актуальное состояние ты уже починил руками. Поэтому файл, который всё ещё лежит в HEAD, BFG не уберёт - сначала удали его обычным коммитом. filter-repo такого ограничения не имеет и переписывает всё подряд. Для простого "вычистить большие блобы" BFG быстрее и проще; для тонкой работы с путями, переименованиями и подкаталогами - filter-repo гибче.

git replace: переписать историю, не переписывая её

Иногда переписывание - перебор. Все хеши меняются, все клоны ломаются. Есть хирургически точная альтернатива - git replace. Идея: положить в репозиторий правило подмены одного объекта другим, не трогая сами исторические объекты. Старый объект остаётся на месте, но Git при чтении видит вместо него подменный.

Механика простая и красивая. git replace создаёт ссылку в namespace refs/replace/. Имя ссылки - это хеш заменяемого объекта, содержимое - хеш замены. Всё. Когда Git встречает объект, у которого есть запись в refs/replace, он молча подставляет замену.

Код: Выделить всё

git replace <старый-объект> <новый-объект>
git replace --list          # показать все подмены
git replace -d <старый>     # удалить подмену
Самый ценный режим - --graft, потомок старого механизма grafts (файл .git/info/grafts, ныне устаревший). Он позволяет переопределить родителей коммита, не меняя его содержимое. Классический сценарий: ты сделал shallow clone или импортировал кусок проекта, история обрезана, и есть отдельный архивный репо со старой историей. Хочешь склеить их в одну линию без полного rewrite.

Код: Выделить всё

# у коммита-корня new-root сделать родителем old-tip из архива
git replace --graft <new-root> <old-tip>
После этого git log пойдёт сквозь точку склейки, как будто история всегда была единой. При этом ни один реальный коммит не переписан, хеши не изменились, клоны не сломались. Когда захочешь сделать склейку постоянной - запускаешь git filter-repo, и он запечёт все активные replace-рефы в настоящую историю.

Код: Выделить всё

# превратить виртуальные подмены в реальную переписанную историю
git filter-repo --replace-refs delete-and-add
Расплата за магию: пока живут replace-рефы, Git отключает commit-graph (кэш-ускоритель обхода истории), потому что граф родства стал виртуальным. На больших репо это заметно бьёт по скорости git log и git merge-base. Поэтому replace - инструмент временный и точечный, не способ жить годами.

После переписывания: force-push и обязательная ротация

Вот тут ломаются почти все. После любого rewrite все существующие клоны невалидны. У них старые хеши, у тебя новые. Обычный git push отклонит как non-fast-forward, обычный git pull у коллег устроит чудовищный мердж старой истории с новой.

Заливаешь так:

Код: Выделить всё

git push --force-with-lease origin --all
git push --force-with-lease origin --tags
--force-with-lease вместо голого --force - чтобы не затереть чужие изменения, которые прилетели в удалёнку, пока ты переписывал. lease проверяет, что удалённая ветка всё ещё там, где ты её видел в последний раз.

Все коллеги после этого делают свежий клон. Не pull, не fetch - именно заново clone. Любые их локальные ветки на старой истории надо переносить вручную через rebase --onto или, проще, переделать поверх новой базы.

И теперь главное, что путают новички. Ты переписал историю и убрал пароль. Это не значит, что пароль в безопасности. Во-первых, он уже неделю лежал в публичном (или внутреннем) репо - его могли скопировать, проиндексировать, забрать боты. Во-вторых, на форках, в чужих клонах, в кэшах CI, в зеркалах - старые объекты с секретом по-прежнему существуют, и их хеш не изменился. GitHub вообще может ещё какое-то время отдавать старый коммит по прямому хешу.

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

Типичные грабли
  • Запуск на рабочем репо без клона. Один --force-промах, и восстановить нечего. Всегда свежий git clone --no-local.
  • Думать, что git rm -rf + новый коммит чистит историю. Нет. Старый blob жив в старых коммитах целиком.
  • Забыть про теги. --all пушит ветки, теги пушатся отдельно. filter-repo переписывает и теги, но залить их надо явно.
  • Чистка без ротации. Самая опасная ошибка: секрет считается утёкшим в момент первого коммита, а не в момент обнаружения.
  • filter-branch в 2026. Печатает варнинг, требует переменную, медленный, оставляет refs/original. Бери filter-repo.
  • Долго жить на git replace. Отключённый commit-graph душит производительность. Replace - временный мост, не постоянное жильё.
  • Коллеги делают pull вместо clone. Получают перемешанную старую и новую историю. Только полный новый клон.
Мини-лаба: убрать секрет руками

Повтори по шагам, это пять минут.

Код: Выделить всё

# 1. песочница с "утёкшим" секретом
mkdir leak-lab && cd leak-lab
git init -b main
echo "db_password = hunter2" > secrets.env
git add secrets.env && git commit -m "add config"
echo "app code" > app.py
git add app.py && git commit -m "feature"
git rm secrets.env && git commit -m "remove secrets (naive)"

# 2. докажи что секрет ЖИВ в истории
git log --all --oneline -- secrets.env
git show HEAD~2:secrets.env        # видишь hunter2 - он на месте

# 3. свежий клон для чистки
cd .. && git clone --no-local leak-lab leak-clean
cd leak-clean

# 4. вычистить файл из ВСЕЙ истории
git filter-repo --path secrets.env --invert-paths

# 5. проверь что секрет исчез отовсюду
git log --all --oneline -- secrets.env   # пусто
git log --oneline                         # хеши коммитов изменились
Теперь представь, что hunter2 - боевой пароль. Что ты сделаешь первым? Сменишь пароль в базе. Только потом чистка имеет смысл.

Контрольные вопросы
  • Ты удалил файл с паролем командой git rm и закоммитил. Почему пароль всё ещё в репозитории и как его убрать по-настоящему?
  • Чем git filter-repo принципиально лучше git filter-branch и почему filter-branch не советуют в 2026?
  • В чём разница между git replace --graft и реальным переписыванием через filter-repo? Когда какой подход выбрать?
  • Ты вычистил секрет из истории и сделал force-push. Считается ли инцидент закрытым? Что обязательно сделать ещё?
Итог

Массовое переписывание истории - это пересборка всей цепочки коммитов от точки правки до вершины, после которой меняются все хеши и ломаются все клоны. Штатный инструмент 2026 - сторонний git filter-repo: --invert-paths чтобы удалить файл из истории git, --replace-text для замены секретов, --mailmap для смены автора, --path-rename для выделения подкаталога. filter-branch не рекомендован и уходит из Git 3.0. BFG - быстрая альтернатива для больших блобов. git replace даёт хирургическую подмену объекта без rewrite (особенно --graft для склейки истории), но отключает commit-graph, поэтому он временный. И запомни намертво: после чистки нужен force-with-lease, свежий клон у всех коллег и обязательная ротация утёкшего секрета - хеш с паролем уже разошёлся, и только смена самого пароля закрывает дыру.[/body]
</invoke>
👍4 ❤️ 🔥1 😄 🤔
Аватара пользователя
ReactMain
Сообщения: 1
Зарегистрирован: 30 май 2026, 14:32

Re: Массовое переписывание истории: git filter-repo и git replace

Сообщение ReactMain »

А я думал git rm навсегда удаляет. Сделал лабу - реально hunter2 лежит в C1 как ни в чём не бывало. Аж холодок по спине)
👍1 ❤️1 🔥 😄 🤔
Аватара пользователя
rnb_chiq
Сообщения: 1
Зарегистрирован: 11 май 2026, 06:30

Re: Массовое переписывание истории: git filter-repo и git replace

Сообщение rnb_chiq »

Вопрос про replace --graft: если он не меняет хеши, почему тогда commit-graph отключается? Не до конца уловил, поясните плз
👍 ❤️ 🔥2 😄 🤔
Ответить
← Предыдущая глава
Stacked diffs: цепочка зависимых PR
Следующая глава →
git reflog: журнал, который помнит всё

Все главы курса «Git профессионально: от первого коммита до внутреннего устройства»

Поделиться темой: ✈ Telegram VK
Похожие запросы: git filter-repo: удалить файл из истории

Вернуться в «Git профессионально: от первого коммита до внутреннего устройства»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость