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

Питання на співбесіді: Командна робота

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

15 питань

  • git fetch завантажує нові коміти й гілки з віддаленого репозиторію і оновлює віддалені гілки (origin/main). Ваші локальні гілки й робочі файли не змінюються.
  • git pull = fetch + інтеграція змін у поточну гілку (за замовчуванням merge, або rebase, якщо так налаштовано).
git fetch
git log main..origin/main      # що нового на сервері, ще до злиття
git diff main origin/main      # які саме зміни
git merge origin/main          # або git rebase origin/main

Чому fetch корисний: спершу подивитися, що змінилося, і лише потім вирішити, як інтегрувати. pull робить усе одразу, і при конфлікті ви опиняєтеся посеред злиття без попередження.

Налаштування pull, яке варто знати:

git config --global pull.rebase true    # pull через rebase - без зайвих комітів злиття
git config --global pull.ff only        # або: лише перемотування, інакше помилка

Без явного налаштування сучасний Git при розбіжності гілок відмовляється робити pull і просить обрати стратегію.

Типова ситуація: «у мене не той код, що на сервері» - часто просто не зроблено fetch, і origin/main у вас застарілий.

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

Конфлікт виникає, коли обидві гілки змінили ті самі рядки (або одна змінила файл, а інша - видалила). Git не знає, яка версія правильна, і зупиняє злиття.

git merge feature
# CONFLICT (content): Merge conflict in app/Services/Billing.php
git status                       # список файлів з конфліктами

У файлі з'являються маркери:

<<<<<<< HEAD
$total = $subtotal * 1.2;
=======
$total = $subtotal + $this->tax($subtotal);
>>>>>>> feature

Що робити:

  1. Відредагувати файл: залишити потрібну версію, поєднати обидві чи написати третю. Прибрати всі маркери.
  2. Перевірити, що код працює: тести, запуск.
  3. Позначити конфлікт розв'язаним і завершити:
git add app/Services/Billing.php
git commit                      # для rebase: git rebase --continue

Передумали - повернути все як до злиття: git merge --abort (чи git rebase --abort).

Корисне:

  • git config --global merge.conflictStyle zdiff3 - маркери показують ще й спільного предка: видно не лише «що в кожній гілці», а й «що було до обох змін». Розв'язувати значно легше.
  • IDE (PhpStorm, VS Code) мають тристоронній редактор конфліктів.
  • Для файлів, які генеруються (composer.lock, package-lock.json), руками не мержать: беруть одну версію й заново виконують команду, що її створила.

Як конфліктів менше: короткоживучі гілки, часта інтеграція з main, невеликі pull request.

Докладніше в документації: git merge: розв'язання конфліктів

У Git три види гілок, і їх часто плутають:

Що Приклад Хто змінює
локальна гілка main, feature/export ви, комітами
віддалена гілка (remote-tracking) origin/main лише git fetch/pull/push - це «знімок» стану сервера на момент останньої синхронізації
гілка на сервері main у репозиторії на GitHub інші учасники через push

Upstream (відстежувана гілка) - зв'язок локальної гілки з віддаленою. Він дозволяє писати просто git push і git pull без параметрів і показує розбіжність:

git status
# Your branch is ahead of 'origin/main' by 2 commits.

Нова локальна гілка upstream не має - тому перший git push відповідає:

fatal: The current branch feature/export has no upstream branch.
To push the current branch and set the remote as upstream, use
    git push --set-upstream origin feature/export
git push -u origin feature/export   # -u = --set-upstream

Після цього git push і git pull працюють без аргументів.

Автоматизація: з git config --global push.autoSetupRemote true перший git push сам створює гілку на сервері й налаштовує upstream.

Корисні команди:

git branch -vv                       # усі гілки з їхнім upstream і розбіжністю
git branch -u origin/main            # задати upstream для поточної гілки
git switch feature/export            # якщо локальної гілки немає, а origin/feature/export є -
                                     # Git створить її з upstream автоматично
git fetch --prune                    # прибрати origin/* гілки, видалені на сервері

Типова плутанина: origin/main не оновлюється сам. Якщо git status каже «up to date with 'origin/main'», це означає «збігається з тим, що ви бачили під час останнього fetch», а не з поточним станом сервера.

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

