Питання на співбесіді: Великі репозиторії й продуктивність
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
Git зберігає кожну версію кожного файлу назавжди. Видалений пізніше файл лишається в історії, і кожен клон завантажує його знову. Тому в репозиторій потрапляє лише те, що не можна відтворити.
vendor/ і node_modules/ відтворюються з composer.json + composer.lock і package.json + package-lock.json:
composer install
npm ci
Якщо їх комітити:
- розмір: десятки тисяч файлів і сотні мегабайтів, а кожне оновлення пакета додає нові версії файлів в історію назавжди;
- шум у diff і pull request: оновлення однієї залежності - тисячі змінених рядків, серед яких не видно власного коду;
- конфлікти при злитті гілок, що оновлювали різні пакети;
- бінарні частини (нативні модулі npm) скомпільовані під конкретну ОС і не працюватимуть на іншій.
Що ще не комітять:
| Що | Чому |
|---|---|
.env |
секрети й налаштування конкретного середовища (комітять .env.example) |
public/build, public/hot |
результат npm run build / dev-сервера |
storage/logs, storage/framework/* |
логи, кеш, сесії, скомпільовані шаблони |
bootstrap/cache/*.php |
кеш конфігурації й маршрутів |
| дампи бази, архіви, відео | великі бінарні файли, часто з персональними даними |
.idea/, .vscode/, .DS_Store |
налаштування конкретного розробника (краще в глобальний ignore) |
Новий Laravel-проєкт уже містить відповідний .gitignore, а в каталогах storage/ - вкладені .gitignore, що зберігають структуру каталогів, але ігнорують вміст.
Що обов'язково комітять: composer.lock і package-lock.json для застосунку - вони гарантують однакові версії пакетів у всіх. (Для бібліотеки composer.lock зазвичай не комітять: версії визначає застосунок, що її встановлює.)
Якщо файл уже в репозиторії, додавання в .gitignore не допоможе - Git продовжує його відстежувати:
git rm -r --cached vendor
git commit -m "Stop tracking vendor"
З історії він при цьому не зникає - для цього потрібне переписування історії.
Докладніше в документації: Composer: чи комітити каталог vendor
Проблема: Git погано працює з великими бінарними файлами - макетами, відео, датасетами, моделями. Їх неможливо ефективно стиснути дельтами, кожна зміна додає повну нову копію, і кожен клон завантажує всі версії за всю історію.
Git LFS (Large File Storage) - розширення, яке зберігає великі файли окремо від репозиторію. У самому Git лишається лише маленький файл-вказівник:
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
size 52428800
А вміст файлу лежить на LFS-сервері (GitHub, GitLab, Bitbucket мають його вбудованим) і завантажується лише для потрібних версій при checkout.
Налаштування:
git lfs install # один раз на машині
git lfs track "*.psd" "*.mp4" # які файли зберігати в LFS
git add .gitattributes # правила записуються сюди
git add design/landing.psd
git commit -m "Add landing mockup"
# .gitattributes
*.psd filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
Коли LFS доречний:
- дизайн-файли, медіа, ігрові ресурси, які справді є частиною проєкту і мають версії;
- тестові фікстури великого розміру;
- великі файли, які мають змінюватися разом з кодом.
Коли не потрібен:
- завантаження користувачів, бекапи, дампи - їм місце в об'єктному сховищі (S3, R2), а не в репозиторії взагалі;
- результати збирання - їх відтворює CI;
- невеликі зображення для сайту - звичайний Git впорається.
Що враховувати:
- квоти: хостинги обмежують обсяг зберігання й трафік LFS, а кожен клон у CI витрачає трафік;
- усі учасники мають встановити LFS - без нього в робочому каталозі будуть вказівники замість файлів;
- перенести існуючі файли в LFS - це переписування історії:
git lfs migrate import --include="*.psd"; - блокування файлів (
git lfs lock) - для форматів, які неможливо злити, щоб двоє не редагували один макет одночасно.
Поверхневий клон (shallow clone) завантажує не всю історію, а лише останні N комітів:
git clone --depth 1 https://github.com/laravel/laravel.git
Замість тисяч комітів - лише останній знімок файлів. Клон великого репозиторію з багаторічною історією стає в рази меншим і швидшим.
Де це доречно:
- CI: для запуску тестів історія не потрібна - лише поточний код.
actions/checkoutу GitHub Actions за замовчуванням робить саме клон з глибиною 1; - збирання Docker-образів з репозиторію;
- одноразове використання: подивитися код, зібрати проєкт, встановити інструмент.
Обмеження - все, що потребує історії:
| Що | Чому не працює |
|---|---|
git log, git blame |
бачать лише завантажені коміти |
git describe (версія з тегу) |
тегу в обрізаній історії немає |
git merge-base, порівняння з main |
спільного предка не завантажено |
git bisect |
немає історії для пошуку |
інструменти, що аналізують зміни від main (лінтер лише змінених файлів) |
немає з чим порівнювати |
Догрузити історію за потреби:
git fetch --depth 50 # поглибити до 50 комітів
git fetch --deepen 100 # ще на 100 глибше
git fetch --unshallow # завантажити всю історію
git fetch --shallow-since=2026-01-01
Пов'язані параметри:
--single-branch- лише одна гілка (з--depthувімкнено автоматично);--no-tags- без тегів.
Для постійної роботи розробника поверхневий клон незручний: багато команд поводяться несподівано, а push з поверхневого клону інколи відхиляється. Якщо проблема в розмірі - краще частковий клон (--filter=blob:none): історія комітів повна, а вміст старих файлів завантажується на вимогу.
Загальний розмір:
git count-objects -vH
count: 0
size: 0 bytes
in-pack: 48213
packs: 1
size-pack: 312.40 MiB
size-pack - скільки займають упаковані об'єкти, тобто фактичний розмір історії. count і size - ще не запаковані об'єкти; після git gc вони переходять у pack.
Що саме роздуває - найбільші об'єкти в історії:
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1 == "blob"' \
| sort -k3 -n -r \
| head -20
Список найбільших файлів за всю історію з їхніми шляхами - навіть тих, яких уже немає в поточному коді.
Типові знахідки:
- дамп бази (
backup.sql), закомічений «тимчасово» і потім видалений - у робочому каталозі його немає, а в історії він назавжди; vendor/чиnode_modules/, які колись потрапили в репозиторій;- зібрані фронтенд-файли (
public/build), що змінюються з кожним комітом; - медіафайли й архіви.
Зручний інструмент - git-sizer (від GitHub): аналізує репозиторій і повідомляє про проблеми - надто великі файли, дерева з тисячами файлів, надто довгі шляхи:
git-sizer --verbose
Що робити зі знайденим:
- якщо файл ще в поточному коді - видалити, додати в
.gitignore, великі ресурси перенести в LFS чи об'єктне сховище; - якщо лише в історії - розмір зменшить тільки переписування історії (
git filter-repo), з усіма наслідками для команди. Часто простіше змиритися: для розробників допомагає частковий клон; - профілактика: перевірка розміру файлів у pre-commit чи CI, правила push у GitHub, що відхиляють файли понад ліміт (GitHub і так блокує файли понад 100 МБ).
Підмодуль - інший Git-репозиторій, вкладений у каталог вашого репозиторію. Головний репозиторій зберігає не файли підмодуля, а посилання на конкретний коміт.
git submodule add https://github.com/acme/docs.git docs
З'являються файл .gitmodules (адреса й шлях) і запис-вказівник у дереві:
[submodule "docs"]
path = docs
url = https://github.com/acme/docs.git
Клонування з підмодулями:
git clone --recurse-submodules https://github.com/acme/app.git
# або в уже існуючому клоні
git submodule update --init --recursive
Типові проблеми:
- порожній каталог після клону - клонували без
--recurse-submodules; - detached HEAD у підмодулі:
submodule updateпереключає підмодуль на записаний коміт, а не на гілку. Коміти, зроблені там без перемикання на гілку, легко загубити; - оновлення - два кроки: зробити коміт і push у репозиторії підмодуля, а потім закомітити новий вказівник у головному репозиторії. Якщо забути другий крок, колеги отримають стару версію; якщо забути push - вказівник посилатиметься на коміт, якого немає на сервері;
git pullне оновлює підмодулі автоматично - потрібенgit submodule update(чиgit config submodule.recurse true);- конфлікти вказівників при злитті гілок, що оновили підмодуль по-різному;
- CI і доступи: приватний підмодуль вимагає окремих прав на клонування.
Коли підмодулі доречні:
- спільний код, що розвивається окремо, з власним циклом випусків, і потрібна точна версія;
- документація чи контент в окремому репозиторії, яким керує інша команда.
Альтернативи для PHP-проєкту:
- Composer-пакет (зокрема з
path- чиvcs-репозиторієм) - для спільного PHP-коду майже завжди краще: версії, залежності, автозавантаження; - subtree - код копіюється в репозиторій з історією, без окремого клонування;
- монорепозиторій - якщо код насправді тісно пов'язаний.
Корисні налаштування: git config --global submodule.recurse true і git config --global status.submoduleSummary true - Git сам оновлює підмодулі й показує їхні зміни в git status.
У великому монорепозиторії робочий каталог може містити сотні тисяч файлів, з яких розробнику потрібні кілька каталогів. Sparse-checkout лишає в робочому каталозі лише вказані шляхи - решта файлів є в історії, але не на диску.
git clone --filter=blob:none --sparse https://github.com/acme/monorepo.git
cd monorepo
git sparse-checkout set apps/billing packages/ui
git sparse-checkout add packages/auth
git sparse-checkout list
git sparse-checkout disable # повернути всі файли
Режим cone (за замовчуванням) - шаблони лише на рівні каталогів:
- до робочого каталогу потрапляють файли в корені репозиторію, вказані каталоги цілком і файли в їхніх батьківських каталогах (без вкладених підкаталогів);
- Git перевіряє шляхи значно швидше, ніж у режимі довільних шаблонів у стилі
.gitignore, тому для великих репозиторіїв cone рекомендований.
Що це дає:
git status,git checkout,git switchпрацюють з меншою кількістю файлів - значно швидше;- IDE індексує лише потрібний код;
- менше місця на диску.
Найкраще - разом з частковим клоном (--filter=blob:none): тоді вміст файлів поза sparse-набором навіть не завантажується з сервера.
Як інші операції поводяться з невидимими файлами:
- коміти, злиття й rebase працюють з усім деревом - Git знає про всі файли;
- конфлікт у файлі поза sparse-набором - Git тимчасово розміщує файл у робочому каталозі для розв'язання;
git grepіgit log -- pathможуть шукати й поза набором.
Типове застосування:
- монорепозиторій: розробник фронтенду бачить лише
apps/webі спільні пакети; - CI: збирання конкретного сервісу без виписування всього репозиторію - разом з
--depth 1і--filter; - документація в репозиторії з великим кодом.
Обмеження:
- інструменти, яким потрібні файли поза набором (наприклад, Composer з
path-репозиторіями на сусідні пакети, збирачі, що шукають конфіги в корені), ламаються - набір має містити всі залежності; - для невеликого проєкту накладні витрати не виправдані.
Обидва способи зменшують обсяг завантаження, але обрізають різні речі.
Поверхневий клон (--depth 1) обрізає історію: комітів до певної глибини просто немає.
Частковий клон (--filter) завантажує всю історію комітів, але не всі об'єкти - пропущені догружаються з сервера на вимогу:
git clone --filter=blob:none https://github.com/acme/app.git # blobless
git clone --filter=tree:0 https://github.com/acme/app.git # treeless
git clone --filter=blob:limit=1m https://github.com/acme/app.git # без файлів понад 1 МБ
| Поверхневий | Без blob-ів (blob:none) |
Без дерев (tree:0) |
|
|---|---|---|---|
| коміти | лише останні | усі | усі |
| дерева (каталоги) | лише останні | усі | на вимогу |
| вміст файлів | лише останні | лише для checkout, решта на вимогу | на вимогу |
git log |
обрізаний | повний | повний |
git log -p, blame |
обмежені | працюють, догружаючи файли | повільні, багато догрузок |
| для кого | CI, одноразові збирання | розробники | CI, якому потрібна історія комітів |
Чому blobless клон - добрий вибір для розробника великого репозиторію:
- історія повна:
log,merge-base,describe, перемикання гілок працюють як звичайно; - старі версії великих файлів не завантажуються, поки не знадобляться;
git blameчи перегляд старого коміту догружають потрібні об'єкти - помітна, але одноразова затримка.
Чому поверхневий клон - для CI: найменший обсяг і жодних мережевих запитів пізніше. Але все, що потребує історії, не працює.
Treeless клон економить найбільше серед часткових, але операції з історією файлів роблять багато запитів до сервера - для щоденної роботи зазвичай повільно.
Що потрібно:
- підтримка на сервері (GitHub, GitLab, Bitbucket підтримують);
- доступ до сервера при операціях, що потребують відсутніх об'єктів - офлайн частина команд не працюватиме;
- не поєднувати бездумно з
--depth- зазвичай обирають одне.
Разом зі sparse-checkout частковий клон дає найкращий ефект для монорепозиторію: не завантажуються ні старі версії, ні файли з чужих каталогів.
Докладніше в документації: GitHub Blog: partial clone і shallow clone
Кілька Laravel-застосунків використовують спільний код: модуль авторизації, клієнт API, набір Blade-компонентів. Є три основні способи.
1. Submodule - посилання на коміт іншого репозиторію:
git submodule add git@github.com:acme/shared.git packages/shared
- точна версія, окрема історія, окремі права доступу;
- незручно: додаткові кроки при клонуванні й оновленні, detached HEAD, легко забути оновити вказівник.
2. Subtree - код копіюється в репозиторій разом з історією:
git subtree add --prefix=packages/shared git@github.com:acme/shared.git main --squash
git subtree pull --prefix=packages/shared git@github.com:acme/shared.git main --squash
git subtree push --prefix=packages/shared git@github.com:acme/shared.git feature-x
- для інших учасників це просто звичайні файли - клон працює без додаткових кроків;
- зміни можна відправити назад в оригінальний репозиторій;
- команди довгі, легко змішати в одному коміті зміни спільного й основного коду;
- історія основного репозиторію росте.
3. Composer-пакет - стандартний спосіб для PHP:
{
"repositories": [
{ "type": "vcs", "url": "git@github.com:acme/shared.git" }
],
"require": { "acme/shared": "^2.1" }
}
- семантичні версії й діапазони, власні залежності пакета, автозавантаження, сервіс-провайдер Laravel;
- оновлення -
composer update acme/shared, аcomposer.lockфіксує точну версію; - приватні пакети - через Private Packagist, Satis чи
vcs-репозиторій з доступом.
Для локальної розробки пакета разом із застосунком - path-репозиторій:
{ "repositories": [{ "type": "path", "url": "../shared" }] }
Composer створює символьне посилання - зміни в пакеті видно одразу.
Порівняння:
| Submodule | Subtree | Composer | |
|---|---|---|---|
| версіонування | коміт | коміт | семантичні версії |
| залежності пакета | ні | ні | так |
| простота для команди | низька | висока | висока |
| зміни в обидва боки | так | так (subtree push) |
у репозиторії пакета |
Для PHP-коду Composer майже завжди кращий. Submodule і subtree доречні для того, що не є PHP-пакетом: документація, спільні конфіги, ресурси. А якщо спільний код змінюється разом із застосунками постійно, - можливо, вам потрібен монорепозиторій.
Монорепозиторій - кілька проєктів (застосунки, пакети, сервіси) в одному репозиторії. Polyrepo - кожен проєкт в окремому репозиторії.
Переваги монорепозиторію:
- атомарні зміни: змінити API пакета й усіх його споживачів одним комітом і одним pull request. У polyrepo це кілька узгоджених релізів у правильному порядку;
- немає пекла версій: усі проєкти завжди використовують поточну версію спільного коду;
- спільні інструменти: одна конфігурація Pint, PHPStan, CI, однакові правила;
- видимість: легко знайти всі використання функції, зробити рефакторинг по всьому коду;
- простіший онбординг: один клон - увесь контекст.
Проблеми монорепозиторію:
- розмір і продуктивність Git:
status,clone,checkoutповільнішають (лікується частковим клоном, sparse-checkout, fsmonitor); - CI: запускати все на кожну зміну - дорого. Потрібні інструменти, що визначають, які проєкти зачеплені зміною (Nx, Turborepo, Bazel, Pants чи власні скрипти по
git diff); - права доступу: Git не обмежує доступ до каталогів - бачать усі все (CODEOWNERS лише керує рев'ю);
- зв'язність: легко створити залежності, яких не мало б бути, - потрібні правила меж між модулями.
Переваги polyrepo:
- незалежні релізи, власний темп і власні інструменти кожної команди;
- чіткі межі й права доступу;
- маленькі швидкі репозиторії.
Проблеми polyrepo:
- зміни через кілька репозиторіїв - кілька pull request, порядок випусків, тимчасова несумісність;
- розбіжність версій: сервіси застрягають на старих версіях спільних пакетів;
- дублювання конфігурацій CI, лінтерів, шаблонів.
Гібрид, яким користується Laravel: розробка в монорепозиторії laravel/framework, а компоненти автоматично розділяються в окремі репозиторії лише для читання (illuminate/database, illuminate/support), щоб їх можна було встановити окремо.
Як обрати:
- кілька тісно пов'язаних проєктів однієї команди (застосунок, адмінка, спільні пакети) - монорепозиторій дає більше, ніж коштує;
- незалежні продукти різних команд з різним циклом випусків - polyrepo;
- ключове питання: як часто зміна в одному проєкті вимагає зміни в іншому. Часто - монорепозиторій, рідко - окремі репозиторії.
З часом у репозиторії накопичуються незапаковані об'єкти (кожен новий коміт створює окремі файли), недосяжні об'єкти (після rebase, reset, видалення гілок) і багато pack-файлів після кожного fetch. Це сповільнює операції й займає місце.
git gc (garbage collection):
- пакує окремі об'єкти в pack-файли з дельта-стисненням;
- видаляє недосяжні об'єкти, старші за термін (за замовчуванням 2 тижні, і лише ті, на які не посилається reflog - а reflog зберігає записи 90 днів, недосяжні - 30);
- упаковує посилання в
packed-refs, оновлює commit-graph.
git gc # звичайне прибирання
git gc --aggressive # повільне глибоке перепакування - рідко потрібне
git gc --prune=now # видалити недосяжні об'єкти негайно
git gc --auto Git запускає сам після деяких команд (commit, merge, fetch), коли незапакованих об'єктів понад 6700 чи pack-файлів понад 50. Тому вручну запускати gc зазвичай не потрібно.
Проблема автоматичного gc у великих репозиторіях: він запускається посеред роботи й може блокувати на хвилини.
git maintenance - сучасна заміна: обслуговування у фоні за розкладом:
git maintenance start
Реєструє репозиторій і задачі в системному планувальнику (launchd на macOS, systemd чи cron на Linux, Task Scheduler на Windows):
| Задача | Частота | Що робить |
|---|---|---|
prefetch |
щогодини | фоновий fetch у спеціальні ref-и - ваш git fetch потім майже миттєвий |
commit-graph |
щогодини | оновлює граф комітів для швидкого log і злиттів |
loose-objects |
щодня | пакує окремі об'єкти |
incremental-repack |
щодня | поступово об'єднує pack-файли без повного перепакування |
git maintenance run --task=gc # вручну конкретну задачу
git maintenance stop
Коли запускати вручну:
- після видалення великих файлів з історії (
git filter-repo) -git gc --prune=now, щоб звільнити місце; - після масового імпорту чи міграції репозиторію;
- для невеликого проєкту нічого робити не треба - автоматичного
gcдостатньо.
На сервері (GitHub, GitLab) обслуговування виконує хостинг.
Що робить 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