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

Питання на співбесіді: Великі репозиторії й продуктивність

Питання з реальних співбесід з відповідями: 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) - для форматів, які неможливо злити, щоб двоє не редагували один макет одночасно.

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

Поверхневий клон (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 clone

Загальний розмір:

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 count-objects

Підмодуль - інший 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.

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

У великому монорепозиторії робочий каталог може містити сотні тисяч файлів, з яких розробнику потрібні кілька каталогів. 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-репозиторіями на сусідні пакети, збирачі, що шукають конфіги в корені), ламаються - набір має містити всі залежності;
  • для невеликого проєкту накладні витрати не виправдані.

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

Обидва способи зменшують обсяг завантаження, але обрізають різні речі.

Поверхневий клон (--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-пакетом: документація, спільні конфіги, ресурси. А якщо спільний код змінюється разом із застосунками постійно, - можливо, вам потрібен монорепозиторій.

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

Монорепозиторій - кілька проєктів (застосунки, пакети, сервіси) в одному репозиторії. 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;
  • ключове питання: як часто зміна в одному проєкті вимагає зміни в іншому. Часто - монорепозиторій, рідко - окремі репозиторії.

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

З часом у репозиторії накопичуються незапаковані об'єкти (кожен новий коміт створює окремі файли), недосяжні об'єкти (після 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 maintenance

Що робить git status:

  1. порівнює індекс з HEAD - швидко, працює з хешами;
  2. порівнює робочий каталог з індексом - для кожного відстежуваного файлу перевіряє метадані (stat: розмір, час зміни, inode);
  3. шукає невідстежувані файли - обходить усі каталоги й застосовує правила .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.

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

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

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.

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

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, модулі) - розробка й тестування разом, а публікація окремими пакетами. Для звичайного застосунку вона зайва.

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

Задача: є репозиторії 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