Middle: питання на співбесіді з теми «Внутрішня будова Git»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Команди Git поділяються на два рівні:
- porcelain («порцеляна») - команди для людей:
commit,switch,merge,log. Їхній вивід і поведінка можуть змінюватися між версіями; - plumbing («сантехніка») - низькорівневі команди, з яких побудовано porcelain: читання й запис об'єктів, оновлення ref-ів. Їхній вивід стабільний - на них розраховані скрипти.
Коміт вручну - те, що робить git commit під капотом:
# 1. записати вміст файлу як blob
echo 'Hello' | git hash-object -w --stdin
# e965047ad7c57865823c7d992b1d046ea66edf78
# 2. додати blob в індекс під ім'ям
git update-index --add --cacheinfo 100644,e965047ad7c57865823c7d992b1d046ea66edf78,hello.txt
# 3. записати індекс як tree
git write-tree
# 7b3ce6f...
# 4. створити коміт з цим деревом і батьком
echo 'Add hello' | git commit-tree 7b3ce6f -p HEAD
# 4a1f2c9...
# 5. пересунути гілку на новий коміт
git update-ref refs/heads/main 4a1f2c9
Робочий каталог при цьому взагалі не використано: файл hello.txt на диску не з'являвся.
Основні plumbing-команди:
| Команда | Що робить |
|---|---|
hash-object |
обчислити хеш, з -w - записати blob |
cat-file |
прочитати об'єкт |
ls-tree, ls-files |
вміст дерева й індексу |
write-tree, commit-tree |
створити tree й коміт |
update-ref, symbolic-ref |
змінити ref безпечно, з записом у reflog |
rev-parse |
перетворити ім'я (гілка, HEAD~2) на хеш |
for-each-ref |
перелік ref-ів у заданому форматі |
merge-tree |
злиття без робочого каталогу |
Де це корисно на практиці:
- скрипти й CI:
git rev-parse --abbrev-ref HEAD,git for-each-ref --format='%(refname:short)' refs/heads/замість розбору виводуgit branch, який може змінитися; - операції без робочого каталогу: сервери Git (GitHub, GitLab) роблять злиття й коміти через
merge-treeіcommit-tree, не створюючи файлів на диску; - розуміння: знаючи plumbing, легко пояснити, що роблять
rebase,stash,cherry-pick, - вони лише комбінують ці кроки.
Порада для скриптів: використовуйте porcelain-команди з --porcelain-виводом (git status --porcelain=v2) або plumbing - і ніколи не розбирайте вивід, призначений для людей.
git cat-file - інструмент для читання будь-якого об'єкта з бази:
git cat-file -t a1b2c3d # тип: blob, tree, commit, tag
git cat-file -s a1b2c3d # розмір у байтах
git cat-file -p a1b2c3d # вміст у зручному вигляді
git cat-file -e a1b2c3d # лише перевірити існування (код виходу)
Подорож від коміту до файлу:
git cat-file -p HEAD
# tree 3c4e9cd...
# parent 84ba035...
# ...
git cat-file -p 3c4e9cd # кореневе дерево
# 100644 blob 1f7a7a4... composer.json
# 040000 tree 8e2b7f1... app
git cat-file -p 8e2b7f1 # дерево каталогу app/
git cat-file -p HEAD:composer.json # одразу вміст файлу в коміті
Blob - лише вміст файлу. У ньому немає імені, прав чи дати: файли з однаковим вмістом у різних місцях і різних комітах - один blob. Перейменування файлу не створює нового blob-а, лише змінює запис у tree.
Хеш blob-а - SHA-1 від заголовка й вмісту: blob <розмір>\0<вміст>. Тому його можна обчислити й без Git:
printf 'Hello\n' | git hash-object --stdin
printf 'blob 6\0Hello\n' | shasum
# обидві команди дають e965047ad7c57865823c7d992b1d046ea66edf78
Tree - відсортований список записів режим тип хеш ім'я. Він відповідає одному каталогу й посилається на blob-и (файли) і інші tree (підкаталоги).
Що випливає зі структури:
- зміна одного файлу в глибокому каталозі створює новий blob, нові tree для кожного каталогу на шляху до кореня й новий коміт. Усі інші tree й blob-и перевикористовуються - коміт великого проєкту з однією зміною займає кілька сотень байтів;
- порівняння двох комітів дешеве: однакові хеші піддерев означають однакові каталоги, і Git не заходить у них;
- ім'я і вміст розділені, тож Git дізнається про перейменування лише порівнянням.
Масова робота: git cat-file --batch і --batch-check читають багато об'єктів за один процес - так працюють інструменти аналізу репозиторіїв (git cat-file --batch-all-objects --batch-check - перелік усіх об'єктів з розмірами, щоб знайти найбільші).
.git/index - бінарний файл, що описує знімок для наступного коміту. Для кожного файлу він зберігає:
- шлях, режим і хеш blob-а вмісту;
- дані
statз файлової системи: розмір, час зміни (mtime, ctime), inode, пристрій, uid/gid; - номер стадії (stage) - для конфліктів злиття.
git ls-files --stage
# 100644 1f7a7a4... 0 composer.json
# 100644 e9c3d2b... 0 app/Models/User.php
git ls-files --debug app/Models/User.php # разом з даними stat
Як git status працює швидко. Обчислювати хеш кожного файлу в проєкті з десятками тисяч файлів було б повільно. Тому Git спершу порівнює дані stat:
- якщо розмір і час зміни файлу збігаються з тими, що в індексі, - файл вважається незмінним без читання вмісту;
- лише для файлів з іншими
statGit читає вміст, хешує й порівнює з blob-ом в індексі; - якщо вміст той самий (наприклад, файл перезбережено без змін), Git оновлює дані
statв індексі, щоб наступного разу не перевіряти.
«Racy git» - файл змінено в ту саму секунду, що й записано індекс: час збігається, хоча вміст інший. Git розпізнає цей випадок і перевіряє такі файли за вмістом.
Стадії при конфлікті: для конфліктного файлу індекс містить до трьох записів - стадія 1 (спільний предок), 2 (наша версія), 3 (їхня). git add після розв'язання замінює їх одним записом стадії 0.
git ls-files -u # конфліктні записи
git show :2:config/app.php # наша версія
git show :3:config/app.php # їхня версія
Прапорці в індексі:
git update-index --assume-unchanged file- обіцянка Git не перевіряти файл (оптимізація для повільних файлових систем);git update-index --skip-worktree file- ігнорувати локальні зміни відстежуваного файлу (локальна конфігурація). Обидва - локальні й не передаються іншим;git ls-files -vпоказує такі файли малими літерами.
Для великих репозиторіїв: core.untrackedCache, core.fsmonitor і index.version=4 (стиснення шляхів) зменшують час status з секунд до мілісекунд.
Пошкоджений індекс («index file corrupt») не означає втрату історії: rm .git/index && git reset відтворить його з HEAD, зберігши файли робочого каталогу.
Refspec - правило відповідності між ref-ами віддаленого репозиторію і вашого локального. Його формат:
+<джерело>:<призначення>
- джерело - ref на тому боці, звідки беремо;
- призначення - куди записати локально;
+- дозволити оновлення без fast-forward (наприклад, після force push на сервері).
Refspec за замовчуванням записується при git clone чи git remote add:
# .git/config
[remote "origin"]
url = git@github.com:acme/shop.git
fetch = +refs/heads/*:refs/remotes/origin/*
Читається так: «усі гілки сервера (refs/heads/*) записати як refs/remotes/origin/*». Звідси й з'являються origin/main, origin/feature-x - це ваші локальні копії стану гілок сервера на момент останнього fetch.
Що можна налаштувати:
# завантажувати лише main і релізні гілки
fetch = +refs/heads/main:refs/remotes/origin/main
fetch = +refs/heads/release/*:refs/remotes/origin/release/*
# додатково отримувати pull request-и GitHub
fetch = +refs/pull/*/head:refs/remotes/origin/pr/*
Після цього git fetch створить origin/pr/142 для кожного PR, і будь-який з них можна переглянути локально.
Refspec у командах:
git fetch origin main # лише main, результат у FETCH_HEAD і origin/main
git fetch origin pull/142/head:pr-142 # PR одразу в локальну гілку pr-142
git push origin feature:review/feature # запушити локальну feature під іншим ім'ям
git push origin :old-branch # порожнє джерело - видалити гілку на сервері
git push origin HEAD:refs/for/main # Gerrit: відправити на рецензію
Push теж використовує refspec: git push origin main - скорочення для main:main. Налаштування push.default визначає, що відправляє git push без аргументів (simple - поточну гілку в однойменну upstream).
Корисні наслідки:
origin/mainоновлюється лише приfetch/pull, а не сам по собі - тому «origin/main відстає від GitHub» лікуєтьсяgit fetch;fetch --prune(чиfetch.prune=true) видаляєorigin/*для гілок, видалених на сервері;- у CI з великим репозиторієм вузький refspec (лише потрібна гілка) суттєво зменшує обсяг завантаження.
Ref - ім'я, що вказує на об'єкт (зазвичай коміт). Класичне зберігання - один файл на ref:
.git/refs/heads/main → 23933defbcb6...
.git/refs/remotes/origin/main → 23933defbcb6...
.git/refs/tags/v2.4.0 → 9fceb02d0ae5...
Просто й наочно, але з тисячами гілок і тегів (типово для великих проєктів і серверів) з'являються проблеми: тисячі дрібних файлів, повільний перелік, а на файлових системах без урахування регістру (macOS, Windows) гілки Feature і feature конфліктують.
packed-refs - багато ref-ів одним текстовим файлом:
git pack-refs --all
cat .git/packed-refs
# # pack-refs with: peeled fully-peeled sorted
# 84ba0356e619... refs/heads/f
# 23933defbcb6... refs/heads/main
# 9fceb02d0ae5... refs/tags/v2.4.0
# ^4a1f2c9b7e3d... ← «peeled»: коміт, на який вказує анотований тег
Як Git шукає ref: спершу окремий файл у refs/, потім packed-refs. Окремий файл має пріоритет - тому оновлення гілки просто створює новий файл, не переписуючи packed-refs. git gc періодично пакує ref-и автоматично.
Наслідок для скриптів: cat .git/refs/heads/main може не знайти файл - гілка є, але упакована. Правильно - через команди:
git rev-parse refs/heads/main
git for-each-ref --format='%(refname) %(objectname)' refs/tags/
git update-ref refs/heads/main <хеш> # атомарно, із записом у reflog
Атомарність: оновлення ref-а йде через lock-файл (main.lock) і перейменування. Звідси повідомлення Unable to create '.git/refs/heads/main.lock': File exists - залишок після аварійно завершеного процесу Git; якщо інших процесів Git немає, файл можна видалити.
Reftable - новий бінарний формат зберігання ref-ів (з Git 2.45), створений у JGit для серверів з мільйонами ref-ів:
- атомарні транзакції для багатьох ref-ів одночасно;
- швидкий пошук і перелік, без проблем з регістром імен;
- reflog у тому самому сховищі.
git init --ref-format=reftable
git refs migrate --ref-format=reftable # перетворити наявний репозиторій
Для звичайного проєкту різниця непомітна, але у великих монорепозиторіях і на серверах reftable прибирає вузьке місце з тисячами ref-ів.