Форк - ваша копія чужого репозиторію на GitHub. Через форк вносять зміни в проєкти, куди немає прав на запис: так працює більшість open source, включно з Laravel.

Схема з двома віддаленими репозиторіями:

git clone git@github.com:olena/framework.git          # ваш форк - це origin
cd framework
git remote add upstream https://github.com/laravel/framework.git
git remote -v
Remote Що це Права
origin ваш форк читання й запис
upstream оригінальний репозиторій лише читання

Робочий цикл:

git fetch upstream
git switch -c fix/route-cache upstream/13.x      # гілка від актуального стану оригіналу
# ... зміни, коміти ...
git push -u origin fix/route-cache               # у свій форк

Далі на GitHub - pull request з olena:fix/route-cache у laravel:13.x.

Синхронізація форку:

git fetch upstream
git switch main
git merge --ff-only upstream/main
git push origin main

Або кнопка «Sync fork» на сторінці форку на GitHub, або gh repo sync.

Правила, що позбавляють проблем:

  • не комітьте в main форку - тримайте його точною копією upstream/main, а кожну зміну робіть в окремій гілці. Тоді синхронізація завжди fast-forward;
  • гілку для pull request створюйте від свіжого upstream/<цільова гілка>, а не від застарілого main форку;
  • якщо оригінал просунувся під час рев'ю - git fetch upstream && git rebase upstream/13.x і git push --force-with-lease;
  • цільова гілка має значення: у Laravel виправлення йдуть у гілку поточної версії, а нові можливості - в master; це описано в правилах внеску.

Дозвіл супровідникам («Allow edits by maintainers») на pull request дає їм змогу дописати дрібні виправлення прямо у вашу гілку.

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

Ситуація: ви зробили коміт у main, а колега тим часом відправив свій. Тепер у вас є коміт, якого немає на сервері, а на сервері - коміт, якого немає у вас. Гілки розійшлися:

         C   (ваш)
        /
A ── B
        \
         D   (origin/main)

Сучасний Git при git pull у такому разі не обирає спосіб сам, а просить вирішити:

hint: You have divergent branches and need to specify how to reconcile them.
fatal: Need to specify how to reconcile divergent branches.

Три варіанти:

Налаштування Що відбувається Результат
pull.rebase false злиття коміт злиття Merge branch 'main' of ...
pull.rebase true rebase ваш коміт C переноситься поверх D, історія лінійна
pull.ff only лише fast-forward при розбіжності pull відмовляється, і ви вирішуєте вручну
git config --global pull.rebase true
# або разово
git pull --rebase

Що обрати для локальних комітів у спільній гілці - зазвичай rebase:

  • ваші ще не відправлені коміти просто «перебудовуються» поверх свіжих змін;
  • історія не засмічується безглуздими комітами Merge branch 'main' of github.com:..., які нічого не означають, крім «ми з колегою працювали одночасно».

Коли rebase при pull не підходить:

  • ваші локальні коміти - це злиття іншої гілки (rebase їх розгорне, якщо не вказати --rebase=merges);
  • конфліктів дуже багато: rebase розв'язує їх коміт за комітом, злиття - один раз.

Корисно разом з rebase: git config --global rebase.autoStash true - незакомічені зміни автоматично ховаються в stash перед rebase й повертаються після.

Якщо щось пішло не так: git rebase --abort під час rebase, git reflog - після.

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

Після rebase чи commit --amend історія локальної гілки розходиться з віддаленою, і звичайний git push відмовляється. git push --force перезаписує віддалену гілку вашою версією безумовно.

Чим це небезпечно: якщо колега встиг відправити в цю гілку свої коміти, force push їх знищить на сервері - ви навіть не побачите, що вони там були.

--force-with-lease перезаписує гілку, лише якщо вона на сервері досі в тому стані, який ви бачили під час останнього fetch. Якщо хтось додав коміти - push відхиляється, і ви спершу інтегруєте їхні зміни.

git rebase main
git push --force-with-lease

