4.1 Откуда взялись названия
Plumbing - это водопровод. Porcelain - это унитаз. То, что пользователь видит, - фарфор; то, что под ним, - трубы. Метафора из самого исходника Git, она же - в документации.
Разделение появилось не сразу. В первых версиях (2005) Git был
набором низкоуровневых утилит, написанных в основном на C и shell:
git-update-index, git-write-tree, git-commit-tree,
git-read-tree. Команд git add и git commit не было -
коммиты собирали руками из этих кирпичей. Линус Торвальдс сделал
сам движок, а удобную обёртку над ним пилили дальше.
Сейчас порядок такой:
- Plumbing - команды, у которых стабильный, документированный
вход и выход. Они никогда не меняются в смысле формата вывода
- на них завязаны скрипты и сторонние инструменты (GitHub, IDE-плагины).
- Porcelain - команды, которые удобны человеку. Их формат вывода может меняться между версиями Git: добавили цвет, изменили формулировку, переименовали колонку. Парсить их автоматически - плохая идея.
Самый простой способ отличить - git help <команда>. У plumbing
первая строка часто звучит как «low-level command», у porcelain -
«high-level command».
4.2 Как porcelain устроен изнутри
Поучительно посмотреть, во что разбирается одна знакомая команда.
Вот git commit -m "fix typo", разложенный на plumbing:
git commit ─┬─→ git write-tree # из индекса собрать tree
├─→ git rev-parse HEAD # узнать SHA текущего коммита
├─→ git commit-tree # создать commit-объект
│ (tree, -p HEAD, -m "fix typo")
└─→ git update-ref HEAD # передвинуть текущую ветку
Каждая стрелка - отдельный системный вызов в исходнике Git. Если
запустить эти четыре команды руками в правильном порядке,
результат будет ровно такой же, как у git commit -m "fix typo".
В лабе главы это и сделаем.
Аналогично:
git add file.txt→git hash-object -w file.txt+git update-index --add --cacheinfogit checkout main→git update-ref HEAD refs/heads/main+git read-tree -m -ugit log→git rev-list HEAD+git cat-file -p <sha>для каждого
Porcelain - это shell-скрипты, превратившиеся со временем в C. Но идея та же: собрать вызов из мелких операций.
4.3 cat-file - швейцарский нож по объектам
git cat-file - главная команда для чтения объектов. У неё
несколько флагов, и каждый отвечает за свой вопрос.
git cat-file -t <sha> # какой тип у объекта
git cat-file -s <sha> # размер в байтах
git cat-file -p <sha> # содержимое (pretty-print)
git cat-file -e <sha> # есть ли такой объект (exit code)
Pretty-print подбирает форматирование под тип. Для blob - это сырые байты. Для tree - список записей. Для commit и tag - текстовое представление со всеми полями.
# Тип ветки main
git cat-file -t main
# commit
# Содержимое первого коммита
git cat-file -p main
# tree 7e3f9a2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f
# parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# author ...
# committer ...
#
# Сообщение коммита
Префикс хэша не обязан быть полным. Если в репозитории нет
другого объекта с таким же префиксом, достаточно 4–7 первых
символов. Это работает и в porcelain (git show 8d0e41), но
базируется на plumbing-команде git rev-parse.
См. cat-file.
4.4 rev-parse - как Git расшифровывает имена
HEAD, main, main~3, HEAD^^, :/fix typo - это всё имена,
которые в итоге сводятся к одному SHA. Команда, которая делает
перевод, - git rev-parse.
git rev-parse HEAD
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
git rev-parse main~2
# 7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d
Синтаксис коротко:
HEAD- куда сейчас смотрит указатель.<branch>- последний коммит в ветке.<sha>- конкретный коммит по хэшу.<ref>^- родитель. Для merge-коммита^1это первый родитель,^2- второй.<ref>~N- N-й предок по первому родителю.HEAD~3- три коммита назад.<ref>^{tree},<ref>^{commit}- переключение типа. Еслиrefуказывает на tag, а нужен коммит -<tag>^{commit}.:/pattern- последний коммит, в сообщении которого встречаетсяpattern.
rev-parse сам не делает с объектом ничего - только переводит
имя в SHA. Дальше этим SHA пользуются другие команды.
Полезный отдельный режим - --show-toplevel:
git rev-parse --show-toplevel
# /home/user/projects/my-portfolio
Это путь к корню репозитория из любой подпапки. Часто используется в hooks и скриптах.
См. rev-parse.
4.5 ls-tree - заглянуть внутрь tree
Если cat-file -p показывает tree более-менее читаемо, то для
машинной обработки удобнее ls-tree:
git ls-tree HEAD
# 100644 blob 5f7e9c12... README.md
# 100644 blob 8a3f2e91... index.html
# 40000 tree e2b5a91f... images
# 100644 blob b1d4a7e0... style.css
Это содержимое корневого tree из текущего коммита. Если нужно зайти в поддиректорию:
git ls-tree HEAD images/
# 100644 blob 9f8a7b6c... logo.png
# 100644 blob 3e2d1c0b... banner.jpg
Полезные флаги:
-r- рекурсивно, спуститься во все поддеревья.--name-only- только имена файлов.--full-tree- игнорировать текущую рабочую директорию, показать всё дерево из корня репозитория.
Комбинация для полного списка путей в коммите:
git ls-tree -r --name-only HEAD
# README.md
# index.html
# images/logo.png
# images/banner.jpg
# style.css
Это часто нужно скриптам - пройтись по всем файлам коммита, не трогая рабочую копию.
4.6 hash-object и update-index - пишем в индекс руками
Эти две команды вместе делают то, что в porcelain называется
git add. Сначала - создать blob, потом - записать запись в
индекс.
# 1. Создать blob из файла
git hash-object -w README.md
# 5f7e9c121234567890abcdef1234567890abcdef
# 2. Записать запись в индекс
git update-index --add --cacheinfo 100644 \
5f7e9c121234567890abcdef1234567890abcdef \
README.md
Что делает --cacheinfo: добавляет в индекс запись «по пути
README.md лежит blob с таким-то SHA и правами 100644», даже
если самого файла на диске нет. Это плумбинг - он не пытается
прочитать рабочую копию.
После этого git status покажет файл как stagged. Можно
коммитить.
Альтернативный режим - git update-index --add README.md -
эквивалентен git add README.md: прочитает файл с диска, создаст
blob, добавит запись. Но это уже шаг ближе к porcelain.
git update-index ещё умеет:
--remove- убрать запись из индекса.--refresh- пересчитать stat'ы, отметить файлы как неизменённые, чтобыgit statusотработал быстрее.--assume-unchanged <file>- флаг «этот файл не трогай, я знаю что делаю». Удобно для локальных правок в файле, который отслеживается, но менять его в общей истории не нужно.
4.6.1 Подводный камень: assume-unchanged ≠ skip-worktree
Есть два похожих флага у update-index, которые путают.
--assume-unchanged- «я обещаю, что не меняю этот файл». Нужен для производительности на больших репозиториях. Если файл всё-таки изменить - Git может это пропустить, и приgit pullбудут странности.--skip-worktree- «считай, что файла нет в рабочей копии». Используется для локальных override (например, конфига с реальными credentials, который не должен попадать в общий репозиторий).
Оба флага локальные, в репозитории не сохраняются. Снять -
--no-assume-unchanged или --no-skip-worktree.
Канонически правильный способ скрыть локальные файлы - это
.gitignore (если файла нет в репо) или хранить шаблон
(config.example.json) и копировать руками. Эти флаги - для
редких случаев, когда других вариантов нет.
4.7 write-tree и commit-tree - собираем коммит
После того как индекс заполнен, нужны две команды, чтобы из него получился коммит.
git write-tree - собрать из индекса tree-объект и записать
в .git/objects/:
git write-tree
# 7e3f9a2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f
Если в индексе есть подкаталоги, write-tree рекурсивно создаст
все нужные tree-объекты для них. На выходе - SHA корневого tree.
git commit-tree - создать commit-объект на основе tree:
echo "Первый коммит" | git commit-tree 7e3f9a2b
# b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7
Полная форма:
echo "сообщение" | git commit-tree <tree-sha> \
-p <parent-sha> \
-p <second-parent-sha> # для merge-коммитов
Флагов -p может быть несколько (или ни одного - это будет root
commit). Автор и коммиттер берутся из user.name и user.email.
После commit-tree коммит уже создан и лежит в .git/objects/,
но ни одна ветка о нём не знает. Если на него не повесить
ссылку - он будет «висячим» и через какое-то время попадёт под
git gc. Поэтому следующий шаг - update-ref.
4.8 update-ref - двигаем ветки и HEAD
Ветки в Git - это файлы в .git/refs/heads/. У каждого внутри
40-символьный SHA. Двигать ветку - это переписывать этот файл.
Соответствующая plumbing-команда - git update-ref.
git update-ref refs/heads/main b8c9d0e1
Это эквивалентно echo b8c9d0e1 > .git/refs/heads/main, но
делает три полезные вещи дополнительно:
- Проверяет, что такой коммит существует.
- Записывает старое значение в reflog (через это потом работает
git reflog). - Атомарно обновляет файл через временный + rename, чтобы при падении Git не оставалось битого ref'а.
Двигать HEAD - отдельный случай. Если HEAD указывает на ветку
(ref: refs/heads/main), то менять надо саму ветку, а не HEAD.
Если HEAD в detached состоянии (указывает прямо на SHA) - то
git update-ref HEAD <sha> меняет HEAD напрямую.
Принудительно переключить HEAD на другую ветку:
git symbolic-ref HEAD refs/heads/feature
Эта команда меняет тип HEAD (с прямого SHA на ссылку и обратно).
Она и есть основа того, что делает git checkout после того,
как обновит рабочую копию.
4.9 Откуда брать список plumbing-команд
Полный список - в выводе git help -a. Команды разделены на
разделы по семантике. Plumbing - в самом конце, в разделе
«Low-level Commands».
Категорий несколько:
- Manipulators - создают и меняют объекты (
hash-object,write-tree,commit-tree,mktag,mktree).- Interrogators - читают объекты, не меняя (
cat-file,ls-tree,ls-files,rev-list,rev-parse). - Sync - обмен с remote'ом на низком уровне (
fetch-pack,send-pack,upload-pack,receive-pack). - Internal Helpers - служебные (
check-ignore,verify-pack,fsck,gc).
- Interrogators - читают объекты, не меняя (
Обычно из всего этого реально нужны примерно десять команд - именно их мы и разобрали в этой главе. Остальные - на случай специфических задач: реализация Git-сервера, написание pre-receive хука, miграция данных из другой VCS.
Уроки в sandbox
lab-4.1. Реализовать git log через plumbing
Цель - собрать упрощённый аналог git log --oneline из
плумбинг-команд. Скрипт получает имя ветки, проходит по
родителям и печатает по строчке на каждый коммит.
Это упражнение убирает магию из git log: видно, как обход
графа коммитов превращается в цепочку cat-file.
В существующем репозитории (можно использовать тот, что сделали в лабе главы 2) узнай SHA вершины ветки:
git rev-parse HEAD. Запиши его в переменную:cur=$(git rev-parse HEAD).Прочитай commit-объект для этого SHA:
git cat-file -p $cur. В выводе видно строкуparent <sha>(или несколько для merge-коммитов) и сообщение коммита.Извлеки первое сообщение (первая строка после пустой строки):
subject=$(git cat-file -p $cur | sed -n '/^$/,$p' | sed '1d' | head -1). Запиши строку лога:echo "${cur:0:7} $subject".Найди родителя:
parent=$(git cat-file -p $cur | grep '^parent ' | head -1 | cut -d' ' -f2). Если переменная пустая - дошли до корневого коммита, цикл закончился.Заверни всё в while-цикл, в каждой итерации обновляй
cur=$parent. На выходе получишь полный лог.Сверь свой вывод с
git log --oneline. Должно совпадать построчно (различаться может только длина SHA - у тебя 7 символов, уgit logтоже 7 по умолчанию).Прокомментируй каждую plumbing-команду в скрипте: что она делает, какой объект читает или меняет. Это и есть полный конспект главы.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Plumbing - низкоуровневые команды со стабильным выводом. Porcelain - высокоуровневые обёртки для человека, формат вывода может меняться.
- Каждая порцелайн-команда разбирается на цепочку plumbing-вызовов. `git commit` = write-tree + commit-tree + update-ref.
- `cat-file` читает любые объекты по SHA. `-t` показывает тип, `-p` - содержимое, `-s` - размер.
- `rev-parse` переводит имена (HEAD, main~2, :/typo) в SHA. Все porcelain-команды внутри используют его.
- `ls-tree` показывает содержимое tree-объекта, удобно для машинной обработки.
- `hash-object` создаёт blob, `update-index` записывает запись в индекс. Вместе - это `git add`.
- `write-tree` собирает tree из индекса, `commit-tree` создаёт commit-объект, `update-ref` передвигает ветку. Вместе - это `git commit`.
- Для сторонних инструментов и скриптов всегда использовать plumbing - у него стабильный API.
Контрольные вопросы
В чём разница между plumbing и porcelain с точки зрения скрипта, который автоматизирует работу с Git?
Показать ответ
У plumbing формат вывода зафиксирован - он не меняется между версиями Git, его можно безопасно парсить. У porcelain формат рассчитан на человека: могут добавлять цвет, переименовывать колонки, менять формулировки. Скрипт, который парсит
git status, рискует сломаться при обновлении Git. Скрипт, который используетgit ls-filesилиgit rev-parse, - не сломается. У части porcelain-команд (в частностиgit status,git push,git blame,git worktree list) есть флаг--porcelain(да, именно так названный) - он переключает их в стабильный формат, пригодный для машин. Общим интерфейсом он не является: у большинства porcelain-команд такого флага нет, и для них надёжнее звать соответствующий plumbing.Что произойдёт, если запустить `git commit-tree`, но не вызвать `git update-ref` после?
Показать ответ
Коммит-объект будет создан и записан в
.git/objects/, но ни одна ветка о нём не узнает. Это так называемый «висячий» (dangling) коммит. Найти его можно черезgit fsck --lost-found. Если в течение времени жизни reflog (по умолчанию 30 дней) этот SHA не попадёт ни на одну ссылку, его удалитgit gc. Если же успеть повесить ссылку - например,git branch rescue <sha>- коммит «оживёт» и станет частью истории.Когда `git rev-parse HEAD~3` даст ошибку?
Показать ответ
Если у текущей ветки меньше трёх коммитов в истории по первому родителю. Например, если репозиторий только-только создан и в нём один коммит -
HEAD~1уже даст «unknown revision». Это нормальный exit code (128), и rev-parse выведет ошибку в stderr.Почему `git update-ref` лучше, чем `echo <sha> > .git/refs/heads/main`?
Показать ответ
Три причины. Первая -
update-refпроверяет, что коммит с таким SHA существует; прямая запись может оставить ссылку, которая указывает в никуда. Вторая -update-refпишет в reflog старое значение, без этогоgit reflogне сможет восстановить прошлое состояние. Третья - атомарность:update-refпишет во временный файл и атомарно его переименовывает, поэтому при падении в середине операции ref не окажется в полу-записанном состоянии.Как через plumbing проверить, существует ли в репозитории объект с конкретным SHA, не выводя его содержимого?
Показать ответ
git cat-file -e <sha>. Эта команда не печатает ничего, только возвращает exit code: 0 - объект есть, ненулевой - нет. Используется в скриптах:if git cat-file -e <sha> 2>/dev/null; then ... fi.