Питання на співбесіді з Git
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Тег - постійне ім'я для конкретного коміту, зазвичай версії: 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не матиме що відправити.
~ і ^ рухаються по батьках коміту, але по-різному:
~n- n кроків назад по першому батькові:HEAD~1- батько,HEAD~3- прапрадід;^n- n-й батько коміту. Має сенс для merge-комітів, у яких батьків два:HEAD^1- гілка, у яку зливали,HEAD^2- гілка, яку зливали.
D---E (feature)
/ \
A---B---C---M (main, HEAD)
HEAD~1 = HEAD^ = HEAD^1 = C
HEAD^2 = E
HEAD~2 = B
HEAD^2~1 = D
Для звичайного коміту з одним батьком HEAD~1 і HEAD^ однакові - різниця видна лише на merge-комітах.
Інші способи назвати коміт:
main@{yesterday} # де була main вчора (за reflog)
@{-1} # попередня гілка
@{u} # upstream поточної гілки
HEAD^{tree} # дерево коміту
:/fix invoice # останній коміт з таким текстом у повідомленні
v2.4.0^{commit} # коміт, на який вказує тег
Діапазони:
| Запис | Що означає |
|---|---|
A..B |
коміти, досяжні з B, але не з A |
A...B |
коміти, досяжні з A або B, але не з обох (симетрична різниця) |
^A B |
те саме, що A..B |
git log main..feature # що є у feature і ще не злито в main
git log feature..main # що з'явилося в main, поки ви працювали
git log --left-right --oneline main...feature # обидві сторони з позначками < і >
git log origin/main..HEAD # що ви ще не запушили
Пастка: .. і ... у git diff означають інше:
git diff A..B- те саме, щоgit diff A B: порівняння двох знімків;git diff A...B- зміни вBвід спільного предка зA. Саме так GitHub показує diff pull request-у: лише зміни гілки, без того, що за цей час з'явилося вmain.
Тому git diff main...feature - правильний спосіб побачити «що змінює моя гілка», а git diff main feature покаже ще й чужі зміни в main з протилежним знаком.
Запис stash - це звичайні коміти, на які вказує ref refs/stash, а список stash@{n} - це reflog цього ref-а.
Структура одного запису:
.----W ← робочий каталог (сам запис stash@{0})
/ /|
H----I | H - HEAD на момент stash, I - стан індексу
U U - невідстежувані файли (лише з -u чи -a)
- W - merge-коміт з батьками
H,Iі, якщо був-u, -U; - окремий коміт для індексу дозволяє
git stash apply --indexвідновити, що саме було в індексі.
git cat-file -p stash@{0}
# tree ...
# parent 84ba035... ← H
# parent 48c2a34... ← I
# parent 84d625d... ← U (untracked files on main)
git show stash@{0}^3 # невідстежувані файли зі stash
Що з цього випливає:
git stash dropі успішнийpopвидаляють лише запис у reflogrefs/stash. Самі коміти лишаються в базі об'єктів, доки їх не прибереgit gc;- до stash можна звертатися як до будь-якого коміту:
git diff stash@{0}^1 stash@{0},git restore --source=stash@{0} -- file.php.
Відновлення видаленого stash:
Якщо щойно виконали pop чи drop, Git друкує хеш:
Dropped refs/stash@{0} (5c3e8a7f...)
git stash apply 5c3e8a7f
Якщо хеш загублено - шукати недосяжні коміти-сироти:
git fsck --no-reflog --unreachable | awk '/commit/ {print $3}' \
| xargs git log --no-walk --merges --format='%h %ci %s' | grep 'WIP on\|On '
Коміти stash - merge-коміти з повідомленнями «WIP on main» чи «On main: опис». Знайдений хеш - git stash apply <хеш> чи git branch rescue <хеш>.
Обмеження: після git gc (Git запускає його й автоматично) недосяжні об'єкти старші за gc.pruneExpire (за замовчуванням 2 тижні) видаляються остаточно.
git stash branch name stash@{1} - корисний для старих записів: створює гілку від коміту H, застосовує stash і видаляє запис, тож конфліктів з новим кодом не буде.
Псевдоніми (aliases) скорочують часті команди:
# ~/.gitconfig
[alias]
st = status -sb
co = switch
lg = log --graph --oneline --decorate --all
last = log -1 HEAD --stat
unstage = restore --staged
amend = commit --amend --no-edit
fixup = "!f() { git commit --fixup=$1 && GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $1~1; }; f"
gone = "!git fetch -p && git branch -vv | awk '/: gone]/ {print $1}' | xargs -r git branch -D"
- звичайний псевдонім підставляє аргументи команди
git; !на початку - виконати команду оболонки, з функцією для аргументів. Такі команди виконуються з кореня репозиторію;git goneвище видаляє локальні гілки, чиї віддалені версії вже видалено.
Власні команди git-*: будь-який виконуваний файл git-назва у PATH стає підкомандою git назва. Для складної логіки це краще за довгі рядки в конфігурації: скрипт можна версіонувати, тестувати й поширювати в команді.
# ~/bin/git-pr-checkout
#!/bin/sh
git fetch origin "pull/$1/head:pr-$1" && git switch "pr-$1"
Налаштування, що заощаджують час:
[pull]
rebase = true # pull без merge-комітів
[push]
autoSetupRemote = true # перший push сам створює upstream
[rebase]
autoStash = true # stash/unstash навколо rebase
autoSquash = true # fixup! коміти автоматично стають на місце
updateRefs = true # оновлювати залежні гілки в стеку
[rerere]
enabled = true # пам'ятати розв'язання конфліктів
[fetch]
prune = true # прибирати видалені віддалені гілки
[diff]
algorithm = histogram # зрозуміліші diff-и
colorMoved = default # підсвічувати переміщені рядки
[merge]
conflictStyle = zdiff3 # показувати базову версію в конфліктах
[branch]
sort = -committerdate # свіжі гілки першими
[help]
autocorrect = prompt # пропонувати виправлення опечаток
Що враховувати:
- псевдоніми й налаштування персональні: в інструкціях для команди і в скриптах CI використовуйте повні стандартні команди;
zdiff3,updateRefs,autoSetupRemoteз'явилися у відносно нових версіях Git - перевірте версію на всіх машинах команди;- dotfiles-репозиторій з
~/.gitconfigдає однакове середовище на всіх ваших машинах.
Логічно кожна версія файлу - окремий blob з повним вмістом. Новий об'єкт спершу записується як loose object - окремий файл у .git/objects/xx/, стиснений zlib. Для файлу на 20 КБ, зміненого в 100 комітах, це 100 майже однакових копій.
Packfile вирішує це на рівні зберігання. git gc (і передача по мережі) збирає об'єкти в один файл:
.git/objects/pack/
├── pack-4f2a....pack ← самі об'єкти
├── pack-4f2a....idx ← індекс: хеш → зміщення в pack
├── pack-4f2a....rev ← зворотний індекс (зміщення → позиція)
└── pack-4f2a....bitmap ← бітмапи досяжності (для швидкого clone/fetch)
Дельта-стиснення: частина об'єктів зберігається не повністю, а як дельта від іншого об'єкта - інструкції «скопіювати байти з базового об'єкта» й «вставити нові байти».
git verify-pack -v .git/objects/pack/pack-*.idx | sort -k3 -n | tail
# хеш тип розмір розмір-у-pack зміщення [глибина база]
Особливості, що відрізняють Git від «diff між версіями»:
- база обирається евристично, а не за історією: Git сортує об'єкти за типом, ім'ям файлу й розміром і шукає схожі у ковзному вікні. Дельта може будуватися від файлу з іншої гілки чи навіть з іншим ім'ям;
- зазвичай база - новіша версія, а старі зберігаються як дельти від неї: свіжі файли, які читають найчастіше, дістаються без розпакування ланцюжка;
- ланцюжки дельт обмежені глибиною (
pack.depth, за замовчуванням 50) - баланс між розміром і швидкістю читання; - логічна модель не змінюється: для всіх команд об'єкт - повний знімок, дельти - лише деталь зберігання.
Передача по мережі: при fetch і push сторони узгоджують, які коміти вже є в отримувача, і сервер формує тонкий pack (thin pack) - дельти можуть посилатися на об'єкти, що вже є в клієнта. Тому fetch після невеликих змін передає кілобайти.
Практичні наслідки:
- бінарні файли (зображення, архіви, дампи) погано стискаються дельтами - кожна версія займає майже повний розмір, і репозиторій росте назавжди. Звідси Git LFS;
git gc --aggressiveперебудовує дельти з більшим вікном - повільно, і для більшості репозиторіїв виграш мізерний;git count-objects -vHпоказує, скільки займають loose objects і pack-и: велика кількість loose objects - сигнал, що автоматичне обслуговування не запускається.
У коміті немає запису «файл перейменовано». Tree лише містить ім'я й хеш: у старому дереві є big.txt, у новому - big2.txt. Перейменування Git обчислює заново щоразу, коли показує diff, log чи виконує злиття.
Алгоритм:
- знайти пари «видалений файл» і «доданий файл»;
- точні збіги - однаковий хеш blob-а: це перейменування зі 100% схожістю (
R100). Перевіряється дешево; - для решти - оцінити схожість вмісту кожної пари і вважати перейменуванням, якщо вона вища за поріг. Поріг за замовчуванням - 50%.
git diff -M HEAD~1 --name-status
# R100 big.txt big2.txt
# R087 app/Helpers.php app/Support/Helpers.php
git diff -M80% HEAD~1 # суворіший поріг
git diff -C HEAD~1 # шукати ще й копії
git diff --no-renames HEAD~1 # показати як видалення + додавання
Чому git mv нічого не гарантує: він лише видаляє й додає файл в індексі. Якщо в тому самому коміті файл перейменовано і суттєво змінено, схожість опуститься нижче порогу - і Git покаже видалення одного файлу й створення іншого. Звідси практика: перейменування окремим комітом, зміни вмісту - наступним.
Де це впливає на роботу:
git log --follow fileстежить за історією файлу через перейменування - але лише для одного файлу й саме евристикою;git blameзнаходить рядки, переміщені з інших файлів, з-C;- злиття: якщо в одній гілці файл перейменовано, а в іншій змінено, стратегія
ortзнаходить перейменування і застосовує зміни до нового імені. Невдале визначення дає конфлікт «deleted by us / modified by them»; - ліміт кандидатів: порівняння - квадратична задача. Якщо видалених і доданих файлів забагато, Git пропускає неточне визначення (
diff.renameLimit,merge.renameLimit) і попереджає про це. Після масового переміщення файлів зручно підняти ліміт.
Плюс підходу: перейменування «знаходиться» навіть якщо його зробили без git mv - просто через IDE чи mv - і навіть ретроспективно для старої історії, коли алгоритм покращується з новими версіями Git.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії