Git: питання на співбесіді рівня Senior
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
30 питань
Коміти в 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- злиття більше ніж двох гілок за раз, без ручних конфліктів.
Що не розв'язує жодна стратегія: семантичні конфлікти. Одна гілка перейменувала метод, інша додала новий виклик старого імені - текстово конфлікту немає, а код зламаний. Тому після злиття запускають тести.
Тег - постійне ім'я для конкретного коміту, зазвичай версії: v2.4.0. На відміну від гілки, тег не рухається.
- Легкий тег - просто вказівник на коміт, як гілка, що не рухається:
git tag v2.4.0
- Анотований тег - окремий об'єкт Git з автором, датою, повідомленням і, за бажанням, підписом GPG/SSH:
git tag -a v2.4.0 -m "Реліз 2.4.0: експорт рахунків"
git tag -s v2.4.0 -m "..." # підписаний
Для релізів використовують анотовані: git describe за замовчуванням бачить лише їх, і видно, хто й коли позначив реліз.
Теги треба відправляти явно - звичайний git push їх не передає:
git push origin v2.4.0
git push --follow-tags # анотовані теги, що вказують на відправлені коміти
Практики:
- Семантичне версіонування (
MAJOR.MINOR.PATCH): ламаюча зміна - major, нова функція - minor, виправлення - patch. Composer і npm розв'язують залежності саме за тегами. - Тег запускає реліз: CI на
pushтегу збирає артефакти, створює GitHub Release, публікує пакет. - Не переміщати опубліковані теги. Якщо в
v2.4.0помилка, випускаютьv2.4.1. Переписаний тег у когось уже завантажений, і в різних людей «v2.4.0» означатиме різний код. git describe --tagsдає версію для збирання на кшталтv2.4.0-12-ga1b2c3d- 12 комітів після тегу.
Перший крок - не чистка історії, а відкликання секрету. Щойно ключ потрапив у віддалений репозиторій, вважайте його скомпрометованим: його вже могли скопіювати клони, CI, форки, боти, що сканують GitHub. Чистка історії не поверне контроль.
Порядок дій:
- Відкликати й замінити секрет: новий API-ключ, новий пароль бази, ротація токенів. Оновити його в усіх місцях, де він використовується.
- Перевірити журнали сервісу на підозріле використання за час, поки ключ був відкритий.
- Прибрати з коду і перенести секрет туди, де він має жити:
.env(у.gitignore), менеджер секретів, змінні CI. - За потреби - переписати історію, щоб секрет не лишався в старих комітах:
git filter-repo(рекомендований інструмент) чи BFG. Потім force push і повідомити команду: усім потрібно переклонувати репозиторій, інакше старі коміти повернуться з чиєїсь копії. На GitHub ще може знадобитися звернутися в підтримку, щоб прибрати кешовані подання й посилання з pull request.
Як не допустити:
.env, ключі й дампи - в.gitignoreз самого початку; у репозиторії лише.env.exampleбез значень.- Сканування секретів: GitHub secret scanning з push protection,
gitleaksчиtrufflehogу pre-commit і CI. - Короткоживучі облікові дані замість вічних ключів (OIDC для CI, тимчасові токени).
Laravel-специфіка: витік APP_KEY дозволяє підробляти зашифровані cookie й підписані URL - його теж ротують, пам'ятаючи, що зашифровані старим ключем дані доведеться перешифрувати (APP_PREVIOUS_KEYS допомагає з переходом).
Докладніше в документації: Видалення чутливих даних з репозиторію
Коли одночасно підтримується кілька версій продукту (бібліотека з версіями 2.x і 3.x, застосунок, що встановлюється у клієнтів), кожна версія має релізну гілку:
main ── розробка наступної версії
release/3.x ── виправлення для 3.x, теги v3.4.1, v3.4.2
release/2.x ── лише критичні й безпекові виправлення
Два напрямки перенесення виправлень:
1. «Зверху вниз» (backport) - виправлення спершу в main:
git switch release/3.x
git cherry-pick -x a1b2c3d
mainгарантовано містить усі виправлення - регресія в наступній версії неможлива;-xзалишає в повідомленні посилання на оригінальний коміт;- конфлікти, якщо код у старій версії відрізняється.
2. «Знизу вгору» (merge-up) - виправлення в найстаршій підтримуваній гілці:
git switch release/2.x # виправлення тут
git switch release/3.x && git merge release/2.x
git switch main && git merge release/3.x
Так працює, наприклад, Laravel: виправлення потрапляють у гілку найстаршої підтримуваної версії, а потім зливаються вгору. Переваги - кожне виправлення має один коміт, і Git знає, що воно вже є у всіх новіших гілках. Недолік - злиття вгору тягне й те, що для новішої версії не потрібне, і його доводиться скасовувати при злитті.
Як зробити процес надійним:
- автоматизація backport: мітка
backport 3.xна pull request запускає бота (наприклад, GitHub Action), що робить cherry-pick і відкриває новий pull request - людина лише перевіряє; - тести в кожній релізній гілці - CI запускається на всіх підтримуваних гілках, а не лише на
main; - політика підтримки: скільки версій і як довго отримують виправлення (баги - остання версія, безпека - дві), записана й опублікована;
- захист релізних гілок - лише через pull request з рев'ю;
- changelog для кожної гілки - користувачі старої версії мають бачити, що виправлено саме в ній.
Чим менше підтримуваних версій, тим дешевше. Для веб-застосунку, що деплоїться з main, релізні гілки зазвичай зайві: виправлення йде в main і на продакшен звичайним деплоєм, а відкат - повторним деплоєм попередньої версії.
Автор коміту в Git нічим не підтверджений. Поля user.name і user.email будь-хто може встановити довільно:
git -c user.name="Taylor Otwell" -c user.email="taylor@laravel.com" commit -m "Totally legit"
GitHub покаже цей коміт з аватаркою власника пошти. Підпис - криптографічне підтвердження, що коміт створив власник ключа, і що вміст не змінювали після підписання.
Налаштування з SSH-ключем (простіше, ніж GPG, з Git 2.34):
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
Той самий публічний ключ додається на GitHub як Signing key (окремо від ключа автентифікації, навіть якщо файл той самий). Після цього коміти отримують позначку Verified.
Локальна перевірка підписів потребує файлу довірених ключів:
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
git log --show-signature
git verify-commit HEAD
GPG чи SSH:
| GPG | SSH | |
|---|---|---|
| налаштування | складніше: окремі ключі, агент, термін дії | ключ, який уже є |
| відкликання й термін дії | вбудовані | немає (лише видалити ключ з GitHub) |
| підтримка | старі версії Git | Git 2.34+ |
Що підпис дає команді:
- branch protection «Require signed commits» - у захищену гілку не потрапить непідписаний коміт;
- захист ланцюга постачання: зловмисник з викраденим токеном push не підробить підпис без приватного ключа;
- аудит у регульованих галузях.
Пастки:
- коміти, створені на GitHub (злиття через інтерфейс, редагування в браузері), підписує ключ GitHub - вони теж Verified;
- після rebase чи squash коміти перестворюються - їх підписує той, хто виконує операцію, з власним ключем;
- режим vigilant на GitHub позначає непідписані коміти з вашою поштою як Unverified, щоб підробку було помітно;
- ключ на ноутбуці без пароля - слабке місце; краще апаратний ключ (YubiKey) чи ключ у менеджері паролів з агентом SSH.
Докладніше в документації: GitHub: перевірка підпису комітів
Windows закінчує рядки двома символами CRLF (\r\n), macOS і Linux - одним LF (\n). Якщо редактор чи інструмент перетворює закінчення, кожен рядок файлу вважається зміненим: diff на весь файл, конфлікти на рівному місці, blame втрачає авторів.
Ще гірші наслідки для PHP-проєкту:
- shell-скрипти (
entrypoint.sh) зCRLFне запускаються в Linux-контейнері:/bin/sh^M: bad interpreter; CRLFу шаблонах чи тестах з очікуваним текстом ламає порівняння рядків.
Рішення - .gitattributes у репозиторії, а не налаштування кожного розробника:
# .gitattributes
* text=auto eol=lf
*.sh text eol=lf
*.bat text eol=crlf
*.png binary
*.jpg binary
*.pdf binary
text=auto- Git сам визначає текстові файли й зберігає їх у репозиторії зLF;eol=lf- у робочому каталозі тежLFна будь-якій ОС (сучасні редактори на Windows працюють зLFбез проблем);binary- жодних перетворень і текстових diff для бінарних файлів.
Чому не core.autocrlf: це локальне налаштування кожного розробника (true на Windows, input на macOS/Linux). Достатньо одному учаснику налаштувати неправильно - і в репозиторій потрапляють CRLF. .gitattributes комітиться й діє для всіх однаково, маючи пріоритет над core.autocrlf.
Нормалізація існуючого репозиторію після додавання .gitattributes:
git add --renormalize .
git commit -m "Normalize line endings"
Цей коміт змінить багато файлів - його варто додати в .git-blame-ignore-revs, щоб blame не показував його автором кожного рядка.
Додатково:
.editorconfigзend_of_line = lf- щоб редактори одразу створювали файли правильно;- перевірка у CI:
git ls-files --eolпоказує закінчення рядків у індексі й робочому каталозі для кожного файлу; - Pint і Prettier також нормалізують закінчення рядків у файлах, які форматують.
Докладніше в документації: gitattributes: перетворення закінчень рядків
Що робить git status:
- порівнює індекс з
HEAD- швидко, працює з хешами; - порівнює робочий каталог з індексом - для кожного відстежуваного файлу перевіряє метадані (
stat: розмір, час зміни, inode); - шукає невідстежувані файли - обходить усі каталоги й застосовує правила
.gitignore.
У репозиторії зі 300 тисячами файлів кроки 2 і 3 - сотні тисяч системних викликів на кожен status. А IDE й промпт оболонки викликають status постійно.
Прискорення:
1. fsmonitor - Git питає не файлову систему, а демон, що стежить за змінами:
git config core.fsmonitor true
Вбудований демон (macOS і Windows) підписується на події файлової системи, і status перевіряє лише файли, змінені з минулого разу. На Linux - через hook з Watchman.
2. Кеш невідстежуваних файлів:
git config core.untrackedCache true
Git запам'ятовує, які каталоги не змінювалися, і не обходить їх повторно. У поєднанні з fsmonitor ефект найбільший.
3. Менше файлів у робочому каталозі:
- sparse-checkout - найдієвіше для монорепозиторію;
- sparse index (
git sparse-checkout init --cone --sparse-index) - індекс зберігає цілі каталоги поза набором одним записом.
4. Версія індексу й багатопотоковість:
git config feature.manyFiles true # index.version=4, untrackedCache
git config index.threads true
5. scalar - утиліта, що постачається з Git і вмикає всі рекомендовані налаштування для великих репозиторіїв разом:
scalar clone https://github.com/acme/monorepo.git
scalar register # для існуючого клону
Вмикає частковий клон, sparse-checkout у режимі cone, fsmonitor, фонове обслуговування й низку налаштувань продуктивності.
Діагностика:
GIT_TRACE2_PERF=1 git status # де витрачається час
git status --untracked-files=no # чи справа в пошуку невідстежуваних
Типові причини поза Git:
- величезні ігноровані каталоги (
node_modules,vendor) - кеш невідстежуваних і fsmonitor допомагають; - антивірус на Windows, що сканує кожне звернення до файлу;
- мережеві файлові системи й bind mount у Docker на macOS.
Ці файли - допоміжні індекси поруч з об'єктами. Вони не змінюють дані, а лише прискорюють операції, які інакше вимагали б читання й розпакування тисяч об'єктів.
Commit-graph (.git/objects/info/commit-graph):
Щоб пройтися історією (git log --graph, merge-base, branch --contains), Git має для кожного коміту знайти батьків - розпакувати об'єкт коміту й розібрати текст. На мільйоні комітів це секунди.
Commit-graph зберігає в компактному бінарному форматі для кожного коміту: батьків, дерево, дату й номер покоління (generation number) - відстань від кореня. Завдяки номеру покоління Git відсікає гілки історії, що не можуть містити шуканий коміт, і не обходить їх.
git commit-graph write --reachable --changed-paths
--changed-paths додає Bloom-фільтри: для git log -- path Git швидко пропускає коміти, які точно не змінювали файл.
Multi-pack-index (.git/objects/pack/multi-pack-index):
Після багатьох fetch у репозиторії десятки pack-файлів, і пошук об'єкта - перебір індексу кожного. Multi-pack-index - один спільний індекс для всіх pack-файлів. Він також дозволяє поступове перепакування: об'єднувати дрібні pack-файли, не переписуючи весь великий.
git multi-pack-index write
Reachability bitmaps (.bitmap поруч з pack-файлом):
Щоб відповісти на clone чи fetch, сервер має визначити, які об'єкти досяжні з потрібних комітів і яких у клієнта ще немає. Без bitmap - обхід усього графа. Bitmap для вибраних комітів зберігає готовий бітовий вектор «які об'єкти досяжні», і відповідь обчислюється операціями над бітами.
git repack -a -d --write-bitmap-index
Саме bitmaps роблять клонування великих репозиторіїв з GitHub швидким - це передусім серверна оптимізація.
Як це використовується на практиці:
- автоматично:
git gcіfetchпишуть commit-graph (gc.writeCommitGraph,fetch.writeCommitGraph),git maintenanceоновлює commit-graph і multi-pack-index у фоні; - scalar вмикає все разом;
- власний Git-сервер (Gitea, GitLab self-hosted) - тут bitmaps і регулярне перепакування на сервері впливають на швидкість клонів у CI.
Вручну це потрібно рідко: для типового Laravel-проєкту різниці не видно. Для монорепозиторію з мільйонами об'єктів - це різниця між секундами й хвилинами в git log і fetch.
laravel/framework - монорепозиторій: усі компоненти (src/Illuminate/Database, Support, Collections...) розвиваються в одному репозиторії з однією історією, одним набором тестів і однією системою CI.
Але користувачі можуть встановити окремий компонент - наприклад, Eloquent поза Laravel:
composer require illuminate/database
Пакети illuminate/* живуть в окремих репозиторіях лише для читання (github.com/illuminate/database), які автоматично отримуються з монорепозиторію розділенням історії (subtree split).
Як працює розділення:
Для каталогу src/Illuminate/Database інструмент проходить історію й будує нову історію, в якій:
- лишаються лише коміти, що змінювали цей каталог;
- файли зміщені в корінь (як
git filter-repo --subdirectory-filter); - результат детермінований: той самий вхід дає ті самі хеші, тож наступне розділення лише додає нові коміти, і push у репозиторій пакета - fast-forward.
SHA=$(splitsh-lite --prefix=src/Illuminate/Database)
git push git@github.com:illuminate/database.git "$SHA:refs/heads/13.x"
Вбудований git subtree split робить те саме, але на великій історії працює дуже повільно - тому Laravel, Symfony й інші використовують швидкий splitsh-lite, що кешує проміжні результати.
Процес у CI: після кожного push у гілку чи тегу монорепозиторію запускається розділення для кожного компонента й push у відповідні репозиторії - разом з тегами, щоб Packagist побачив нові версії.
Що потрібно для такої схеми:
composer.jsonу кожному компоненті з власними залежностями (illuminate/databaseзалежить відilluminate/support,illuminate/collections), а кореневийcomposer.jsonмонорепозиторію оголошуєreplaceдля всіх компонентів, щоб вони не встановлювалися двічі;- pull request лише в монорепозиторій: у репозиторіях пакетів pull request автоматично закриваються з поясненням, куди їх надсилати;
- узгоджене версіонування - усі компоненти отримують однаковий тег при релізі;
- межі між компонентами: залежності між каталогами мають відповідати оголошеним у
composer.json, інакше окремий пакет не працюватиме без решти фреймворку.
Для своїх проєктів схема корисна, якщо ви підтримуєте набір пов'язаних пакетів (SDK, модулі) - розробка й тестування разом, а публікація окремими пакетами. Для звичайного застосунку вона зайва.
Задача: є репозиторії api, admin і shared, і їх треба перенести в один репозиторій з каталогами apps/api, apps/admin, packages/shared - не втративши історію, щоб git log і blame працювали для старого коду.
Крок 1 - у кожному вихідному репозиторії перемістити файли в цільовий каталог через переписування історії (на свіжому клоні):
git clone https://github.com/acme/api.git api-rewrite
cd api-rewrite
git filter-repo --to-subdirectory-filter apps/api
Кожен коміт історії тепер виглядає так, ніби файли завжди лежали в apps/api. Без цього кроку git log -- apps/api/... і blame губили б історію на межі переміщення.
Крок 2 - злити переписані історії в новий репозиторій:
git init monorepo && cd monorepo
git commit --allow-empty -m "Initial monorepo commit"
git remote add api ../api-rewrite
git fetch api
git merge --allow-unrelated-histories --no-edit api/main
--allow-unrelated-histories потрібен, бо історії не мають спільного предка - без нього Git відмовляє: refusing to merge unrelated histories.
Повторити для admin і shared. Результат - коміт злиття, в якому сходяться всі історії.
Що ще врахувати:
- теги конфліктують:
v1.0.0є в кожному репозиторії. Перейменувати при переписуванні:git filter-repo --tag-rename '':'api-'даєapi-v1.0.0; - гілки в роботі: відкриті гілки теж треба перенести - переписати й відновити в монорепозиторії, або домовитися злити їх до міграції;
- pull request і задачі посилаються на старі хеші й репозиторії - старі репозиторії архівувати (не видаляти), лишивши в README посилання на монорепозиторій;
- CI, деплой, права, вебхуки налаштувати заново під каталоги;
- залежності: якщо
apiпідключавsharedяк Composer-пакет за версією - перейти наpath-репозиторій, інакше в монорепозиторії залишаться дві копії коду; .gitignore,.gitattributes, конфіги лінтерів з коренів репозиторіїв опиняться в підкаталогах - вирішити, що лишити локально, а що винести в корінь.
Порядок міграції: заморозити push у старі репозиторії, перенести, перевірити історію (git log --follow, blame на кількох файлах), перемкнути CI - і лише потім відкрити монорепозиторій для роботи.
Зворотна операція - виділення каталогу в окремий репозиторій - git filter-repo --subdirectory-filter packages/shared на свіжому клоні.
Докладніше в документації: git merge: --allow-unrelated-histories
Зазвичай HEAD вказує на гілку, а гілка - на коміт:
cat .git/HEAD
# ref: refs/heads/main
Новий коміт пересуває гілку main, а HEAD іде слідом.
Detached HEAD - HEAD вказує напряму на коміт, оминаючи гілку:
git switch --detach v2.4.0 # чи git checkout v2.4.0, git checkout a1b2c3d
cat .git/HEAD
# 9fceb02d0ae598e95dc970b74767f19372d61af8
Коли це відбувається:
- перехід на тег чи конкретний коміт, щоб подивитися старий стан;
git bisectперемикає коміти саме так;- під час
rebaseз зупинками й розв'язання конфліктів; - CI зазвичай клонує конкретний коміт у detached HEAD;
- підмодулі - завжди на конкретному коміті.
Небезпека: у цьому стані можна комітити, але на нові коміти не вказує жодна гілка. Після перемикання на main вони стають недосяжними:
Warning: you are leaving 2 commits behind, not connected to
any of your branches:
3e1f2a4 Try new cache driver
Через якийсь час (за замовчуванням недосяжні записи reflog живуть 30 днів) git gc їх видалить.
Як зберегти роботу:
# ще в detached HEAD - створити гілку на поточному коміті
git switch -c experiment/cache-driver
# уже перемкнулися - знайти коміт і створити гілку
git reflog
git branch experiment/cache-driver 3e1f2a4
Як помітити стан: git status пише HEAD detached at v2.4.0, а більшість промптів оболонки показують хеш замість назви гілки.
Практичні поради:
- для експериментів зі старою версією одразу створюйте гілку:
git switch -c try-fix v2.4.0; git switchбез--detachне перейде на тег - захист від випадкового detached HEAD;- у скриптах CI, які щось комітять (наприклад, оновлення changelog), явно створюйте чи перемикайте гілку перед комітом, інакше
git pushне матиме що відправити.
Питання рівня Senior з реальних технічних співбесід - 30 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 46 відкритих вакансій рівня Senior. Переглянути вакансії