[Перевод] origin master или origin/master: как Git хранит ветки в обычном словаре «ключ — значение»

Недавно я заметно поправил свою ментальную модель Git. Возьмём две команды:
$ git fetch origin master
$ git switch -c my-feature origin/master
Понимаете, почему в одной написано origin master, а в другой origin/master? Я — нет, ещё пару недель назад не понимал!
Я представлял Git как контентно-адресуемую базу данных. Git хранит коммиты, коммит определяется хешем своего содержимого, а содержимое коммита — это в основном:
снимок состояния кода в определённый момент, без какой-либо памяти о прошлом;
список (хешей) родительских коммитов.
Этого мне хватало, чтобы читать вывод git log и выбираться из любого запоротого ребейза, не клонируя репозиторий заново. (Примерно половину карьеры я как раз клонировал его заново. Ничего стыдного! Разобраться в Git полезно, но, когда только начинаешь, это не самое важное, чему стоит учиться.)
Теперь я понимаю, что в Git есть не только неизменяемая база с адресацией по содержимому, куда можно лишь дописывать, но и самое обычное изменяемое хранилище «ключ — значение».
У Git есть изменяемый словарь: ключи в нём — строки, значения — объекты из той самой контентно-адресуемой базы. Ключи по соглашению записываются как пути в файловой системе, и содержимое словаря обычно можно посмотреть, просто заглянув в каталог .git/refs:
$ eza -T .git/refs
.git/refs
├── heads
│ ├── make
│ ├── master
│ ├── my-feature
│ └── pbd-adt
├── origin
├── remotes
│ └── origin
│ ├── context-switches
│ ├── gh-pages
│ ├── HEAD
│ ├── make
│ └── master
└── tags
$ cat .git/refs/heads/master
b59148228e52f7c615ead7fdd4e91001994ad50f
$ git show-ref refs/heads/master
b59148228e52f7c615ead7fdd4e91001994ad50f refs/heads/master
Запутывает в этой инфраструктуре ссылок (refs) вот что:
На ней держится множество разных видимых пользователю возможностей Git, но сами ссылки — деталь реализации.
Абстракция немного протекает: в повседневной работе ссылки почти не видны.
В командах Git полно сокращённых записей ссылок и аргументов по умолчанию, поэтому далеко не очевидно, что конкретный аргумент — это ссылка.
И, как обычно, Git любит давать одной вещи несколько имён и называть одним именем разные вещи.
Ветки, теги и git notes — всё это просто ссылки!
Структура становится куда понятнее, если развернуть все сокращения. Исходная команда
$ git fetch origin master
превращается в
$ git fetch \
https://github.com/matklad/matklad.github.io \
refs/heads/master:refs/remotes/origin/master
Первый аргумент fetch (https://...) — адрес удалённого репозитория. Git «звонит» по этому адресу и перекачивает с той машины часть данных по сети.
Второй аргумент — пара строковых ключей (ссылок) вида источник:цель. Источник — ключ в удалённом репозитории, цель — имя локального ключа. В целом fetch просит Git прочитать значение из удалённого репозитория и сохранить его у себя под другим именем.
Чтобы не набирать каждый раз URL репозитория, Git даёт им символические имена, и origin — общепринятое имя для основного удалённого репозитория:
$ git fetch origin \
refs/heads/master:refs/remotes/origin/master
refs/heads/master — полное имя ветки в удалённом репозитории. То есть ветка my-feature — это всего лишь ссылка refs/heads/my-feature. Её могли бы назвать refs/branch/my-feature, но не назвали :)
Точных правил сокращения я не знаю, но в целом Git позволяет писать только хвост имени ссылки:
$ git fetch origin \
master:refs/remotes/origin/master
refs/remotes/origin/master — имя локальной ссылки, куда мы сохраним результат. Казалось бы, естественнее взять локально то же имя, что и в удалённом репозитории, но это работает, только пока удалённый репозиторий один. Если их два (например, ваш форк и оригинальный репозиторий, от которого вы форкнулись), имена ссылок столкнутся. Поэтому ссылки удалённого репозитория foo складываются в отдельное пространство имён refs/remotes/foo. А origin — просто общепринятое имя для того самого удалённого репозитория в простых конфигурациях.
Опять же, естественнее было бы напрямую повторить у себя структуру удалённых ссылок:
refs/ heads/my-branch -> refs/ remotes/origin/ heads/my-branch
но Git выбрасывает лишний компонент heads. Это отображение «убрать heads, добавить remotes/$remote» встроено в Git, и оно сжимает команду до
$ git fetch origin master
Прим. перев.: точнее, отображение задаёт не сам Git, а настройка remote.origin.fetch, которую git clone и git remote add записывают по умолчанию: +refs/heads/*:refs/remotes/origin/*. Если её поменять, git fetch origin master обновит другую ссылку.
Стоит задуматься, почему всё устроено именно так. Модель Git рассчитана на работу без сети. Более того, она предполагает явные точки синхронизации. Git не синхронизируется с удалённым репозиторием в фоне, когда есть связь: чтобы байты ушли по проводу, нужно явно выполнить fetch или push. В такой парадигме полезно хранить у себя модель того, в каком состоянии была удалённая сторона, когда мы с ней в последний раз говорили. Теория разума, только для репозиториев!
Надеюсь, это проясняет, чем в Git локальные ветки отличаются от удалённых. Возьмём ветку main. В удалённом репозитории origin она существует как refs/heads/main. Когда вы синхронизируете свой локальный репозиторий с origin, у вас появляется refs/remotes/origin/main — ваше последнее знание о том, в каком состоянии main на origin.
А ещё есть ваша локальная refs/heads/main. Обычно она сначала указывает на тот же коммит, что и refs/remotes/origin/main. Но когда вы делаете коммит, refs/heads/main сдвигается вперёд, а refs/remotes/origin/main остаётся на месте.
Когда вы пытаетесь запушить свой коммит в origin, а ветка main на origin за это время ушла вперёд, вы получите конфликт. Тогда Git сам обновит refs/remotes/origin/main (это ведь всего лишь локальное зеркало удалённого состояния), а вот обновить refs/heads/main и запушить снова — уже ваша забота.
Прим. перев.: здесь автор неточен. Отклонённый push ничего не скачивает, и refs/remotes/origin/main остаётся прежней: мы проверили это в контейнере с Git. Чтобы увидеть новые коммиты с origin, сначала нужен git fetch (или git pull), и только потом rebase или merge.
Вернёмся к исходному примеру:
$ git fetch origin master
$ git switch -c my-feature origin/master
Первая команда находит в .git/config URL удалённого репозитория origin и отправляет запрос на ту машину. В результате локальная refs/remotes/origin/master начинает указывать на тот же коммит, что и refs/heads/master на удалённой стороне (а сам коммит со всеми предками при этом скачивается к вам).
Вторая команда создаёт ссылку refs/heads/my-feature (то есть ветку), начальная точка которой — refs/remotes/origin/master. Тут абстракция как раз и протекает.
Второй аргумент здесь — (сокращённая) ссылка, поэтому можно написать и так:
$ git switch -c my-feature \
refs/remotes/origin/master
А вот первый аргумент, хотя и создаёт ссылку, сам ссылкой не является. Иначе говоря, если развернуть и его
$ git switch -c refs/heads/my-feature \
refs/remotes/origin/master
получится
refs/heads/refs/heads/my-feature
Вот и всё! Не уверен, что это особенно полезно, но, может быть, хотя бы интересно!
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.