Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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 hash-object

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 cat-file

.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:

  1. якщо розмір і час зміни файлу збігаються з тими, що в індексі, - файл вважається незмінним без читання вмісту;
  2. лише для файлів з іншими stat Git читає вміст, хешує й порівнює з blob-ом в індексі;
  3. якщо вміст той самий (наприклад, файл перезбережено без змін), 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, зберігши файли робочого каталогу.

Докладніше в документації: Формат індексу Git

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 (лише потрібна гілка) суттєво зменшує обсяг завантаження.

Докладніше в документації: Pro Git: 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-ів.

Докладніше в документації: git pack-refs