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

Senior: питання на співбесіді з теми «Історія й гілки»

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

5 питань

Коміти в Git майже ніколи не зникають одразу. reset --hard, rebase чи видалення гілки лише прибирають посилання на коміти, а самі об'єкти лишаються в репозиторії, доки їх не прибере збирач сміття (за замовчуванням недосяжні записи reflog живуть 30 днів).

git reflog - журнал того, куди вказував HEAD (і кожна гілка) після кожної операції:

git reflog
9f8e7d6 HEAD@{0}: reset: moving to HEAD~3
a1b2c3d HEAD@{1}: commit: Send invoice email
d4e5f6a HEAD@{2}: commit: Add invoice model

Знайшли стан до помилки - повертаємося:

git reset --hard HEAD@{1}          # повернути гілку туди
# або обережніше:
git branch rescue a1b2c3d          # створити гілку на втраченому коміті

Після невдалого rebase шукають у reflog запис перед rebase (start). Ще простіше - ORIG_HEAD: rebase, merge і reset зберігають у ньому попереднє положення.

Що reflog не врятує:

  • Незакомічені зміни після reset --hard чи checkout -- file: їх у репозиторії не було. Лише якщо їх додавали в індекс (git add), об'єкти можна знайти через git fsck --lost-found.
  • Чужий репозиторій: reflog локальний. Коміт, втрачений на сервері після force push, шукають у reflog того, хто його мав.
  • Записи, старші за термін зберігання, після git gc.

Висновок для команди: закомітьте - і зміни майже неможливо втратити. Небезпечна лише незакомічена робота.

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

Git - це сховище об'єктів, адресованих за хешем вмісту, і набір вказівників на них.

Чотири типи об'єктів:

  • blob - вміст файлу (без імені й прав).
  • tree - каталог: список імен з посиланнями на blob-и й вкладені tree.
  • commit - посилання на кореневий tree (повний знімок проєкту), на батьківські коміти, автор, дата, повідомлення.
  • tag (анотований) - іменований вказівник на об'єкт з власними метаданими.

Ідентифікатор кожного об'єкта - хеш його вмісту (SHA-1, у нових репозиторіях можливий SHA-256). Однаковий файл у двох комітах - той самий blob, збережений один раз. Змінити старий коміт неможливо: інший вміст - інший хеш, тобто вже інший об'єкт.

Гілка - файл з одним хешем. refs/heads/main містить хеш останнього коміту. Новий коміт просто переписує цей хеш. Тому створення гілки миттєве й нічого не копіює.

HEAD - вказівник на поточну гілку (ref: refs/heads/main) або напряму на коміт («detached HEAD»).

git cat-file -p HEAD          # вміст коміту: tree, parent, author
git cat-file -p HEAD^{tree}   # дерево каталогу
cat .git/refs/heads/main      # гілка - це просто хеш

Що з цього випливає:

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

Докладніше в документації: Об'єкти Git

rerere - «reuse recorded resolution», повторне використання записаних розв'язань конфліктів. Git запам'ятовує, як ви розв'язали конфлікт, і коли той самий конфлікт виникає знову, застосовує розв'язання автоматично.

git config --global rerere.enabled true

Як це працює:

  1. виникає конфлікт - Git записує «образ» конфлікту (обидві версії фрагмента) в .git/rr-cache;
  2. ви розв'язуєте конфлікт і робите коміт - Git записує результат;
  3. наступного разу при такому самому конфлікті Git підставляє збережене розв'язання:
Resolved 'app/Models/Invoice.php' using previous resolution.

Файл лишається не доданим до індексу - розв'язання варто переглянути й зробити git add (з rerere.autoUpdate true Git додає сам).

Коли це заощаджує час:

  • довгоживуча гілка, яку регулярно оновлюють rebase-ом на main: при кожному rebase ті самі конфлікти повторюються коміт за комітом;
  • пробне злиття: злити гілку, щоб перевірити, чи все збирається, скасувати злиття - а при справжньому злитті пізніше конфлікти вже розв'язані;
  • скасований rebase: розв'язали половину конфліктів, зробили git rebase --abort, почали знову - розв'язане вже не треба повторювати;
  • інтеграційні гілки (як у Git Flow чи при підготовці релізу), куди ті самі гілки зливаються багато разів.

Команди:

git rerere status      # файли, для яких записано розв'язання
git rerere diff        # що саме буде застосовано
git rerere forget app/Models/Invoice.php   # забути неправильне розв'язання

Ризики:

  • збережене помилкове розв'язання застосовуватиметься знову й знову - тому forget;
  • rerere зіставляє текст конфлікту, а не зміст: якщо код навколо змінився, розв'язання може бути формально застосовне, але логічно хибне. Тести після злиття обов'язкові;
  • кеш локальний і не передається колегам; старі записи прибирає git gc (за замовчуванням через 60 днів для розв'язаних і 15 - для нерозв'язаних).

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