Пастка: «той стан, що ви бачили» - це ваш origin/feature. Якщо ви зробили git fetch (чи IDE зробила його у фоні), не подивившись, що прийшло, lease вважатиме нові чужі коміти «баченими». Суворіший варіант - --force-if-includes (Git 2.30+), який вимагає, щоб віддалені коміти були інтегровані в вашу гілку.

Правила команди:

  • Force push лише у свої гілки. У main, develop, релізні гілки - ніколи.
  • Захищені гілки на GitHub/GitLab забороняють force push і вимагають pull request з рев'ю.
  • Якщо над гілкою працює кілька людей - домовитися перед переписуванням історії або обійтися merge.

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

GitHub Flow - найпоширеніший для веб-застосунків:

  • main завжди готовий до деплою;
  • кожна задача - коротка гілка від main;
  • pull request, рев'ю, CI → злиття в main → деплой.

Простий і добре працює з безперервним розгортанням.

Git Flow - для продуктів з версіями й релізами:

  • main - лише релізи, develop - інтеграція;
  • feature/* від develop, release/* для підготовки версії, hotfix/* від main для термінових виправлень.

Підходить, коли одночасно підтримують кілька версій (бібліотеки, коробкове ПЗ, мобільні застосунки з рев'ю в сторах). Для веб-сервісу з кількома деплоями на день - надто важкий: багато злиттів, довгоживучі гілки.

Trunk-based development - усі інтегрують зміни в головну гілку дуже часто, щодня чи кілька разів на день:

  • гілки живуть години, а не тижні (або коміт одразу в trunk);
  • незавершені фічі ховають за feature flags, а не тримають у гілці;
  • потрібні сильні автотести й CI.

Мінімум конфліктів злиття і швидкий зворотний зв'язок, але потребує дисципліни й інфраструктури.

Як обирати: веб-продукт з частими деплоями - GitHub Flow чи trunk-based. Кілька підтримуваних версій - щось ближче до Git Flow. Головне - не модель, а короткоживучі гілки: чим довше гілка живе окремо, тим болючіше її злиття.

Докладніше в документації: Робочі процеси з гілками

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

git worktree add ../app-hotfix hotfix/invoice-rounding
git worktree add -b review/pr-412 ../app-review origin/feature/export
git worktree list
git worktree remove ../app-hotfix

Сценарій: ви посеред великої задачі з десятком змінених файлів, і терміново потрібно виправити баг у продакшені.

  • через stash: сховати зміни, перемкнутися, виправити, повернутися, дістати зі stash. Каталог vendor/ і node_modules/ можуть не відповідати гілці, запущений npm run dev перезбирає не той код, IDE переіндексовує проєкт;
  • через worktree: відкрити окремий каталог з гілкою виправлення. Основна робота лишається недоторканою, можна навіть паралельно запустити тести в обох каталогах.

Коли worktree зручний:

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

Обмеження:

  • одна гілка - один worktree: не можна перемкнутися на гілку, вже відкриту в іншому worktree (Git захищає від двох незалежних змін однієї гілки);
  • ігноровані файли не спільні: vendor/, node_modules/, .env у новому каталозі відсутні - їх доведеться встановити чи скопіювати;
  • для Laravel: окремий каталог означає окремий .env; якщо обидва worktree використовують ту саму базу, міграції однієї гілки зачеплять іншу;
  • видаляйте worktree через git worktree remove, а не rm -rf - інакше лишаються записи (git worktree prune їх прибирає).

Порівняно з другим клоном worktree не дублює історію (економить місце й час) і бачить локальні гілки й коміти основного репозиторію без fetch.

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

Поки ви працюєте над задачею, main просувається. Чим довше гілка живе окремо, тим більше конфліктів і тим вищий ризик, що код, який працює в гілці, зламається після злиття. Тому гілку періодично оновлюють.

Варіант 1 - злити main у гілку:

git fetch origin
git merge origin/main
  • історія гілки не переписується - безпечно, навіть якщо в гілці працюють кілька людей;
  • push без force;
  • конфлікти розв'язуються один раз;
  • в історії з'являються коміти злиття Merge branch 'main' into feature - при squash-злитті pull request вони зникнуть.

Варіант 2 - rebase на main:

git fetch origin
git rebase origin/main
git push --force-with-lease
  • лінійна історія: гілка виглядає так, ніби її почали щойно;
  • коміти отримують нові хеші, потрібен force push;
  • конфлікти розв'язуються для кожного коміту окремо - на довгій гілці це може повторюватися (допомагає rerere);
  • небезпечно, якщо гілкою користуються інші: їхні локальні копії розійдуться з вашою.

Як обрати:

Ситуація Що краще
особиста гілка, коміти ще не рев'ювали rebase
у гілці працюють кілька людей merge
pull request уже на рев'ю merge - рецензенти бачать, що змінилося після їхніх коментарів (після rebase GitHub гірше показує різницю)
злиття в main буде через squash будь-який, історія гілки все одно зникне

Як часто: невеликими кроками - щодня чи перед кожним push, а не раз на тиждень. Конфлікт у двох файлах розв'язати легше, ніж у двадцяти.

Найкращий захист від болісного оновлення - короткоживучі гілки: задача на 1-3 дні дає значно менше розбіжностей, ніж гілка, що живе місяць.

На GitHub кнопка «Update branch» у pull request робить merge (чи rebase, якщо обрати). Branch protection з вимогою «Require branches to be up to date» змушує оновлювати гілку перед злиттям.

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

Lock-файли змінюються майже при кожній зміні залежностей, і дві гілки, що додали по пакету, гарантовано конфліктують. Розв'язувати такий конфлікт вручну, редагуючи JSON, не можна: у composer.lock є content-hash - хеш вмісту composer.json, і ручне злиття майже завжди дає невідповідність або неузгоджене дерево залежностей.

Правильний спосіб для Composer:

  1. розв'язати конфлікт у composer.json - він короткий і зрозумілий людині;
  2. взяти composer.lock з основної гілки;
  3. повторити зміни своєї гілки командою Composer:
git checkout --theirs composer.lock     # при rebase - --ours, див. нижче
composer update vendor/added-package    # лише пакети, які додала ваша гілка
# або, якщо змінювали лише composer.json без нових пакетів:
composer update --lock
git add composer.json composer.lock

composer update лише для конкретних пакетів, а не всіх: повний composer update оновить усе дерево, і pull request раптом міститиме десятки чужих оновлень.

Для npm:

git checkout --theirs package-lock.json
npm install            # перебудує lock з урахуванням вашого package.json

npm також вміє сам розв'язувати конфлікти в package-lock.json: якщо запустити npm install при конфлікті лише в lock-файлі, він об'єднує обидві версії.

Увага до --ours і --theirs при rebase: під час rebase ролі міняються місцями - --ours означає гілку, на яку переносите (тобто main), а --theirs - ваші коміти. Тому при rebase версія main - це --ours.

Як зменшити кількість конфліктів:

  • оновлювати залежності окремими невеликими pull request, а не в гілках задач;
  • автоматизовані оновлення (Dependabot, Renovate) з частим злиттям;
  • перед роботою над задачею оновити гілку з main.

Перевірка після розв'язання:

composer validate          # чи відповідає lock-файл composer.json
composer install           # чи встановлюється дерево
php artisan test

Чого не робити: додавати lock-файли в .gitignore, щоб «не конфліктували». Для застосунку lock-файл - гарантія, що продакшен, CI і колеги отримують однакові версії пакетів.

Докладніше в документації: Composer: розв'язання конфліктів злиття

Тег - постійне ім'я для конкретного коміту, зазвичай версії: 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. Чистка історії не поверне контроль.

Порядок дій:

  1. Відкликати й замінити секрет: новий API-ключ, новий пароль бази, ротація токенів. Оновити його в усіх місцях, де він використовується.
  2. Перевірити журнали сервісу на підозріле використання за час, поки ключ був відкритий.
  3. Прибрати з коду і перенести секрет туди, де він має жити: .env (у .gitignore), менеджер секретів, змінні CI.
  4. За потреби - переписати історію, щоб секрет не лишався в старих комітах: 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 і на продакшен звичайним деплоєм, а відкат - повторним деплоєм попередньої версії.

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

Автор коміту в 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: перетворення закінчень рядків