Git — штука не то чтобы особо сложная, но гибкая. Иногда эта гибкость приводит к забавным последствиям. К примеру, посмотрите на этот коммит на GitHub. Он выглядит как нормальный коммит, но если вы клонируете себе данный репозиторий, то такого коммита в нем не найдете. Потому что это потерянный коммит, более известный как git loose object или же orphaned commit. Под катом — немного про внутренности Git, откуда такое берется и что делать, если оно вам встретилось.

Как 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 дней, чтобы восстановить “ненужный” коммит, который в противном случае будет окончательно удален из репозитория по истечении этого срока.

Что же произошло на GitHub?


Думаю, вы уже догадываетесь. Указанный коммит оказался ненужным: скорее всего, автор сделал rebase. Но GitHub показывает содержимое серверного репозитория, с которого никогда не выполняется команда push. И garbage collector, скорее всего, тоже никто не вызывает. При этом при клонировании такого репозитория Git передает по сети только те коммиты, на которые есть ссылки, а «потерянные коммиты», более известные как loose objects, остаются лежать мертвым грузом на серверной стороне.

Надеюсь, этот небольшой экскурс во внутренности Git сэкономит кому-нибудь ценное время при поиске «пропавших коммитов», на которые ссылается, к примеру, баг-трекер. Если я где-то ошибся или есть замечания — с удовольствием пообщаюсь в комментах.

