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

Senior: питання на співбесіді з теми «Внутрішня будова Git»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Логічно кожна версія файлу - окремий blob з повним вмістом. Новий об'єкт спершу записується як loose object - окремий файл у .git/objects/xx/, стиснений zlib. Для файлу на 20 КБ, зміненого в 100 комітах, це 100 майже однакових копій.

Packfile вирішує це на рівні зберігання. git gc (і передача по мережі) збирає об'єкти в один файл:

.git/objects/pack/
├── pack-4f2a....pack   ← самі об'єкти
├── pack-4f2a....idx    ← індекс: хеш → зміщення в pack
├── pack-4f2a....rev    ← зворотний індекс (зміщення → позиція)
└── pack-4f2a....bitmap ← бітмапи досяжності (для швидкого clone/fetch)

Дельта-стиснення: частина об'єктів зберігається не повністю, а як дельта від іншого об'єкта - інструкції «скопіювати байти з базового об'єкта» й «вставити нові байти».

git verify-pack -v .git/objects/pack/pack-*.idx | sort -k3 -n | tail
# хеш   тип   розмір  розмір-у-pack  зміщення  [глибина  база]

Особливості, що відрізняють Git від «diff між версіями»:

  • база обирається евристично, а не за історією: Git сортує об'єкти за типом, ім'ям файлу й розміром і шукає схожі у ковзному вікні. Дельта може будуватися від файлу з іншої гілки чи навіть з іншим ім'ям;
  • зазвичай база - новіша версія, а старі зберігаються як дельти від неї: свіжі файли, які читають найчастіше, дістаються без розпакування ланцюжка;
  • ланцюжки дельт обмежені глибиною (pack.depth, за замовчуванням 50) - баланс між розміром і швидкістю читання;
  • логічна модель не змінюється: для всіх команд об'єкт - повний знімок, дельти - лише деталь зберігання.

Передача по мережі: при fetch і push сторони узгоджують, які коміти вже є в отримувача, і сервер формує тонкий pack (thin pack) - дельти можуть посилатися на об'єкти, що вже є в клієнта. Тому fetch після невеликих змін передає кілобайти.

Практичні наслідки:

  • бінарні файли (зображення, архіви, дампи) погано стискаються дельтами - кожна версія займає майже повний розмір, і репозиторій росте назавжди. Звідси Git LFS;
  • git gc --aggressive перебудовує дельти з більшим вікном - повільно, і для більшості репозиторіїв виграш мізерний;
  • git count-objects -vH показує, скільки займають loose objects і pack-и: велика кількість loose objects - сигнал, що автоматичне обслуговування не запускається.

Докладніше в документації: Pro Git: packfiles

У коміті немає запису «файл перейменовано». Tree лише містить ім'я й хеш: у старому дереві є big.txt, у новому - big2.txt. Перейменування Git обчислює заново щоразу, коли показує diff, log чи виконує злиття.

Алгоритм:

  1. знайти пари «видалений файл» і «доданий файл»;
  2. точні збіги - однаковий хеш blob-а: це перейменування зі 100% схожістю (R100). Перевіряється дешево;
  3. для решти - оцінити схожість вмісту кожної пари і вважати перейменуванням, якщо вона вища за поріг. Поріг за замовчуванням - 50%.
git diff -M HEAD~1 --name-status
# R100	big.txt	big2.txt
# R087	app/Helpers.php	app/Support/Helpers.php

git diff -M80% HEAD~1          # суворіший поріг
git diff -C HEAD~1             # шукати ще й копії
git diff --no-renames HEAD~1   # показати як видалення + додавання

Чому git mv нічого не гарантує: він лише видаляє й додає файл в індексі. Якщо в тому самому коміті файл перейменовано і суттєво змінено, схожість опуститься нижче порогу - і Git покаже видалення одного файлу й створення іншого. Звідси практика: перейменування окремим комітом, зміни вмісту - наступним.

Де це впливає на роботу:

  • git log --follow file стежить за історією файлу через перейменування - але лише для одного файлу й саме евристикою;
  • git blame знаходить рядки, переміщені з інших файлів, з -C;
  • злиття: якщо в одній гілці файл перейменовано, а в іншій змінено, стратегія ort знаходить перейменування і застосовує зміни до нового імені. Невдале визначення дає конфлікт «deleted by us / modified by them»;
  • ліміт кандидатів: порівняння - квадратична задача. Якщо видалених і доданих файлів забагато, Git пропускає неточне визначення (diff.renameLimit, merge.renameLimit) і попереджає про це. Після масового переміщення файлів зручно підняти ліміт.

Плюс підходу: перейменування «знаходиться» навіть якщо його зробили без git mv - просто через IDE чи mv - і навіть ретроспективно для старої історії, коли алгоритм покращується з новими версіями Git.

Докладніше в документації: gitdiffcore