Для змін, які зачіпають усю історію (а не кілька останніх комітів), interactive rebase не підходить. Інструмент для цього - git-filter-repo, який рекомендує сам проєкт Git замість застарілого й повільного git filter-branch.

brew install git-filter-repo     # чи pip install git-filter-repo

Типові задачі:

# видалити файл з усієї історії
git filter-repo --invert-paths --path storage/dump.sql

# видалити всі файли, більші за 10 МБ
git filter-repo --strip-blobs-bigger-than 10M

# лишити лише підкаталог (виділити пакет в окремий репозиторій)
git filter-repo --subdirectory-filter packages/billing

# замінити текст у всіх файлах історії (наприклад, ключ)
git filter-repo --replace-text replacements.txt

Змінити автора - через файл .mailmap:

Olena Petrenko <olena@company.com> <olena@old-laptop.local>
git filter-repo --mailmap .mailmap

Що відбувається: кожен коміт, починаючи з першого зміненого, отримує новий хеш - бо змінюється вміст або батько. Фактично це новий репозиторій зі схожою історією.

Наслідки, які треба спланувати:

  • усі клони застаріли: колеги мають зробити свіжий клон, а не pull - інакше старі коміти повернуться при наступному злитті;
  • force push усіх гілок і тегів, тимчасове зняття захисту гілок;
  • відкриті pull request посилаються на старі коміти - їх доведеться перестворити;
  • посилання на коміти в задачах, документації, changelog стають недійсними;
  • копії на сервері: GitHub ще деякий час зберігає старі об'єкти в кеші й у форках - для повного видалення чутливих даних потрібне звернення в підтримку.

Захисні механізми filter-repo: за замовчуванням він відмовляється працювати не на свіжому клоні (щоб не зіпсувати робочий репозиторій) і видаляє origin після переписування, щоб випадковий push не перезаписав сервер.

Альтернатива для великих файлів - BFG Repo-Cleaner: простіший, але менш гнучкий.

Перед тим як переписувати - перевірити, чи не простіше залишити історію як є: великий файл у минулому лише збільшує розмір клону, а витік секрету лікується ротацією ключа, а не переписуванням історії.

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

Трибічне злиття (three-way merge) порівнює не дві версії, а три:

  • base - спільний предок обох гілок (merge-base);
  • ours - поточна гілка;
  • theirs - гілка, яку зливають.
git merge-base main feature    # хеш спільного предка

Правило для кожного фрагмента:

base ours theirs результат
X X Y Y (змінили лише вони)
X Y X Y (змінили лише ми)
X Y Y Y (змінили однаково)
X Y Z конфлікт

Саме спільний предок дозволяє зрозуміти, хто змінив рядок. Порівнюючи лише дві версії, неможливо відрізнити «ми додали рядок» від «вони його видалили».

Конфлікт із базою легше розв'язувати, якщо бачити всі три версії:

git config --global merge.conflictStyle zdiff3
<<<<<<< HEAD
$total = round($sum * 1.2, 2);
||||||| base
$total = $sum * 1.2;
=======
$total = $sum * $vatRate;
>>>>>>> feature

Видно: одна сторона додала округлення, інша - змінну ставки. Правильний результат поєднує обидві зміни.

Кілька спільних предків (criss-cross merge - гілки зливалися одна в одну кілька разів): рекурсивна стратегія спершу зливає предків між собою у «віртуальну базу», а потім використовує її.

Стратегія ort («Ostensibly Recursive's Twin») - за замовчуванням з Git 2.34 замість recursive:

  • той самий результат для звичайних випадків, але значно швидше на великих репозиторіях і з великою кількістю перейменувань;
  • краще розпізнає перейменування й не торкається робочого каталогу, поки результат не обчислено;
  • лежить в основі git merge-tree --write-tree - злиття без робочого каталогу (так сервіси на кшталт GitHub перевіряють, чи можна злити pull request).

Інші стратегії й параметри:

  • -X ours / -X theirs - у конфліктних фрагментах автоматично взяти свою чи їхню версію (неконфліктні зміни обох сторін зберігаються);
  • -s ours - ігнорувати зміни іншої гілки повністю, лише записати факт злиття (не плутати з -X ours);
  • octopus - злиття більше ніж двох гілок за раз, без ручних конфліктів.

Що не розв'язує жодна стратегія: семантичні конфлікти. Одна гілка перейменувала метод, інша додала новий виклик старого імені - текстово конфлікту немає, а код зламаний. Тому після злиття запускають тести.

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