1.2Ссылки и граф коммитов
Проследишь, как branch, tag и HEAD дают имена commit objects и как родители образуют граф. Разберёшь detached HEAD, first parent и значения диапазонов log и diff.
- Что физически стоит за веткой и почему файла
.git/refs/heads/mainиногда нет. - Кого двигает коммит: ветку, а не HEAD. И куда деваются коммиты, сделанные в detached HEAD.
- Чем
HEAD~2отличается отHEAD^2и зачем у merge-коммита порядок родителей. - Что покажут
git log main..featureиgit log main...featureи почемуgit diffчитает те же точки по-своему.
Ссылка: имя, за которым записан хеш
Примеры главы идут на одном демо-репозитории: Go-сервис shop с ветками main,
retry, timeout и аннотированным тегом v0.1.0. Его граф есть
на схемах ниже. Ветка main лежит в нём обычным файлом:
$ cat .git/refs/heads/main
b6fcc4a26066e71e4a81595bf011dbbea13d9dce
$ git for-each-ref
b6fcc4a26066e71e4a81595bf011dbbea13d9dce commit refs/heads/main
252fe58338e3e41e8fb4f9c489aab27d73b23636 commit refs/heads/retry
f19ab7b1847a76844ca38626faab1ba2164d04b8 commit refs/heads/timeout
33d2d981809887d6f96df33035f58b0320783daf tag refs/tags/v0.1.0
В файле 41 байт: хеш коммита и перевод строки. Списка коммитов ветки там нет, даты создания и автора
тоже. Поэтому ветка создаётся одной записью на диск, а «коммиты ветки» git каждый раз вычисляет
заново, идя от этого хеша по родителям. Тег v0.1.0 смотрит не на коммит, а на объект
тега (tag во второй колонке), как и положено аннотированному тегу. Первый уровень под
.git/refs задаёт вид ссылки:
| Префикс | Что там лежит | Кто двигает |
|---|---|---|
refs/heads/ | локальные ветки | твои commit, merge, rebase, reset |
refs/tags/ | теги | никто: тег по договорённости не переносят (глава 4.3) |
refs/remotes/origin/ | remote-tracking ветки: что твой клон последним узнал о ветках сервера | fetch, push и служебные вроде git remote prune; своим коммитом их не сдвинешь (глава 4.1) |
refs/stash | последняя запись stash | git stash (глава 2.2) |
refs/pull/<n>/head, refs/merge-requests/<iid>/head | голова pull request на GitHub и merge request на GitLab | сервер; в клоне их нет, пока не скачаешь явно |
Короткое имя git разворачивает по правилам из git help revisions. Для main он
проверяет по очереди .git/main (так находятся HEAD и ORIG_HEAD),
refs/main, refs/tags/main, refs/heads/main,
refs/remotes/main и последним refs/remotes/main/HEAD. Тег стоит в списке
раньше ветки, и это вылезает, если завести ветку с именем тега:
$ git branch v0.1.0 retry
$ git switch v0.1.0
warning: refname 'v0.1.0' is ambiguous.
Switched to branch 'v0.1.0'
$ git log --oneline -1 v0.1.0
warning: refname 'v0.1.0' is ambiguous.
5034bfd (tag: v0.1.0) Добавить /health
switch переключился на ветку, а log с тем же именем показал коммит тега, хотя
HEAD стоит на 252fe58. Git предупредил и всё равно выполнил. Однозначно работает полное имя,
heads/v0.1.0 или refs/heads/v0.1.0, и в скриптах лучше писать только его.
Слеш в имени ссылки превращается в каталог, так что ветки feature и
feature/retry вместе не живут: файл и каталог с одним именем в одной папке не лежат (в
формате reftable, о котором ниже, запрет сохранили).
$ git branch feature
$ git branch feature/retry
fatal: cannot lock ref 'refs/heads/feature/retry': 'refs/heads/feature' exists; cannot create 'refs/heads/feature/retry'
Руками в эти файлы писать незачем. git update-ref refs/heads/hotfix 5034bfd создаёт ветку
так же, как git branch. В отличие от echo в файл он берёт блокировку
hotfix.lock и пишет запись в reflog (глава 5.2), а с третьим
аргументом сдвинет ссылку, только если она всё ещё стоит на ожидаемом коммите.
packed-refs: у ветки может не быть файла
Тысячи тегов в виде тысяч крошечных файлов занимают место и медленно читаются. Поэтому
git pack-refs --all складывает ссылки в один текстовый файл .git/packed-refs и
удаляет отдельные файлы. git gc (глава 1.4) делает это сам.
$ git pack-refs --all
$ find .git/refs -type f
$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
b6fcc4a26066e71e4a81595bf011dbbea13d9dce refs/heads/main
252fe58338e3e41e8fb4f9c489aab27d73b23636 refs/heads/retry
f19ab7b1847a76844ca38626faab1ba2164d04b8 refs/heads/timeout
33d2d981809887d6f96df33035f58b0320783daf refs/tags/v0.1.0
^5034bfdb61fd85a2a1ea1b48514fec2bfae1962c
find не нашёл ни одного файла, а git rev-parse main отвечает как раньше.
Строка с крышкой ^ хранит коммит, на который указывает тег строкой выше, чтобы не читать
объект тега при каждом обращении. Теперь закоммитим в ветку после упаковки:
$ git switch -q retry
$ echo "// TODO: пауза между попытками" >> retry.go
$ git commit -qam "Оставить TODO про паузу"
$ find .git/refs -type f
.git/refs/heads/retry
$ grep retry .git/packed-refs
252fe58338e3e41e8fb4f9c489aab27d73b23636 refs/heads/retry
$ git rev-parse retry
f593e345af9ea58c9d28e6329fd383fa16e10295
Коммит заново создал файл ветки, а в packed-refs осталось старое значение. Побеждает файл:
git ищет ссылку сначала в каталоге и только потом в packed-refs. Свежий
git clone вообще пишет ссылки сервера сразу в packed-refs. Так что
cat .git/refs/heads/main годится посмотреть глазами, а скрипт должен читать ссылки через
git rev-parse или git for-each-ref.
Такой формат хранения называется files. С git 2.45 есть второй, reftable:
git init --ref-format=reftable кладёт ссылки и их журналы в двоичные таблицы в
.git/reftable/, а готовый репозиторий переводит git refs migrate (с 2.46).
В .git/HEAD там лежит заглушка ref: refs/heads/.invalid. В Git 3.0 reftable
собираются сделать форматом по умолчанию, git 2.54 всё ещё создаёт files.
Опыты выше меняли демо-репозиторий, поэтому дальше снова берём его исходный вид.
На macOS и Windows files подводит: файловая система там по умолчанию не различает регистр,
и timeout с Timeout для неё один файл:
$ git branch Retry main
fatal: a branch named 'Retry' already exists
$ git pack-refs --all
$ git branch Timeout main
$ git branch -v
Timeout b6fcc4a Перейти на Go 1.27
* main b6fcc4a Перейти на Go 1.27
retry 252fe58 Ограничить число повторов
timeout f19ab7b Брать таймаут из окружения
$ git log --oneline -1 timeout
b6fcc4a (HEAD -> main, Timeout) Перейти на Go 1.27
Пока retry лежит файлом, git честно отказывает. После упаковки файла timeout
нет, проверка проходит, и появляется файл Timeout. Дальше команды расходятся:
git branch -v находит timeout в packed-refs на
f19ab7b, а git log timeout открывает refs/heads/timeout, получает
от файловой системы Timeout и уходит на b6fcc4a. На Linux, где обычно
крутится CI, всё выглядит нормально. Не заводи такие ветки; в формате reftable этой беды нет.
HEAD: ссылка на ссылку
HEAD отвечает на вопрос «где я сейчас». Внутри у него обычно не хеш, а имя ветки. Ссылку,
которая указывает на другую ссылку, называют символической. Посмотри, какой из двух файлов
меняет коммит (перед ним в go.mod поднята версия Go):
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
c189a4cb7594bac819d3382931a81b621d337a1f
$ git commit -am "Перейти на Go 1.27"
[main b6fcc4a] Перейти на Go 1.27
1 file changed, 1 insertion(+), 1 deletion(-)
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
b6fcc4a26066e71e4a81595bf011dbbea13d9dce
.git/HEAD не изменился ни на байт. git commit записал новый коммит, родителем
поставил c189a4c (туда вёл HEAD) и переписал файл ветки, на которую HEAD указывает. Причём
со сверкой: git передаёт старое значение, и если ветку за это время сдвинул другой процесс, чужая
правка не затрётся. Отсюда привычное «ветка растёт»: коммит двигает текущую ветку, а HEAD едет вместе
с ней, потому что смотрит на её имя. git switch timeout устроен наоборот: ветки не трогает,
обновляет индекс и рабочую копию (глава 1.3) и пишет в .git/HEAD строку
ref: refs/heads/timeout.
Символическая ссылка может указывать на ветку, которой ещё нет. В новом репозитории HEAD уже содержит имя ветки, а файла ветки нет, ей не на что указывать:
$ git init -q -b main shop
$ cd shop
$ cat .git/HEAD
ref: refs/heads/main
$ git log
fatal: your current branch 'main' does not have any commits yet
Такую ветку называют unborn, «ещё не рождённой», файл появится с первым коммитом. Есть символическая
ссылка и среди remote-tracking веток: после клонирования refs/remotes/origin/HEAD содержит
ref: refs/remotes/origin/main, ветку сервера по умолчанию. По последнему правилу
разворачивания имён голое origin в команде означает origin/main.
Detached HEAD: коммиты без ветки
Когда в .git/HEAD записан хеш, а не имя ветки, HEAD называют отсоединённым. Так бывает после переключения не на ветку, а на тег, хеш коммита или remote-tracking
ветку. git switch без флага на это не пойдёт:
$ git switch v0.1.0
fatal: a branch is expected, got tag 'v0.1.0'
hint: If you want to detach HEAD at the commit, try again with the --detach option.
$ git switch --detach v0.1.0
HEAD is now at 5034bfd Добавить /health
$ cat .git/HEAD
5034bfdb61fd85a2a1ea1b48514fec2bfae1962c
$ git branch
* (HEAD detached at v0.1.0)
main
retry
timeout
git checkout v0.1.0 отсоединяет HEAD без флагов и только печатает пояснение. Само
состояние ничего не ломает: так смотрят старую версию или гоняют тесты на чужом коммите. Git и сам
отсоединяет HEAD на время rebase и bisect (в git branch это
(no branch, rebasing timeout)), а git submodule update по умолчанию оставляет
подмодуль в detached HEAD на записанном коммите.
Опасно становится после коммита. Механика та же, что на ветке, только двигать некого, и git
переписывает сам .git/HEAD:
$ git commit -m "Попробовать другой роутер"
[detached HEAD 89f82f1] Попробовать другой роутер
1 file changed, 3 insertions(+)
create mode 100644 router.go
$ git status
HEAD detached from v0.1.0
nothing to commit, working tree clean
$ git switch main
Warning: you are leaving 1 commit behind, not connected to
any of your branches:
89f82f1 Попробовать другой роутер
If you want to keep it by creating a new branch, this may be a good time
to do so with:
git branch <new-branch-name> 89f82f1
Switched to branch 'main'
В статусе at сменилось на from: HEAD ушёл с коммита, на который
переключались. После switch main на 89f82f1 не ведёт ни одна ссылка. Объект
цел, но git log --all обходит ссылки и его уже не покажет, а со временем
git gc удалит его совсем, по умолчанию не раньше чем через 30 дней (сроки в главах 1.4 и
5.2).
Пока HEAD на новом коммите, хватит git switch -c router-try: ветка появится прямо здесь, и
HEAD снова станет символическим. Уже переключился, возьми хеш из предупреждения:
git branch router-try 89f82f1. А если предупреждение уже уехало из терминала, коммит найдётся в
git reflog: он помнит каждое перемещение HEAD (подробно в главе 5.2).
История как граф: корень, слияние, достижимость
Коммит хранит хеши родителей и ничего не знает о потомках: связи в истории смотрят назад во
времени. Замкнуть их в цикл нельзя. Хеш родителя входит в содержимое потомка, значит, родитель записан
раньше, а хеш потомка до его записи никому не известен. Такая структура называется направленным
ациклическим графом, DAG. git log --graph рисует его
псевдографикой, новые коммиты сверху:
$ git log --oneline --graph --all
* b6fcc4a (HEAD -> main) Перейти на Go 1.27
| * f19ab7b (timeout) Брать таймаут из окружения
| * 5796c9e Добавить таймаут клиента
|/
* c189a4c Описать запуск в README
* dab01e5 Merge branch 'retry'
|\
| * 252fe58 (retry) Ограничить число повторов
| * a99a9ea Повторять запрос к базе
* | 386ce70 Логировать запросы
|/
* 5034bfd (tag: v0.1.0) Добавить /health
* 2ca63de Создать модуль shop
У 2ca63de родителей нет, это корневой коммит: в
git cat-file -p 2ca63de нет ни одной строки parent. Корней может быть
несколько. git switch --orphan docs начинает ветку без истории, и её первый коммит тоже
станет корнем. Слить две истории без общего предка git откажется
(fatal: refusing to merge unrelated histories), пока не добавишь
--allow-unrelated-histories: флаг нужен, когда объединяют два проекта, начатых независимо.
У dab01e5 родителей два, это merge-коммит:
$ git cat-file -p dab01e5
tree 0afba99949a509b6ddd89a30d6b0d37d11226500
parent 386ce708aaa3781c2d94245ad244b797d0334557
parent 252fe58338e3e41e8fb4f9c489aab27d73b23636
author Аня <anya@example.com> 1788249000 +0300
committer Аня <anya@example.com> 1788249000 +0300
Merge branch 'retry'
Порядок строк parent не случаен. Первым записан коммит, на котором стояла текущая ветка
(386ce70 из main), вторым то, что вливали (252fe58 из
retry). На этом держится git log --first-parent: он идёт только по первым
родителям и показывает main так, будто вся работа в retry уместилась в один
коммит-слияние:
$ git log --oneline --first-parent
b6fcc4a (HEAD -> main) Перейти на Go 1.27
c189a4c Описать запуск в README
dab01e5 Merge branch 'retry'
386ce70 Логировать запросы
5034bfd (tag: v0.1.0) Добавить /health
2ca63de Создать модуль shop
Из того же устройства следует, что коммит не принадлежит ветке. Ветка указывает на одну вершину, а «коммиты ветки» означают всё, что из этой вершины достижимо по родителям:
$ git branch --contains a99a9ea
* main
retry
timeout
$ git branch --contains 386ce70
* main
timeout
a99a9ea делали в retry, но после слияния он достижим и из main,
и из timeout, которая ответвилась позже. А 386ce70 из retry
недостижим: сливали retry в main, не наоборот. В какой ветке коммит
создавали, по графу не узнать, такого поля в объекте нет. Подсказку даёт разве что сообщение
merge-коммита.
~ и ^: сколько шагов и какой родитель
Обе записи ставятся после имени коммита и ведут к предкам, но число после них значит разное.
X~n делает n шагов назад и на каждом берёт первого родителя. X^n делает
ровно один шаг, к n-му родителю. Без числа обе означают единицу, поэтому main~ и
main^ ведут к одному коммиту. Расходятся записи на merge-коммите.
~ шагает по верхней линии первых родителей,
^2 сворачивает ко второму родителю merge-коммита.| Запись | Маршрут от main (b6fcc4a) | Коммит |
|---|---|---|
main~, main^ | шаг к первому родителю | c189a4c |
main~2, main^^ | два шага по первым родителям | dab01e5, merge-коммит |
main~3, main~2^1 | за merge-коммитом к первому родителю | 386ce70 |
main~2^2 | за merge-коммитом ко второму родителю | 252fe58 |
main~2^2~1 | и ещё шаг назад по бывшей ветке retry | a99a9ea |
main^2 | второй родитель у b6fcc4a, которого нет | ошибка |
Длинную запись читай слева направо как маршрут: main~2^2~1 значит «два шага по первым
родителям, свернуть ко второму родителю, ещё шаг назад». ^^ равно ~2, а
^2 на коммите с одним родителем даёт сообщение, которое сбивает с толку:
$ git log main^2
fatal: ambiguous argument 'main^2': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
Про отсутствующего родителя ни слова: git не нашёл такой ревизии и допустил, что main^2
может оказаться путём к файлу. Так же выглядит main~6, если история короче шести шагов.
Крышка умеет и снимать тег: v0.1.0 указывает на объект тега 33d2d98, а
v0.1.0^{} доходит до коммита 5034bfd. Для merge-коммита есть запись, которая
сразу строит диапазон из следующего раздела: dab01e5^- означает
dab01e5^1..dab01e5, то есть слияние и всё, что пришло с влитой веткой:
$ git log --oneline dab01e5^-
dab01e5 Merge branch 'retry'
252fe58 (retry) Ограничить число повторов
a99a9ea Повторять запрос к базе
В zsh с включённой опцией extendedglob крышка участвует в
шаблонах имён файлов, и git show HEAD^ падает ещё в оболочке с
no matches found: HEAD^. Помогает HEAD~ или кавычки.
Диапазоны A..B и A...B
Команды, которые обходят историю (log, rev-list, shortlog),
понимают одиночную ревизию как множество: сам коммит и всё, что из него достижимо.
git rev-list --count timeout отвечает 9, это все коммиты графа, кроме b6fcc4a.
Диапазон задаёт множество вычитанием. A..B сокращает запись ^A B: всё
достижимое из B минус всё достижимое из A, то есть «что есть в B и чего нет в A»:
$ git log --oneline main..timeout
f19ab7b (timeout) Брать таймаут из окружения
5796c9e Добавить таймаут клиента
$ git log --oneline timeout..main
b6fcc4a (HEAD -> main) Перейти на Go 1.27
$ git log --oneline 5034bfd..c189a4c
c189a4c Описать запуск в README
dab01e5 Merge branch 'retry'
386ce70 Логировать запросы
252fe58 (retry) Ограничить число повторов
a99a9ea Повторять запрос к базе
Последний пример ломает привычку читать A..B как «коммиты между A и B». Коммиты
retry не лежат на прямой линии от 5034bfd к c189a4c, но из
c189a4c они достижимы через второго родителя merge-коммита, а из 5034bfd нет.
Пустой вывод тоже ответ: git log main..retry ничего не печатает, потому что всё из
retry уже достижимо из main. На той же проверке построен
git branch --merged main, который здесь выводит main и retry.
A...B даёт симметрическую разность: коммиты, достижимые из A или из B, но не из обоих
сразу. Граница проходит по merge-base (как его ищут, разобрано в главе 3.1).
--left-right помечает сторону каждого коммита, --boundary показывает границу:
$ git log --oneline --graph --left-right --boundary main...timeout
< b6fcc4a (HEAD -> main) Перейти на Go 1.27
| > f19ab7b (timeout) Брать таймаут из окружения
| > 5796c9e Добавить таймаут клиента
|/
o c189a4c Описать запуск в README
$ git rev-list --left-right --count main...timeout
1 2
c189a4c. Снизу 5034bfd..c189a4c, куда попали и коммиты retry.
Слева, в main, один коммит, справа два. Так получаются «ahead» и «behind»:
git status внутри запускает тот же обход --left-right между веткой и её
upstream. Любую сторону диапазона можно опустить, на её место встанет HEAD. В клоне с одним
неотправленным коммитом:
$ git log --oneline origin/main..
65b6085 (HEAD -> main) Добавить TODO
$ git status -sb
## main...origin/main [ahead 1]
git diff понимает точки иначе, потому что сравнивает два снимка, а не множества коммитов.
git diff A..B значит ровно то же, что git diff A B. git diff A...B
сравнивает с B их merge-base и показывает только сделанное в B после развилки:
$ git diff --stat main..timeout
client.go | 17 +++++++++++++++++
go.mod | 2 +-
2 files changed, 18 insertions(+), 1 deletion(-)$ git diff --stat main...timeout
client.go | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
В log три точки шире двух: к коммитам B добавляются коммиты A после развилки. В
diff они, наоборот, выбрасывают всё, что появилось в A после развилки. Слева
go.mod попал в разницу, хотя timeout его не трогала: это коммит
b6fcc4a из main, прочитанный задом наперёд. Pull request на GitHub показывает
diff с тремя точками. Чтобы не путаться, пиши явно: git diff --merge-base main timeout (git
2.30) даёт то же, что git diff main...timeout.
Вопросы
4.git/refs/heads/
или строка в .git/packed-refs. HEAD хранит имя текущей ветки, то есть это символическая
ссылка. Коммит сдвигает ветку, на которую смотрит HEAD, и не трогает сам HEAD. switch
делает наоборот.Что лежит на диске
В .git/HEAD строка ref: refs/heads/main, в файле ветки 41 байт: хеш и
перевод строки. Ни списка коммитов, ни даты создания. «Коммиты ветки» git находит, шагая от хеша по
родителям, поэтому один коммит входит сразу в несколько веток, а удаление ветки удаляет только имя.
По той же причине git branch -d сначала проверяет, влита ли ветка, и откажет с
error: the branch 'timeout' is not fully merged, пока не скажешь -D.
Файла может и не быть: после git gc или git clone ссылки лежат строками в
packed-refs, и скрипту надёжнее спросить git rev-parse.
Что делает git commit
- Собирает из индекса объекты tree. Содержимое файлов уже лежит в blob-ах, их записал
git add. - Записывает объект коммита, родителем ставит коммит, к которому сейчас ведёт HEAD.
- Обновляет ссылку
HEAD, и раз она символическая, запись проходит насквозь вrefs/heads/main. Git делает это транзакцией: берёт блокировкиHEAD.lockиmain.lock, сверяет старое значение, пишет запись в reflog.
Что делает git switch
git switch timeout приводит индекс и рабочую копию к коммиту ветки и пишет в
.git/HEAD строку ref: refs/heads/timeout. Ни одна ветка не сдвигается.
git switch -c fix перед этим создаёт refs/heads/fix на текущем коммите. А
если переключиться на тег или хеш, в .git/HEAD ляжет хеш, и это уже detached HEAD.
.git/HEAD лежит хеш коммита, а не имя ветки. Попадают
туда переключением на тег, хеш или remote-tracking ветку, а ещё git сам отсоединяет HEAD на время
rebase и bisect. Коммит в этом состоянии двигает только HEAD, и после ухода на ветку на него не
остаётся ссылок. Спасает ветка: git switch -c до ухода, git branch имя хеш
после, а хеш хранит reflog.Как туда попадают
git checkout v0.1.0 или git checkout origin/main отсоединяют HEAD без
вопросов, git switch требует --detach. Сам git отсоединяет HEAD на время
rebase и bisect, а git submodule update ставит подмодуль на
записанный коммит, и коммит внутри подмодуля без ветки теряется тем же способом.
Что происходит с коммитом
Коммит создаётся как обычно, но сдвинуть git может только HEAD: в .git/HEAD ложится
новый хеш, ветки стоят на местах. После git switch main на коммит нет ссылок:
git log --all его не показывает, git branch --contains молчит. При уходе
git печатает Warning: you are leaving 1 commit behind вместе с хешем. Объект цел и
удаляется только сборкой мусора, по умолчанию не раньше чем через 30 дней.
Как вернуть
До ухода хватит git switch -c router-try, после нужна ветка на хеше из предупреждения
или из reflog:
$ git reflog -3
b6fcc4a (HEAD -> main) HEAD@{0}: checkout: moving from 89f82f14ffc12e0372b6d8a8729b448b5d61a9a3 to main
89f82f1 HEAD@{1}: commit: Попробовать другой роутер
5034bfd (tag: v0.1.0) HEAD@{2}: checkout: moving from main to v0.1.0
$ git branch router-try 89f82f1
HEAD@{1} через минуту значит другое
Номер в HEAD@{n} отсчитывается от последнего перемещения HEAD, и каждый
switch сдвигает нумерацию. Сразу после ухода HEAD@{1} указывает на
89f82f1, а после ещё одного git switch timeout уже на b6fcc4a.
Бери из reflog хеш, а не номер. И reflog локальный: в чужом клоне твоих перемещений HEAD нет.
~n делает n шагов назад по первым родителям,
^n делает один шаг к n-му родителю. На линейной истории HEAD~2 даёт деда, а
HEAD^2 даёт ошибку: второго родителя нет. Первым родителем merge-коммита записана ветка, в
которую вливали, и на этом держатся --first-parent и revert -m 1.Правило
X~, X^ и X~1 означают первого родителя, X~3 равно
X^^^, а X^2 имеет смысл только у merge-коммита. Записи складываются в
маршрут и читаются слева направо. В демо-репозитории main~2 указывает на merge-коммит
dab01e5:
$ git rev-parse --short main~3
386ce70
$ git rev-parse --short main~2^1
386ce70
$ git rev-parse --short main~2^2
252fe58
$ git rev-parse --short main~2^2~1
a99a9ea
main~3 и main~2^1 совпадают, потому что оба идут по первым родителям.
main~2^2 сворачивает в бывшую ветку retry, и следующий ~1 идёт
уже по ней.
Зачем порядок родителей
git log --first-parentпоказывает основную линию, где каждая влитая ветка выглядит одним коммитом;git diff M^1 Mпоказывает, что слияние принесло в основную ветку, аgit log M^-выводит сам merge-коммит и коммиты влитой ветки;git bisect start --first-parent(git 2.29) идёт только по первой линии и внутрь влитых веток не заходит;git revert -m 1 Mоткатывает слияние относительно первого родителя (глава 5.1).
$ git diff --stat dab01e5^1 dab01e5
retry.go | 13 +++++++++++++
1 file changed, 13 insertions(+)
$ git diff --stat dab01e5^2 dab01e5
log.go | 7 +++++++
1 file changed, 7 insertions(+)
Один коммит, два ответа на вопрос «что изменилось»: для main слияние добавило
retry.go, для retry добавило log.go.
Порядок родителей зависит от того, где запускали merge. Влей main в
timeout, потом перемотай main через fast-forward (глава 3.1), и первым
родителем нового merge-коммита окажется f19ab7b из timeout. После этого
git log --first-parent main идёт через коммиты timeout, а
b6fcc4a, бывшая вершина main, из основной линии пропадает.
log обе записи задают множества коммитов.
A..B даёт «что есть в B и нет в A», A...B даёт достижимое ровно с одной
стороны, граница проходит по merge-base. В diff точки означают концы сравнения:
A..B равно A B, а A...B сравнивает merge-base с B.Две точки
git log A..B можно записать как git log ^A B или git log B --not A.
Это не «коммиты между A и B по времени»: боковая ветка, влитая в B, тоже попадёт в вывод. На практике
git log main..feature показывает, что уедет в merge request, а пустой вывод значит, что
ветка уже влита.
Три точки
git log A...B выводит коммиты, достижимые из A или из B, но не из обоих. С
--left-right видно сторону каждого, а подсчёт по сторонам и есть «ahead/behind» из
git status:
$ git log --oneline --left-right main...timeout
< b6fcc4a (HEAD -> main) Перейти на Go 1.27
> f19ab7b (timeout) Брать таймаут из окружения
> 5796c9e Добавить таймаут клиента
$ git rev-list --left-right --count main...timeout
1 2
В git diff
| Запись | git log | git diff |
|---|---|---|
A B | всё, что достижимо из A или из B | снимок A против снимка B |
A..B | что есть в B и нет в A | то же, что A B |
A...B | обе стороны после развилки | merge-base против B: только работа в B |
git help diff прямо оговаривает, что diff сравнивает две конечные точки и точки в нём не
означают диапазон. Для ревью ветки нужен вариант с тремя точками, его показывает и pull request на
GitHub. Длинная форма того же: git diff --merge-base main feature.
Что стоит запомнить
- Branch - изменяемая ссылка на commit; HEAD обычно символически указывает на текущую ветку.
- Коммит двигает текущую ветку.
- HEAD~2 идёт дважды по first parent; HEAD^2 выбирает второго родителя merge-коммита.
Частые ошибки
- Detached HEAD не теряет новые коммиты сразу, но на них может не остаться ссылки.
- Трёхточечные диапазоны log и diff имеют разные определения.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Прочитай родителей merge
Прочитай graph output. Куда ведут M^1 и M^2?
* 6530c48 (HEAD -> main) M
|\
| * d0d360e (feature) F
* | 6521758 A
|/
* 20d5adf B
$ git rev-parse HEAD^1 HEAD^2 | cut -c1-7
6521758
d0d360e
Ответ и разбор
M^1 указывает на A, линию, где выполняли merge; M^2 указывает на F, влитую feature-ветку. Порядок родителей важен и не равен хронологическому порядку изменений.
Код-ревью
2. Не путай поколения и родителей
В инструкции написано: «HEAD^2 - коммит на два шага назад». Исправь описание и покажи выражение для двух шагов по first parent.
git show HEAD^2
Ответ и разбор
HEAD^2 выбирает второго родителя текущего merge-коммита. Два first-parent шага задаются как HEAD~2 или HEAD^^. На обычном коммите второго родителя нет.
Применение
3. Закрепи detached commit
Ты переключился на хеш, сделал коммит cafe123, затем вернулся на main. Ветка на него не указывает. Как закрепить коммит именем?
Ответ и разбор
Создай ветку на коммите, например git branch rescue cafe123. Detached HEAD означает отсутствие текущей branch-ссылки. Пока reflog удерживает объект, его можно закрепить, но полагаться на долгий срок хранения нельзя.
Применение
4. Выбери диапазон коммитов
Нужно показать коммиты, достижимые из feature, но не из main. Какой запрос log подходит и что вернёт трёхточечная форма?
Ответ и разбор
git log main..feature показывает коммиты, достижимые из feature, но не main. git log main...feature показывает симметрическую разность. Для git diff три точки сравнивают merge-base с правой ревизией.