Комментарии (41)


  1. kyrylo
    03.07.2015 14:35
    +1

    Спасибо, очень интересно!

    Есть вопрос. Я склонировал npm и нашел этот коммит.

    Вот его SHA1: 01f8f5befb33dd3e892d53fe9b9f27f7a9e69cb6
    А вот работающая ссылка на GitHub с этим SHA1: github.com/npm/npm/commit/01f8f5befb33dd3e892d53fe9b9f27f7a9e69cb6

    Как видно, ваш SHA1: 5ff786ae103161465d84ecdfdc5b0cfd8839eac8

    Откуда взялся мой SHA1? GitHub понимает оба значения.


    1. DmitryIvaneychik Автор
      03.07.2015 14:37
      +1

      Второй sha — это как раз коммит, образовавшийся после rebase, копия коммита из топика. Его можно найти по содержимому или комментарию, если есть. Но иногда это бывает не так просто. BTW, а как вы его искали?


      1. kyrylo
        03.07.2015 14:42
        +4

        С помощью git blame.


  1. mejedi
    03.07.2015 14:43
    +1

    А как можно «утянуть к себе» объект из удаленного репозитория, если на него нет ссылок в виде веток/тэгов? Допустим если надо.


    1. DmitryIvaneychik Автор
      03.07.2015 14:44

      На stackoverflow проскальзывали утверждения, что без доступа к серверу — невозможно, гит не передает по сетке loose object'ы. Рекомендовали искать кого-нить у кого такой коммит есть локально. Но на 100% я не уверен. Ведь если есть доступ на запись — наверняка можно как-то соорудить для такого коммита ссылку и запушить с --force?


      1. CaptainFlint
        03.07.2015 15:09
        +3

        Я как-то интересовался этим вопросом. Мне объяснили, что запрет клонирования «потерянных» коммитов сделан намеренно в качестве меры безопасности. Предположим, кто-то случайно закоммитил и запушил скрипт с прописанными там логином-паролем к некоему сервису. Если этот кто-то быстренько откатит коммит и запушит с форсом, то несмотря на наличие на сервере прежнего коммита, его уже никто не увидит и не утащит.

        P.S. Понятное дело, тут речь идёт конкретно о функциональности самого гита как VCS, а не о надстройках типа гитхаба, которые могут выдавать пользователю произвольный коммит по хэшу. Но даже там этот хэш ещё сначала надо откуда-то узнать.


        1. AlexanderG
          04.07.2015 01:23

          Возможно, git clone --bare скопирует и loose-объекты.

          Вообще, странно, что гитхаб не делает housekeeping. Ведро-битов делает с определенной периодичностью и однажды возникла необходимость перезалить репозиторий, убрав все старые коммиты, чтобы они не маячили в интерфейсе (git push --force не помог, новые коммиты налились наравне со старыми). Сейчас уже не вспомню подробностей, но с помощью бубна, plumbing-а и матюков удалось.


          1. CaptainFlint
            04.07.2015 02:27

            Возможно, git clone --bare скопирует и loose-объекты.
            Нет, не склонирует. Я пробовал это.
            Единственная ситуация, когда у меня склонировались висящие коммиты, это локальный клон.


    1. sergey_novikov
      03.07.2015 21:41

      Можно через API создать ветку, указав хеш: developer.github.com/v3/git/refs/#create-a-reference


  1. CaptainFlint
    03.07.2015 15:03
    +6

    Эта архитектурная особенность породила мутное высказывание, что Git отслеживает переименование по содержимому файла. При переименовании объект «коммит» будет содержать ссылку на объект «содержимое файла», но если содержимое не изменилось, то это будет ссылка на объект, уже имеющийся в хранилище.
    Это не мутное высказывание, а вполне себе корректное. Если переименовать файл и немного отредактировать, то гит всё равно будет определять его как переименованный, хотя хэш уже другой. Можно даже настраивать граничное количество отличий, до которого файл будет считаться переименованным, а при превышении этого порога отобразится как пара операций «удаление старого файла + создание нового файла».


    1. DmitryIvaneychik Автор
      03.07.2015 15:14
      +2

      А как настройка называется?


      1. CaptainFlint
        03.07.2015 15:55
        +2

        diff.renameLimit
        merge.renameLimit


    1. neolink
      03.07.2015 15:55
      +1

      не будет, этот порог указывается при просмотре diff'ов что как бы намекает


      1. CaptainFlint
        03.07.2015 16:46

        Ну так больше, вроде бы, особо и негде.
        С точки зрения внутренностей гита переименований как бы и вовсе нет. Но для удобства пользователей введён вот такой механизм «угадывания». Соответственно, и используется он только при взаимодействии с пользователем — в списках изменений.


        1. DmitryIvaneychik Автор
          03.07.2015 16:52

          Потому и написал — мутноватое высказывание :). Пока сам не прочел очень внимательно раздел книжки про internals — не мог понять, что все эти люди имели в виду.


        1. neolink
          03.07.2015 17:24

          ну так и svn храня патчи пользователю отдает полные файлы, но говорить что svn хранит полные файлы всех версий не корректно.
          также и наоборот не корректно сказать что git хранит отличия между версиями файлов и вообще как-то отслеживает что меняются файлы


          1. khim
            03.07.2015 17:45
            +1

            Это ещё одно место, когда «в действительности всё совсем не так, как на самом деле». Git, конечно же, не хранит копии всех файлов — иначе бы его репозитории были во много раз больше. Конечно «внутри себя» он использует delta-diff'ы. Но это — детали реализации, которые как бы никого не волнуют. Используются для уменьшения объёма репозитория и больше ни для чего — снаружи всё выглядит так, как если бы каждая версия файла хранилась как отдельная сущность. Никакой истории у файлов нет (история — это последовательность, возможно нелинейная, мгновенных состояний проекта). В SVN же есть понятие «история файла» есть и она активно используется в интерфейсе.


            1. neolink
              03.07.2015 18:52

              > Конечно «внутри себя» он использует delta-diff'ы
              это делает очень не часто и в зависимости от внутренней логики git и то только в процессе запуска gc при формировании паков. и да это скрыто ото всех и никто, в том числе и все что есть в поставке гита, эти «дифы» не видит. Все работают с полными версиями.

              > Никакой истории у файлов нет
              я как бы этого и не утверждал


        1. ZyXI
          03.07.2015 17:28

          Не только. Во?первых, merge. Во?вторых, команды типа rebase, частично использующие тот же код. Mercurial и bazaar сохраняют в истории переименования/копирования именно для того, чтобы merge работал корректно. Git использует угадывание. У обоих вариантов есть достоинства и недостатки, но мне больше нравится то, что в mercurial я могу запротоколировать перемещение, даже если оно сопряжено с переписыванием значительного куска файла, а не то, что я могу забыть указать факт перемещения в git и merge всё равно сработает корректно.


    1. khim
      03.07.2015 17:34

      Нифига это высказывание не корректное. Ибо высказывание с подстрокой «Git отслеживает переименования» априори неверно. Не отслеживает Git ничего! Отслеживание переименований вообще в его парадигму никак не вписывается — это не SVN!

      Это типичный пример случая, когда «в действительности всё совсем не так, как на самом деле». Многим людям кажется, что Git отслеживает перемещения. И они могут многие годы жить с этим заблуждением. Но это совсем не так! Git восстанавливает перемещения с использованием переменных diff.renameLimit и/или merge.renameLimit когда вы используете команды git log или git diff. Немного особняком стоит команда git merge — тут переменные diff.renameLimit и merge.renameLimit могут-таки реально повлиять на результат, но, опять-таки, только на состояние файлов в дереве после объединения нескольких веток. Последующий git log будет заново рассчитывать переименования и может придти к совсем другим выводам.


      1. CaptainFlint
        03.07.2015 17:57

        Ибо высказывание с подстрокой «Git отслеживает переименования» априори неверно. Не отслеживает Git ничего!
        Это всего лишь варианты трактовок слова «отслеживает». Его можно понять как «следит за переименованиями и сохраняет их в базе» — разумеется, это будет неправильно. Но «отслеживает» можно также применять в смысле «в процессе показа истории обрабатывает файлы, отслеживая слаборазличающиеся и маркируя их как скопированные/перемещённые».

        Когда я писал своё сообщение, я подразумевал только второй вариант, и не подумал, что кто-то может воспринимать это слово в другой трактовке. Хотя, возможно, неподготовленному человеку именно «неправильный» вариант придёт на ум раньше. Так что слово технически корректное, но неудачное, это да.


  1. JC_Piligrim
    03.07.2015 15:46

    А существуют ли адекватные DCVS или плагины к существующим, в которых для редактирования «некрасивой истории» в репозитории никаких деструкитвных действий не происходит (как при rebase в git), а добавляется только пачка сервисных коммитов, влияющих только на отображение?


    1. develop7
      03.07.2015 15:57
      +2

      да, Mercurial + плагин evolve. Экспериментальный, конечно, но хотеть чего-то подобного для Git не принято в принципе.


      1. ZyXI
        03.07.2015 17:46

        Мне кажется, хотеть не «не принято», а «не имеет смысла». Git значительно менее расширяем: это mercurial архитектурно может позволить себе дополнения, предполагающие передачу собственных метаданных по сети или добавляющие аргументы к стандартным командам (и даже вставляющие их в вывод hg help). Для git я такого что?то не помню.


        1. khim
          03.07.2015 18:00

          У них просто «подход к жизни» отличается. В Mercurial главное — интерфейс, а как оно там устроено внутри — неважно (и может меняться со временем). Git же в своей сути — это вообще не система контроля версий. Это псевдофайловая система, которая может хранить миллионы связанных в иерархию снапшотов — и всё. Всё остальное — навешано «поверх» этой файловой системы. Новые команды могут добавляться и удаляться, команды могут возникать и исчезать, но единожды сделанный снапшот больше, конечно, меняться не может — отсюда и все следствия.

          Тот факт, что там ничего изменить нельзя меня скорее радует и я вообще не могу себе представить как можно пользоваться DCVS без такого свойства. В случае с Git'ом условный Вася с условным Петей могуг говорить о коммите 5ff786ae103161465d84ecdfdc5b0cfd8839eac8 имея абсолютно уверенность о том, что они говорят об одном и том же объекте: у него совпадает содержимое всех файлов и вся история. Даже если они никогда в жизни до этого не сталкивались и одно и то же изменение (с тем же самым сопутствующим описанием) они сделали независимо! А как можно что-то обсуждать используя систему, где условный Вася и условный Петя могут глядеть на коммит с одной и той же меткой, но видя при этом разную историю или, ещё того хуже, разное содержимое? Тут уже централизация потребуется как ни крути, чтобы понять чья история «правильная»…


          1. JC_Piligrim
            03.07.2015 18:25

            Кажется, вы что-то не то говорите. В Git'е сломать историю можно штатными средствами. Сделал rebase — и всё: «Вася и условный Петя могут видеть при этом разную историю или, ещё того хуже, разное содержимое». Причём можно ещё и весь репозиторий этим сломать, а потом лазить руками искать соответствие хеша коммиту… Хотя да, сами снепшоты при этом не меняются (и это правильно).

            Мой вопрос был в том, есть ли какой инструмент, чтобы менять отображение, но чтобы история при этом никогда не менялась и однажды отправленный коммит был впечатан навеки так, что не выдерешь, как вы и описали. Чтобы можно было изменить её (истори) отображение навешивая коммиты сверху. Хочешь — отключаешь «вьюху» (аналогия с SQL) для дерева коммитов и видишь всё как есть. Хочешь — включаешь «вьюху» и видишь красивую историю, где показано не то, кто как случайно коммитил, забывая про разделение фич и адекватные комменты, находясь «в потоке» и не желая отвлекаться на весь этот гемор, а показана подправленная версия истории, где какие-то коммиты слиты в один, есть нормальные комменты, ветки по-человечески проименованы т.п.


            1. khim
              03.07.2015 18:48
              +1

              В Git'е сломать историю можно штатными средствами.
              Сломать — да, легко. Ну так против rm -rf ни одна DCVS не устоит. А вот изменить — нельзя. С помощь rebase и прочего вы порождаете новую, другую историю — и это хорошо заметно и спрятать это нельзя.

              Хочешь — включаешь «вьюху» и видишь красивую историю, где показано не то, кто как случайно коммитил, забывая про разделение фич и адекватные комменты, находясь «в потоке» и не желая отвлекаться на весь этот гемор, а показана подправленная версия истории, где какие-то коммиты слиты в один, есть нормальные комменты, ветки по-человечески проименованы т.п.
              Ну так создайте себе «красивую» историю в отдельной ветке, кто ж вам мешает? Если вы действительно «случайно коммитили, забывая про разделение фич», то вам придётся довольно сильно историю кромсать, чтобы что-то путное получилось. Поставьте в «реперной точке» ссылки на старую историю, или git tagами их пометьте. Так как git не отслеживает историю, то git diff вы можете сделать между двумя любыми ревизиями проекта, чего должно хватить, чтобы убедиться в том, что вы ничего не потеряли — чего вам ещё нужно?


          1. ZyXI
            03.07.2015 18:47
            +2

            Дополнения могут только добавлять метаданные, а никак не изменять их или изменять хэшируемое содержимое. Даже largefiles (который заменяет файлы?хэши на хэшированное содержимое при некоторых условиях) под это попадает. В хэш git, кстати, включается дата, так что одно и то же изменение сделать практически невозможно.

            И не забывайте про largefiles (точнее, git?овые эквиваленты), submodules и прочие вещи, которые сохраняют в репозитории идентификатор, но не содержимое. Даже в git ни о какой абсолютной уверенности речи быть не может: Вася может иметь git-lfs и получить нормальное содержимое больших файлов, а Петя его не иметь и получить набор хэшей. Или забыть сделать git submodule update.

            Также при использовании коммитов с любых хэшем конечной длины абсолютной уверенности быть не может никаким образом из?за коллизий.

            Хотя, конечно, у дополнений mercurial есть возможности по добавлению коммитов в экспортируемую/импортируемую историю, но, насколько я знаю, такое возможно только с использование дополнительного аргумента к push/pull (я имею ввиду Mercurial Queues со своим аргументом --mq). Помимо коммитов можно добавлять метаданные. И ещё можно запретить делать clone, если нет нужных возможностей: т.е. когда в случае с git отсутствие git-lfs означает, что вы успешно сделаете pull/clone, но checkout потерпит неудачу, оставив вас наедине с недосозданным рабочим деревом и хэшами в git log --patch, в случае с mercurial никакого pull/clone не будет, пока вы не включите largefiles. (Хотя хэши в log --patch будут и с largefiles, и с git-lfs, даже если они установлены.)


        1. develop7
          03.07.2015 20:50

          хотеть не «не принято», а «не имеет смысла»
          именно не принято. навесить на коммит произвольных метаданных можно и в Git (чем вовсю пользуются Fog Creek в своём Kiln Harmony), другой вопрос, что в развитии Git в этом направлении не заинтересованы ни Линус, ни Junio Hamano (maintainer). Ну и приходится юзерам с этим как-то примиряться.


          1. ZyXI
            03.07.2015 23:55

            Всё равно изменить команды из /usr/libexec/git-core не получится, не трогая содержимое данного каталога (а потрогать его не дадут менеджеры пакетов; обходные пути есть, но не слишком удобны). А по метаданным вопрос: они хранят внешние к изменению метаданные? Т.е. те, что не учавствуют в расчёте хэша? Если да, то где? Я из «внешних метаданных» в git знаю только ссылки на объект в .git/refs/ / .git/packed-refs.


      1. JC_Piligrim
        03.07.2015 18:34

        Правильно ли я понял, что он всё-таки меняет историю, а не управляет её отображением? Если так — то это не совсем то, что я имел в виду. История изменений не должна модифицироваться, должны только навешиваться на неё «фильтры», грубо говоря, с опцией «наследования» как в ООП (указание на слияние пачки коммитов в один и т.п.). Отключил фильтр отображения — история показывается «как есть». Включил — показывается красивая и подправленная этими дополнительными коммитами.


        1. ZyXI
          03.07.2015 18:55

          Evolve навешивает метаданные на изменения (которые управляют отображением и показывают, что вы с ними сделали (на какие изменения заменили или, может, вообще удалили их)), создаёт новые изменения (при rebase/graft, strip, конечно, ничего не создаст) и не даёт клиенту/серверу без явного запроса выдать «устаревшие» варианты, также уже имеющиеся «устаревшие» варианты на сервере/клиенте маскируются метаданными, если только они их уже не имеют.

          Т.е. история меняется только путём добавления метаданных и их пересылки, но история «как есть» никому не передаётся и «фильтр отображения» можно поставить только туда, где это «как есть» есть.


        1. develop7
          03.07.2015 20:59

          с evolve новые коммиты только добавляются


  1. JIghtuse
    03.07.2015 19:37
    +1

    Вспомнилась небольшая книга Think Like a Git. Там есть доходчивые пояснения, как работает Git. В частности и нечто подобное описывается.

    1. Коммиты выстраиваются в граф, по которому можно свободно перемещаться при наличии ссылок. Ссылки — это любые указатели на коммит: тэги, локальные и удалённые (remote) коммиты.
    2. Ссылки делают коммиты достижимыми (References makes commits reachable).
    3. Все недостижимые коммиты — не принадлежащие веткам и не помеченные тегами — спустя какое-то время удаляются из истории.

    В данном случае, выходит, на Github можно просматривать недостижимые коммиты. А вот что такие объекты не передаются по сети не знал, хотя это логично — к чему таскать мусор.


    1. Sap_ru
      04.07.2015 15:49

      Т.е. в результате rebase на коммите, имеющем tag, можно создать вечно-потерянный коммит? Т.е. он не будет виден ни в одной ветке, но из-за наличия tag GC его тоже никогда не удалит? Он же так может навсегда «зависнуть». Это грустно. Много разных шаманских скриптов и расширений вешают временные тэгит при rebase и некоторые могут забывать их удалять.


      1. DmitryIvaneychik Автор
        04.07.2015 16:06
        +4

        С точки зрения git это будет совсем не потерянный коммит. Какой же он потерянный, если на него указывает тег? :)


        1. Sap_ru
          06.07.2015 01:02
          +2

          Хорошо этому git :) Только пользователь может и не знать, что в результате работы какого-нибудь шаманского скрипта, делающего rebase, у него «завис» один или несколько коммитов :) Кстати, не подскажите, какой-нибудь простой путь это проверять — посмотреть коммиты, которые не имеют ссылок или имеют ссылки только в виде тэгов?


          1. DmitryIvaneychik Автор
            06.07.2015 06:15
            +2

            Посмотреть loose objects:

            git fsck --unreachable --no-reflogs

            А вот с тегами не уверен. У fsck есть аргумент --tags, но что именно он делает — хз.


            1. Sap_ru
              06.07.2015 10:09

              Ну, дальше уже понятно куда копать. Спасибо. Тут главное знать с чего начать :)


          1. neolink
            06.07.2015 12:56

            если вы натворили бед, то есть git reflog, это аналог лога транзакций в СУБД. тамже можете найти с какого коммиты вы начали делать ребейз и т.п.


      1. ZyXI
        04.07.2015 20:29

        С точки зрения GC и вообще многих частей git ссылки из refs/heads и refs/tags не отличаются никак. В том числе это частично касается git pull и push: вы можете затянуть/обновить/удалить тёг точно так же, как и ветку (частично — потому что ветки обрабатываются особым образом, особенно текущая ветка).