Питання на співбесіді: Історія й гілки
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
15 питань
Обидві команди переносять зміни з однієї гілки в іншу, але по-різному формують історію.
git merge main (у своїй гілці) створює коміт злиття з двома батьками. Історія показує, як було насправді: дві гілки розвивалися паралельно і зійшлися.
git rebase main переписує коміти гілки так, ніби вона почалася з останнього коміту main: кожен коміт застосовується заново й отримує новий хеш. Історія стає лінійною, без комітів злиття.
git switch feature
git rebase main # перебудувати feature поверх свіжого main
git switch main
git merge --ff-only feature # тепер злиття - просте перемотування
Що обирати:
- Rebase - для власної гілки, щоб підтягнути свіжий
mainі отримати чисту історію перед pull request. - Merge - для злиття спільних гілок і коли важливо зберегти, як саме йшла робота.
Головне правило rebase: не переписувати коміти, які вже опубліковані й використовуються іншими. Після rebase гілку доведеться відправляти з --force-with-lease, а в колег, що працювали поверх старих комітів, історія розійдеться.
Конфлікти бувають і там, і там. Але при rebase їх розв'язують по коміту: якщо гілка з 10 комітів зачіпає те саме місце, конфлікт може з'явитися кілька разів.
Спосіб залежить від того, чи коміт уже відправлено в спільний репозиторій.
Коміт ще локальний - git reset пересуває гілку назад:
git reset --soft HEAD~1 # коміт скасовано, зміни лишилися в індексі (staged)
git reset HEAD~1 # (--mixed) зміни лишилися у файлах, але не staged
git reset --hard HEAD~1 # коміт і зміни видалено повністю
Найчастіше потрібен --soft чи --mixed: виправити повідомлення, додати забутий файл, розбити коміт. --hard знищує незбережену роботу.
Виправити лише останній коміт - git commit --amend: змінити повідомлення чи додати файли.
Коміт уже в спільній гілці - git revert створює новий коміт, що скасовує зміни старого. Історія не переписується, тож колегам нічого не ламається:
git revert a1b2c3d
git push
Головна відмінність: reset переписує історію (коміт ніби зник), revert додає до неї (коміт є, і є його скасування). Для main, з якого вже розгорнуто прод, - лише revert.
Для окремих файлів є git restore: git restore app/User.php - відкинути незакомічені зміни у файлі, git restore --staged app/User.php - прибрати з індексу.
git log без параметрів виводить довгий список комітів, у якому важко зорієнтуватися. Кілька параметрів роблять його зручним інструментом.
Компактний вигляд і граф гілок:
git log --oneline # хеш і заголовок в один рядок
git log --oneline --graph --all # граф усіх гілок
git log --oneline -10 # лише останні 10
Фільтри:
| Параметр | Що показує |
|---|---|
--author="Olena" |
коміти автора |
--since="2 weeks ago" |
за період (--until - до дати) |
--grep="invoice" |
коміти, в повідомленні яких є слово |
-- app/Models/User.php |
коміти, що змінювали файл |
--follow -- file.php |
історія файлу з урахуванням перейменувань |
main..feature |
коміти гілки feature, яких ще немає в main |
-S "calculateTax" |
коміти, що додали чи видалили цей рядок коду |
Зміни разом з історією:
git log -p -- app/Services/Billing.php # diff кожного коміту
git log --stat # які файли й скільки рядків змінено
git show - один об'єкт детально:
git show a1b2c3d # повідомлення коміту й diff
git show HEAD~2 --stat # коміт, на два раніше від поточного
git show main:config/app.php # вміст файлу в гілці main, не перемикаючись
Власний формат:
git log --pretty=format:"%h %ad %an %s" --date=short
Практичні поради:
-S(пошук «кирками», pickaxe) - найшвидший спосіб з'ясувати, коли з'явилася чи зникла функція;main..featureперед злиттям показує, що саме потрапить уmain;- часто вживані варіанти варто оформити як аліас:
git config --global alias.lg "log --oneline --graph --all".
git commit --amend замінює останній коміт новим: з виправленим повідомленням, доданими файлами чи обома змінами.
Виправити повідомлення:
git commit --amend -m "Fix invoice total rounding"
Додати забутий файл:
git add tests/Feature/InvoiceTest.php
git commit --amend --no-edit # зберегти попереднє повідомлення
Змінити автора (наприклад, коміт зроблено з неправильною поштою):
git commit --amend --reset-author --no-edit
Що відбувається насправді: Git не змінює старий коміт - коміти незмінні. Він створює новий коміт з іншим хешем і переставляє на нього гілку. Старий коміт лишається в репозиторії (його видно в git reflog), доки його не прибере збирач сміття.
Коли --amend безпечний: коміт ще не відправлено у спільний репозиторій (git push) або гілка особиста й ніхто на неї не спирається.
Коли не можна: коміт уже в спільній гілці (main, гілка колеги). Після --amend локальна історія розходиться з віддаленою:
! [rejected] main -> main (non-fast-forward)
Щоб відправити, доведеться робити force push - і колеги, що вже отримали старий коміт, матимуть конфлікти й дублікати. У спільній гілці замість --amend - новий коміт з виправленням.
У своїй гілці pull request після --amend використовуйте git push --force-with-lease - він не перезапише чужих змін, якщо вони з'явилися на сервері.
Виправити не останній, а давніший коміт - через git commit --fixup <хеш> і git rebase -i --autosquash, а не --amend.
Якщо --amend зроблено помилково: git reset --soft HEAD@{1} повертає гілку на попередній коміт, а зміни лишаються в індексі.
git cherry-pick бере зміни з указаного коміту й застосовує їх як новий коміт у поточній гілці.
git switch release/2.4
git cherry-pick a1b2c3d
Новий коміт має той самий diff і повідомлення, але інший хеш: в нього інший батько й час створення.
Типові сценарії:
- виправлення в реліз: баг виправлено в
main, і те саме виправлення потрібне в гілці підтримуваної версії; - коміт не в тій гілці: зроблено коміт у
mainзамість гілки задачі - перенести його в потрібну гілку, а зmainприбрати; - врятувати частину роботи з гілки, яку не будуть зливати.
Кілька комітів:
git cherry-pick a1b2c3d e4f5g6h # окремі коміти
git cherry-pick main~3..main # діапазон (без main~3)
Корисні параметри:
| Параметр | Що робить |
|---|---|
-x |
додає в повідомлення рядок (cherry picked from commit ...) - видно походження |
--no-commit (-n) |
застосувати зміни без коміту, щоб об'єднати кілька в один |
--continue / --abort |
продовжити після конфлікту чи скасувати |
Конфлікти розв'язуються як при злитті: виправити файли, git add, git cherry-pick --continue.
Чому не варто зловживати:
- дублікати в історії: той самий зміст у двох комітах з різними хешами. Коли гілки згодом зіллються, Git зазвичай розпізнає однакові зміни, але інколи це дає конфлікти;
- заміна злиттю: якщо доводиться переносити десятки комітів, правильніше злити гілку чи переглянути процес роботи з гілками.
Порада: для виправлень у кількох версіях використовуйте -x - тоді з історії релізної гілки видно, звідки прийшла кожна зміна.
git rebase -i відкриває список комітів гілки, де кожному можна задати дію. Так історію з «wip», «fix typo», «ще фікс» перетворюють на кілька змістовних комітів.
git rebase -i main
pick a1b2c3d Add invoice model
squash d4e5f6a fix typo
pick 9f8e7d6 Send invoice email
fixup 1a2b3c4 forgot import
reword 5d6e7f8 wip
Основні дії:
pick- лишити коміт як є.reword- змінити повідомлення.squash- злити з попереднім, об'єднавши повідомлення.fixup- злити з попереднім, відкинувши повідомлення.edit- зупинитися на коміті, щоб змінити його чи розбити на кілька.dropабо видалити рядок - прибрати коміт.- Переставити рядки - змінити порядок комітів.
Зручний прийом - fixup-коміти:
git commit --fixup=a1b2c3d # «виправлення до коміту a1b2c3d»
git rebase -i --autosquash main # сам поставить fixup у потрібне місце
Навіщо: рев'юеру легше читати кілька логічних комітів, ніж двадцять випадкових; git bisect і git blame працюють точніше; коміт можна відкотити цілком.
Обережно: це переписування історії. Лише для своєї гілки, а потім - git push --force-with-lease. Якщо щось пішло не так, git rebase --abort повертає все як було, а після завершення врятує git reflog.
Багато команд обходяться без цього, вливаючи PR через squash merge - тоді вся гілка стає одним комітом у main.
git bisect шукає коміт, що вніс помилку, двійковим пошуком. Ви називаєте один «поганий» коміт (де баг є) і один «хороший» (де його ще не було), а Git щоразу переходить на середину проміжку й питає, чи є там баг.
git bisect start
git bisect bad # поточний коміт - зламаний
git bisect good v2.3.0 # у цьому релізі все працювало
# Git перемикається на коміт посередині
# перевіряєте й кажете:
git bisect good # або
git bisect bad
# ...через кілька кроків:
# a1b2c3d is the first bad commit
git bisect reset # повернутися, звідки почали
Серед 1000 комітів винуватця знайдено за ~10 перевірок.
Автоматично - якщо є команда, що відповідає «добре/погано» кодом виходу:
git bisect run php artisan test --filter=InvoiceTotalTest
Код 0 - коміт хороший, 1-127 (крім 125) - поганий, 125 - пропустити (наприклад, коміт не збирається).
Що допомагає bisect:
- Невеликі коміти, кожен з яких працює. Якщо коміт «напівготовий» і падає з іншої причини, bisect заплутається - такі коміти пропускають (
git bisect skip). - Тест, що відтворює баг, - його пишуть першим, а потім ганяють через
bisect run.
Знайшовши коміт, видно не лише рядок, а й контекст: повідомлення, задачу, автора - часто це пояснює, чому баг з'явився.
Звичайний git rebase main переносить усі коміти поточної гілки, яких немає в main. Але інколи потрібно перенести лише частину комітів чи змінити основу на зовсім іншу гілку. Для цього є --onto:
git rebase --onto <нова-основа> <стара-основа> <гілка>
Git бере коміти з діапазону стара-основа..гілка і відтворює їх поверх нова-основа.
Сценарій 1 - гілку створено не від тієї гілки. Гілку feature почали від develop, а треба було від main:
main ── A
\
develop B ── C
\
feature D ── E
git rebase --onto main develop feature
main ── A ── D' ── E' (feature)
Коміти B і C з develop у feature не потрапили.
Сценарій 2 - залежні pull request. Гілка part-2 побудована поверх part-1. Після злиття part-1 через squash її коміти в main мають інші хеші, і звичайний git rebase main намагатиметься застосувати їх повторно з конфліктами:
git rebase --onto main part-1 part-2
Переноситься лише власна робота part-2.
Сценарій 3 - видалити коміти з середини гілки:
git rebase --onto HEAD~5 HEAD~3
Коміти HEAD~4 і HEAD~3 зникають, решта переноситься на HEAD~5.
Що пам'ятати:
- як і будь-який rebase,
--ontoстворює нові коміти з новими хешами - для вже опублікованої спільної гілки це переписування історії; - якщо заплуталися -
git rebase --abortпід час процесу чиgit reflogпісля нього; --update-refs(Git 2.38+) оновлює й проміжні гілки в ланцюжку - зручно для стопки залежних гілок.
Докладніше в документації: git rebase: перенесення гілки з --onto
Злити гілку feature у main можна трьома способами, і від вибору залежить вигляд історії.
Fast-forward - можливий, коли main не просунувся після створення feature. Git просто пересуває вказівник main на останній коміт feature, нового коміту не створюється:
до: main ── A після: A ── B ── C (main, feature)
\
B ── C (feature)
Історія лінійна, але факт існування гілки зникає: не видно, які коміти були однією задачею.
--no-ff - завжди створює коміт злиття, навіть коли fast-forward можливий:
git merge --no-ff feature
A ────────── M (main)
\ /
B ── C ── (feature)
Видно межі задачі; відкотити всю функціональність можна одним git revert -m 1 M. Ціна - більше комітів злиття в історії.
Squash - усі зміни гілки стають одним новим комітом в main:
git merge --squash feature
git commit -m "Add invoice export"
Історія main чиста: одна задача - один коміт. Але:
- проміжні коміти гілки в
mainне потрапляють - деталізація дляgit bisectіblameвтрачається; - Git не вважає
featureзлитою (git branch --mergedїї не покаже), аgit branch -d featureвідмовиться її видаляти; - якщо продовжити роботу в тій самій гілці, наступний squash може дати конфлікти з уже злитими змінами.
Порівняння:
| Fast-forward | --no-ff |
Squash | |
|---|---|---|---|
| коміт злиття | ні | так | ні |
проміжні коміти в main |
так | так | ні |
| межі задачі видно | ні | так | так (один коміт) |
Налаштування: git config merge.ff only дозволяє лише fast-forward (злиття впаде, якщо воно неможливе), pull.ff only - те саме для git pull. На GitHub і GitLab спосіб злиття pull request обирається в налаштуваннях репозиторію.
git blame показує для кожного рядка файлу, в якому коміті й ким він востаннє змінений:
git blame app/Services/Billing.php
git blame -L 40,60 app/Services/Billing.php # лише рядки 40-60
a1b2c3d4 (Olena 2026-03-14 41) $total = round($subtotal * 1.2, 2);
Мета - не «кого звинуватити», а «чому так»: знайдений хеш веде до коміту з повідомленням, pull request і обговоренням, де видно причину рішення.
git show a1b2c3d4
Проблема: шумні коміти. Масове форматування (Pint, Prettier), перейменування чи перенесення коду роблять автором кожного рядка «коміт форматування», і справжня історія ховається.
Що допомагає:
| Параметр | Що робить |
|---|---|
-w |
ігнорує зміни пробілів |
-M |
розпізнає рядки, переміщені в межах файлу |
-C |
розпізнає рядки, перенесені з інших файлів того самого коміту (-C -C -C - з будь-якого коміту) |
--ignore-rev <хеш> |
пропустити конкретний коміт і показати попереднього автора |
Файл ігнорованих комітів - рішення для всієї команди:
# .git-blame-ignore-revs
# Pint: форматування всього проєкту
3f9e1a2b7c...
git config blame.ignoreRevsFile .git-blame-ignore-revs
GitHub автоматично враховує файл .git-blame-ignore-revs у корені репозиторію у своєму інтерфейсі blame.
Коли рядок змінювався багато разів:
git log -L 40,60:app/Services/Billing.php- вся історія змін саме цього фрагмента, з diff кожного кроку;git log -S "1.2"- коміти, що додали чи прибрали конкретний вираз;- в IDE blame вбудований («Annotate with Git Blame» у PhpStorm) з можливістю переходити до попередньої ревізії рядка.
Коміти в 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 - це сховище об'єктів, адресованих за хешем вмісту, і набір вказівників на них.
Чотири типи об'єктів:
- 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 не «переносить» коміти - він створює нові з іншими батьками й хешами.
- Видалення гілки не видаляє коміти, лише вказівник - звідси можливість їх відновити.
rerere - «reuse recorded resolution», повторне використання записаних розв'язань конфліктів. Git запам'ятовує, як ви розв'язали конфлікт, і коли той самий конфлікт виникає знову, застосовує розв'язання автоматично.
git config --global rerere.enabled true
Як це працює:
- виникає конфлікт - Git записує «образ» конфлікту (обидві версії фрагмента) в
.git/rr-cache; - ви розв'язуєте конфлікт і робите коміт - Git записує результат;
- наступного разу при такому самому конфлікті 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 - для нерозв'язаних).
Для змін, які зачіпають усю історію (а не кілька останніх комітів), 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: простіший, але менш гнучкий.
Перед тим як переписувати - перевірити, чи не простіше залишити історію як є: великий файл у минулому лише збільшує розмір клону, а витік секрету лікується ротацією ключа, а не переписуванням історії.
Трибічне злиття (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- злиття більше ніж двох гілок за раз, без ручних конфліктів.
Що не розв'язує жодна стратегія: семантичні конфлікти. Одна гілка перейменувала метод, інша додала новий виклик старого імені - текстово конфлікту немає, а код зламаний. Тому після злиття запускають тести.