[Из песочницы] Тайны потерянных коммитов в Git
Git — штука не то, чтобы особо сложная, но гибкая. Иногда эта гибкость приводит к забавным последствиям. К примеру, посмотрите на этот коммит на GitHub. Он выглядит как нормальный коммит, но если вы клонируете себе данный репозиторий, то такого коммита в нем не найдете. Потому что это потерянный коммит, более известный как git loose object или же orphaned commit. Под катом — немного про внутренности Git, откуда такое берется и что делать, если оно вам встретилось.
Репозиторий Git использует простое хранилище типа ключ-значение, где в роли ключа выступает хеш SHA-1, а значение представляет собой контейнер одного из трех типов: описание коммита, описание дерева файлов или содержимое файла. Существуют даже низкоуровневые служебные команды (plumbing) для работы с этим хранилищем как с базой данных:
echo 'test content' | git hash-object -w --stdin
Эта архитектурная особенность породила мутное высказывание, что Git отслеживает переименование по содержимому файла. При переименовании объект «коммит» будет содержать ссылку на объект «содержимое файла», но если содержимое не изменилось, то это будет ссылка на объект, уже имеющийся в хранилище.
Когда разработчик создает коммит, Git помещает в хранилище один объект описания коммита и кучку объектов, описывающих файловую структуру и содержимое файлов. Таким образом, «коммиты» — это связанные между собой объекты Git в хранилище типа ключ-значение.
По умолчанию Git хранит содержимое файлов целиком: если мы поменяли строчку в 100-килобайтном исходнике, то в хранилище будет добавлен объект со всеми 100 килобайтами, сжатыми с помощью zlib. Чтобы репозиторий излишне не распухал, в Git предусмотрен garbage collector, который запускается при выполнении команды push, при этом объекты переупаковываются в pack-файл, который содержит разницу между исходным файлом и следующей ревизией (diff).
В ряде случаев коммит может быть не нужен. Например, разработчик сделал коммит foo, а затем откатил изменение с помощью команды reset. Git устроен таким образом, что не удаляет коммиты сразу же, давая разработчику возможность «вертать взад» даже самые деструктивные действия. Специальная команда reflog позволяет просмотреть журнал операций, содержащий ссылки на все изменения репозитория.
Но «ненужные» коммиты случаются не только при использовании команды reset. К примеру, популярная операция rebase просто копирует информацию о коммитах, оставляя в хранилище «оригинал», который никому уже не потребуется. Чтобы такие «потерянные» объекты не копились, в Git предусмотрен механизм сборки мусора — уже упомянутый выше garbage collector, автоматически вызываемый при выполнении команды push либо вызываемый вручную.
Garbage collector ищет объекты, на которые больше нет ссылок, и удаляет их из хранилища. Огромную роль при этом играет журнал операций reflog: ссылки в нем имеют ограниченный срок жизни, по умолчанию 30 дней для объекта без ссылок и 90 дней для объекта со ссылками. Garbage collector сначала удаляет из журнала reflog все ссылки с истекшим «сроком годности», а затем удаляет из хранилища объекты, на которые больше нет ссылок. Такая архитектура дает разработчику 30 дней, чтобы восстановить «ненужный» коммит, который в противном случае будет окончательно удален из репозитория по истечении этого срока.
Думаю, вы уже догадываетесь. Указанный коммит оказался ненужным: скорее всего, автор сделал rebase. Но GitHub показывает содержимое серверного репозитория, с которого никогда не выполняется команда push. И garbage collector, скорее всего, тоже никто не вызывает. При этом при клонировании такого репозитория Git передает по сети только те коммиты, на которые есть ссылки, а «потерянные коммиты», более известные как loose objects, остаются лежать мертвым грузом на серверной стороне.
Надеюсь, этот небольшой экскурс во внутренности Git сэкономит кому-нибудь ценное время при поиске «пропавших коммитов», на которые ссылается, к примеру, баг-трекер. Если я где-то ошибся или есть замечания — с удовольствием пообщаюсь в комментах.