Ідентифікатори всіх об'єктів Git - хеші SHA-1 (160 біт, 40 шістнадцяткових символів). У 2017 році атака SHAttered показала практичну колізію SHA-1 - два різні PDF-файли з однаковим хешем. Згодом атаки з вибраним префіксом стали ще дешевшими.

Чим це загрожує Git: зловмисник теоретично може створити два об'єкти з однаковим хешем - безпечний для рецензії і шкідливий - і підмінити один іншим. Уся модель цілісності Git тримається на тому, що хеш однозначно визначає вміст.

Що зроблено вже зараз:

  • Git використовує SHA-1 з виявленням колізій (sha1collisiondetection) - об'єкти, побудовані відомими методами атаки, розпізнаються й відхиляються;
  • підтримка SHA-256 у Git з версії 2.29 (ще позначена як експериментальна, хоча вже стабільна для використання):
git init --object-format=sha256
git rev-parse --show-object-format     # sha1 або sha256

Хеші в такому репозиторії - 64 символи.

Чому перехід повільний:

  • SHA-1 і SHA-256 репозиторії несумісні: не можна напряму зробити fetch чи push між ними. План переходу передбачає режим сумісності з таблицею відповідності хешів, але він ще не завершений;
  • хостинги: підтримка SHA-256 у GitHub, GitLab та інших з'являється поступово - перед створенням такого репозиторію треба перевірити, чи його приймає ваш сервер, CI і інструменти;
  • екосистема: інструменти, що вважають хеш 40-символьним рядком (регулярні вирази, колонки в базах, парсери логів), ламаються;
  • конвертація наявного репозиторію змінює всі хеші - посилання на коміти в задачах, changelog, документації стають недійсними.

Практичні висновки:

  • для звичайних проєктів SHA-1 з виявленням колізій зараз достатній - реальна загроза потребує атаки на конкретний репозиторій і значних ресурсів;
  • не покладайтеся на довжину хешу у власних інструментах: використовуйте git rev-parse --show-object-format і не обрізайте хеші жорстко до 40 символів;
  • для перевірки походження важливіші підписи комітів і тегів - їх теж стосується проблема SHA-1, тому сучасні підписи (SSH, GPG) хешують вміст власними алгоритмами;
  • нові репозиторії на SHA-256 - розумний вибір для внутрішніх систем, де ви контролюєте всі інструменти; для відкритих проєктів поки що краще дочекатися повної підтримки хостингів.

Докладніше в документації: Перехід на нову хеш-функцію

Коміт неможливо змінити: будь-яка зміна, навіть у повідомленні, дає новий хеш. Але інколи до вже опублікованого коміту треба «дописати» інформацію: результат CI, посилання на рецензію, час деплою, заміток для розслідування.

git notes зберігає примітки окремо від комітів:

git notes add -m "Deployed to production 2026-10-04 14:20" a1b2c3d
git notes append -m "Rolled back: memory leak" a1b2c3d
git log -1 a1b2c3d
# commit a1b2c3d...
# ...
# Notes:
#     Deployed to production 2026-10-04 14:20
#     Rolled back: memory leak

Як це влаштовано: примітки - звичайні об'єкти Git в окремій гілці refs/notes/commits. Ця гілка містить коміти з деревом, де ім'я файлу - хеш коміту, до якого примітка, а вміст - текст. Тобто це історія приміток з власними комітами, а сам коміт-ціль не змінюється.

Простори імен - різні види метаданих окремо:

git notes --ref=ci add -m "build #4812: passed" HEAD
git notes --ref=deploy add -m "prod 2026-10-04" HEAD
git log --notes=ci --notes=deploy

Головна незручність - примітки не передаються за замовчуванням. git push і git fetch працюють лише з гілками й тегами, тож refs/notes/* треба вказати явно:

git push origin 'refs/notes/*'
git fetch origin 'refs/notes/*:refs/notes/*'

Або додати refspec у конфігурацію remote. GitHub давно не показує примітки в інтерфейсі, тому вони корисні передусім для внутрішніх інструментів.

Ще деталі:

  • rebase і amend створюють нові коміти - примітки старих не переносяться, якщо не налаштувати notes.rewriteRef;
  • злиття приміток з різних джерел - окрема команда git notes merge зі стратегіями union, cat_sort_uniq;
  • примітки не входять у хеш і не захищені підписом коміту - не використовуйте їх для чогось, що має бути незмінним.

Де notes доречні:

  • CI записує результат збирання чи покриття до коміту;
  • інструмент деплою позначає, які коміти й коли потрапили в продакшен;
  • проєкти, що приймають патчі поштою, додають посилання на обговорення;
  • особисті нотатки при розслідуванні історії.

Альтернативи: trailer-рядки в повідомленні (Reviewed-by:, Refs: #142) - якщо інформація відома до коміту; теги - для позначення конкретних точок; зовнішні системи (CI, трекер) - якщо метадані не мають жити разом з репозиторієм.

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