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

Питання на співбесіді з Git

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

100 питань

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

Рівень Файл Прапорець
system /etc/gitconfig (чи в каталозі встановлення) --system
global ~/.gitconfig чи ~/.config/git/config --global
local .git/config репозиторію --local (за замовчуванням)
worktree .git/config.worktree --worktree
команда git -c key=value ... -
git config --global user.name "Olena Kovalenko"
git config user.email olena@company.com          # лише цей репозиторій
git config --list --show-origin                  # усі значення і звідки вони
git config --show-origin --get user.email

--show-origin - перший інструмент, коли «налаштування не працює»: видно, який файл перекрив значення.

Умовні включення - різні налаштування для роботи й особистих проєктів:

# ~/.gitconfig
[user]
    name = Olena Kovalenko
    email = olena@personal.dev

[includeIf "gitdir:~/work/"]
    path = ~/.gitconfig-work
# ~/.gitconfig-work
[user]
    email = olena@company.com
    signingkey = ~/.ssh/work_ed25519.pub

Усі репозиторії в ~/work/ автоматично отримують робочу пошту - більше ніяких комітів з особистою адресою в корпоративному репозиторії.

Умови includeIf:

  • gitdir: - шлях до репозиторію (gitdir/i: - без урахування регістру);
  • onbranch: - поточна гілка;
  • hasconfig:remote.*.url: - адреса віддаленого репозиторію, наприклад усі репозиторії з github.com/company/**.

Що важливо:

  • слеш у кінці gitdir:~/work/ означає «цей каталог і все всередині»;
  • .git/config не комітиться і не поширюється через клон - спільні налаштування команди передаються інакше (документація, скрипт налаштування, .gitattributes і .editorconfig у репозиторії);
  • у CI змінні оточення GIT_AUTHOR_NAME, GIT_COMMITTER_EMAIL перекривають конфігурацію.

Докладніше в документації: git config: умовні включення

Буває, що в одному файлі змішалися дві задачі: виправлення помилки й рефакторинг. git add -p (--patch) дозволяє додати в індекс лише окремі фрагменти (hunks) змін і зробити з них два чисті коміти.

git add -p app/Services/InvoiceService.php

Git показує кожен фрагмент і питає, що з ним робити:

@@ -42,7 +42,7 @@ public function total(): int
-        return $this->lines->sum('price');
+        return $this->lines->sum(fn ($line) => $line->price * $line->quantity);
(1/3) Stage this hunk [y,n,q,a,d,s,e,?]?

Основні відповіді:

Клавіша Дія
y / n додати / пропустити фрагмент
s розбити фрагмент на менші (якщо між змінами є незмінені рядки)
e відредагувати фрагмент вручну - для змін у сусідніх рядках
a / d додати / пропустити всі решта фрагменти файлу
q вийти

Типовий процес:

git add -p                         # обрати фрагменти виправлення
git diff --staged                  # перевірити, що в індексі
git commit -m "Fix invoice total for multiple quantities"
git add -p                         # тепер рефакторинг
git commit -m "Extract line total calculation"

Той самий режим є в інших командах:

  • git restore -p - відкинути частину змін у робочому каталозі;
  • git restore --staged -p - прибрати частину з індексу;
  • git stash push -p - відкласти лише вибрані фрагменти;
  • git commit -p - обрати фрагменти й одразу закомітити.

Про що пам'ятати:

  • нові файли add -p не показує, бо Git їх ще не відстежує. Спершу git add -N файл (intent-to-add) - тоді вміст з'явиться як фрагменти;
  • проміжний стан може не працювати: закомічена половина змін не перевірялася окремо. Перед пушем корисно прогнати тести на кожному коміті чи хоча б на останньому;
  • IDE (PhpStorm, VS Code) дають той самий механізм через галочки біля рядків - під капотом те саме часткове додавання в індекс.

Докладніше в документації: git add: інтерактивний режим

git checkout історично робить дві принципово різні речі:

git checkout feature        # перемкнути гілку
git checkout -- config.php  # відкинути зміни у файлі (безповоротно!)

Одна команда і для безпечної операції, і для руйнівної. git checkout name перемкне гілку, якщо гілка name існує, а якщо ні, але є такий файл - мовчки перезапише файл. Саме через це в Git 2.23 з'явилися дві вузькі команди.

git switch - лише гілки:

git switch main
git switch -c feature/export        # створити й перемкнутися
git switch -c fix origin/fix        # нова локальна гілка від віддаленої
git switch -                        # повернутися на попередню гілку
git switch --detach v2.4.0          # явно перейти в detached HEAD

git restore - лише файли:

git restore config.php                    # відкинути зміни в робочому каталозі
git restore --staged config.php           # прибрати з індексу, зміни лишаються
git restore --source=HEAD~3 config.php    # взяти версію файлу з іншого коміту
git restore --staged --worktree .         # повністю повернути все до HEAD

Відповідність старих і нових команд:

Було Стало
git checkout branch git switch branch
git checkout -b new git switch -c new
git checkout -- file git restore file
git reset HEAD file git restore --staged file
git checkout abc123 -- file git restore --source=abc123 file

Чому це важливо:

  • намір явний: switch ніколи не чіпає незакомічені зміни у файлах без потреби, а restore ніколи не перемикає гілку;
  • detached HEAD не трапляється випадково: git switch v2.4.0 відмовиться працювати з тегом без --detach, тоді як checkout мовчки перейде в detached HEAD;
  • git status сам підказує нові команди («use git restore...»).

git checkout нікуди не дівся і працює, як раніше, - у скриптах і старих інструкціях він зустрічатиметься ще довго. Але для щоденної роботи switch і restore безпечніші.

Обережно: git restore файл так само безповоротно відкидає незакомічені зміни, як і checkout --. Їх немає ні в reflog, ні в stash.

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

.gitignore діє лише на невідстежувані файли. Якщо файл уже закомічено, Git продовжує відстежувати його зміни, хоч би що було написано в .gitignore.

Типова ситуація: .env чи .idea/ випадково потрапили в репозиторій, потім їх додали в .gitignore - а git status все одно показує зміни.

Рішення - прибрати файл з індексу, не видаляючи з диска:

git rm --cached .env
git rm -r --cached .idea/
git commit -m "Stop tracking local environment files"
  • --cached видаляє файл лише з індексу - на диску він лишається;
  • після коміту файл стає невідстежуваним, і тепер .gitignore на нього діє.

Пастка для команди: у колег після git pull цей файл буде видалено з диска, бо для Git коміт означає «файл видалено». Їхній локальний .env зникне. Попередьте команду або попросіть зберегти копію перед оновленням.

Чим відрізняються варіанти:

Команда Індекс Диск
git rm file видаляє видаляє
git rm --cached file видаляє залишає
rm file залишає (до git add) видаляє

Масово привести репозиторій у відповідність з .gitignore:

git rm -r --cached .
git add .
git commit -m "Apply .gitignore"

Перед комітом обов'язково перегляньте git status - так можна випадково прибрати потрібні файли.

Перейменування - git mv:

git mv app/Helpers.php app/Support/Helpers.php

Це те саме, що mv + git rm старого + git add нового. Git не зберігає факт перейменування - він визначає його при порівнянні за схожістю вмісту.

Якщо файл містив секрет - git rm --cached прибирає його лише з наступних комітів. В історії він лишається, і секрет треба вважати скомпрометованим: змінити ключі, а історію чистити окремими інструментами.

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

git clean видаляє з робочого каталогу файли, які Git не відстежує: тимчасові файли, результати збирання, згенерований код, залишки експериментів.

Головне - ці файли не можна відновити засобами Git. Їх немає в жодному коміті, reflog чи stash. Тому git clean за замовчуванням навіть відмовляється працювати без явного прапорця (clean.requireForce=true).

Безпечний порядок - спершу подивитися:

git clean -n          # dry run: що буде видалено
git clean -nd         # разом з невідстежуваними каталогами
git clean -f          # видалити файли
git clean -fd         # файли й каталоги

Прапорці:

Прапорець Що робить
-n лише показати (dry run)
-f справді видалити
-d включно з каталогами
-x також проігноровані файли (.env, vendor/, node_modules/)
-X лише проігноровані файли
-i інтерактивний вибір
-e <шаблон> додатково виключити шаблон

-x - найнебезпечніший прапорець. git clean -fdx у Laravel-проєкті видалить .env, vendor/, node_modules/, storage/*.key, локальну базу SQLite - усе, що ігнорується. Корисно для «чистого» збирання в CI, але вдома варто запускати лише після -n.

git clean -fdx -e .env -n     # усе, окрім .env, - спершу перевірка

Інтерактивний режим зручний, коли більшість файлів треба видалити, а кілька - залишити:

git clean -id

Типові сценарії:

  • повернути репозиторій до стану щойно клонованого: git restore --staged --worktree . (відстежувані) + git clean -fd (невідстежувані);
  • прибрати лише результати збирання, не чіпаючи нові вихідні файли: git clean -fX;
  • CI на повторно використовуваному runner-і - git clean -ffdx перед збиранням (подвійний -f видаляє й вкладені репозиторії).

Альтернатива без ризику: якщо не впевнені, git stash -u прибере невідстежувані файли, але збереже їх у stash.

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

Команди Git поділяються на два рівні:

  • porcelain («порцеляна») - команди для людей: commit, switch, merge, log. Їхній вивід і поведінка можуть змінюватися між версіями;
  • plumbing («сантехніка») - низькорівневі команди, з яких побудовано porcelain: читання й запис об'єктів, оновлення ref-ів. Їхній вивід стабільний - на них розраховані скрипти.

Коміт вручну - те, що робить git commit під капотом:

# 1. записати вміст файлу як blob
echo 'Hello' | git hash-object -w --stdin
# e965047ad7c57865823c7d992b1d046ea66edf78

# 2. додати blob в індекс під ім'ям
git update-index --add --cacheinfo 100644,e965047ad7c57865823c7d992b1d046ea66edf78,hello.txt

# 3. записати індекс як tree
git write-tree
# 7b3ce6f...

# 4. створити коміт з цим деревом і батьком
echo 'Add hello' | git commit-tree 7b3ce6f -p HEAD
# 4a1f2c9...

# 5. пересунути гілку на новий коміт
git update-ref refs/heads/main 4a1f2c9

Робочий каталог при цьому взагалі не використано: файл hello.txt на диску не з'являвся.

Основні plumbing-команди:

Команда Що робить
hash-object обчислити хеш, з -w - записати blob
cat-file прочитати об'єкт
ls-tree, ls-files вміст дерева й індексу
write-tree, commit-tree створити tree й коміт
update-ref, symbolic-ref змінити ref безпечно, з записом у reflog
rev-parse перетворити ім'я (гілка, HEAD~2) на хеш
for-each-ref перелік ref-ів у заданому форматі
merge-tree злиття без робочого каталогу

Де це корисно на практиці:

  • скрипти й CI: git rev-parse --abbrev-ref HEAD, git for-each-ref --format='%(refname:short)' refs/heads/ замість розбору виводу git branch, який може змінитися;
  • операції без робочого каталогу: сервери Git (GitHub, GitLab) роблять злиття й коміти через merge-tree і commit-tree, не створюючи файлів на диску;
  • розуміння: знаючи plumbing, легко пояснити, що роблять rebase, stash, cherry-pick, - вони лише комбінують ці кроки.

Порада для скриптів: використовуйте porcelain-команди з --porcelain-виводом (git status --porcelain=v2) або plumbing - і ніколи не розбирайте вивід, призначений для людей.

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

git cat-file - інструмент для читання будь-якого об'єкта з бази:

git cat-file -t a1b2c3d     # тип: blob, tree, commit, tag
git cat-file -s a1b2c3d     # розмір у байтах
git cat-file -p a1b2c3d     # вміст у зручному вигляді
git cat-file -e a1b2c3d     # лише перевірити існування (код виходу)

Подорож від коміту до файлу:

git cat-file -p HEAD
# tree 3c4e9cd...
# parent 84ba035...
# ...

git cat-file -p 3c4e9cd           # кореневе дерево
# 100644 blob 1f7a7a4...  composer.json
# 040000 tree 8e2b7f1...  app

git cat-file -p 8e2b7f1           # дерево каталогу app/

git cat-file -p HEAD:composer.json   # одразу вміст файлу в коміті

Blob - лише вміст файлу. У ньому немає імені, прав чи дати: файли з однаковим вмістом у різних місцях і різних комітах - один blob. Перейменування файлу не створює нового blob-а, лише змінює запис у tree.

Хеш blob-а - SHA-1 від заголовка й вмісту: blob <розмір>\0<вміст>. Тому його можна обчислити й без Git:

printf 'Hello\n' | git hash-object --stdin
printf 'blob 6\0Hello\n' | shasum
# обидві команди дають e965047ad7c57865823c7d992b1d046ea66edf78

Tree - відсортований список записів режим тип хеш ім'я. Він відповідає одному каталогу й посилається на blob-и (файли) і інші tree (підкаталоги).

Що випливає зі структури:

  • зміна одного файлу в глибокому каталозі створює новий blob, нові tree для кожного каталогу на шляху до кореня й новий коміт. Усі інші tree й blob-и перевикористовуються - коміт великого проєкту з однією зміною займає кілька сотень байтів;
  • порівняння двох комітів дешеве: однакові хеші піддерев означають однакові каталоги, і Git не заходить у них;
  • ім'я і вміст розділені, тож Git дізнається про перейменування лише порівнянням.

Масова робота: git cat-file --batch і --batch-check читають багато об'єктів за один процес - так працюють інструменти аналізу репозиторіїв (git cat-file --batch-all-objects --batch-check - перелік усіх об'єктів з розмірами, щоб знайти найбільші).

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

.git/index - бінарний файл, що описує знімок для наступного коміту. Для кожного файлу він зберігає:

  • шлях, режим і хеш blob-а вмісту;
  • дані stat з файлової системи: розмір, час зміни (mtime, ctime), inode, пристрій, uid/gid;
  • номер стадії (stage) - для конфліктів злиття.
git ls-files --stage
# 100644 1f7a7a4... 0	composer.json
# 100644 e9c3d2b... 0	app/Models/User.php

git ls-files --debug app/Models/User.php   # разом з даними stat

Як git status працює швидко. Обчислювати хеш кожного файлу в проєкті з десятками тисяч файлів було б повільно. Тому Git спершу порівнює дані stat:

  1. якщо розмір і час зміни файлу збігаються з тими, що в індексі, - файл вважається незмінним без читання вмісту;
  2. лише для файлів з іншими stat Git читає вміст, хешує й порівнює з blob-ом в індексі;
  3. якщо вміст той самий (наприклад, файл перезбережено без змін), Git оновлює дані stat в індексі, щоб наступного разу не перевіряти.

«Racy git» - файл змінено в ту саму секунду, що й записано індекс: час збігається, хоча вміст інший. Git розпізнає цей випадок і перевіряє такі файли за вмістом.

Стадії при конфлікті: для конфліктного файлу індекс містить до трьох записів - стадія 1 (спільний предок), 2 (наша версія), 3 (їхня). git add після розв'язання замінює їх одним записом стадії 0.

git ls-files -u            # конфліктні записи
git show :2:config/app.php # наша версія
git show :3:config/app.php # їхня версія

Прапорці в індексі:

  • git update-index --assume-unchanged file - обіцянка Git не перевіряти файл (оптимізація для повільних файлових систем);
  • git update-index --skip-worktree file - ігнорувати локальні зміни відстежуваного файлу (локальна конфігурація). Обидва - локальні й не передаються іншим;
  • git ls-files -v показує такі файли малими літерами.

Для великих репозиторіїв: core.untrackedCache, core.fsmonitor і index.version=4 (стиснення шляхів) зменшують час status з секунд до мілісекунд.

Пошкоджений індекс («index file corrupt») не означає втрату історії: rm .git/index && git reset відтворить його з HEAD, зберігши файли робочого каталогу.

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

Refspec - правило відповідності між ref-ами віддаленого репозиторію і вашого локального. Його формат:

+<джерело>:<призначення>
  • джерело - ref на тому боці, звідки беремо;
  • призначення - куди записати локально;
  • + - дозволити оновлення без fast-forward (наприклад, після force push на сервері).

Refspec за замовчуванням записується при git clone чи git remote add:

# .git/config
[remote "origin"]
    url = git@github.com:acme/shop.git
    fetch = +refs/heads/*:refs/remotes/origin/*

Читається так: «усі гілки сервера (refs/heads/*) записати як refs/remotes/origin/*». Звідси й з'являються origin/main, origin/feature-x - це ваші локальні копії стану гілок сервера на момент останнього fetch.

Що можна налаштувати:

# завантажувати лише main і релізні гілки
fetch = +refs/heads/main:refs/remotes/origin/main
fetch = +refs/heads/release/*:refs/remotes/origin/release/*

# додатково отримувати pull request-и GitHub
fetch = +refs/pull/*/head:refs/remotes/origin/pr/*

Після цього git fetch створить origin/pr/142 для кожного PR, і будь-який з них можна переглянути локально.

Refspec у командах:

git fetch origin main                    # лише main, результат у FETCH_HEAD і origin/main
git fetch origin pull/142/head:pr-142    # PR одразу в локальну гілку pr-142
git push origin feature:review/feature   # запушити локальну feature під іншим ім'ям
git push origin :old-branch              # порожнє джерело - видалити гілку на сервері
git push origin HEAD:refs/for/main       # Gerrit: відправити на рецензію

Push теж використовує refspec: git push origin main - скорочення для main:main. Налаштування push.default визначає, що відправляє git push без аргументів (simple - поточну гілку в однойменну upstream).

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

  • origin/main оновлюється лише при fetch/pull, а не сам по собі - тому «origin/main відстає від GitHub» лікується git fetch;
  • fetch --prune (чи fetch.prune=true) видаляє origin/* для гілок, видалених на сервері;
  • у CI з великим репозиторієм вузький refspec (лише потрібна гілка) суттєво зменшує обсяг завантаження.

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

Ref - ім'я, що вказує на об'єкт (зазвичай коміт). Класичне зберігання - один файл на ref:

.git/refs/heads/main            → 23933defbcb6...
.git/refs/remotes/origin/main   → 23933defbcb6...
.git/refs/tags/v2.4.0           → 9fceb02d0ae5...

Просто й наочно, але з тисячами гілок і тегів (типово для великих проєктів і серверів) з'являються проблеми: тисячі дрібних файлів, повільний перелік, а на файлових системах без урахування регістру (macOS, Windows) гілки Feature і feature конфліктують.

packed-refs - багато ref-ів одним текстовим файлом:

git pack-refs --all
cat .git/packed-refs
# # pack-refs with: peeled fully-peeled sorted
# 84ba0356e619... refs/heads/f
# 23933defbcb6... refs/heads/main
# 9fceb02d0ae5... refs/tags/v2.4.0
# ^4a1f2c9b7e3d...    ← «peeled»: коміт, на який вказує анотований тег

Як Git шукає ref: спершу окремий файл у refs/, потім packed-refs. Окремий файл має пріоритет - тому оновлення гілки просто створює новий файл, не переписуючи packed-refs. git gc періодично пакує ref-и автоматично.

Наслідок для скриптів: cat .git/refs/heads/main може не знайти файл - гілка є, але упакована. Правильно - через команди:

git rev-parse refs/heads/main
git for-each-ref --format='%(refname) %(objectname)' refs/tags/
git update-ref refs/heads/main <хеш>     # атомарно, із записом у reflog

Атомарність: оновлення ref-а йде через lock-файл (main.lock) і перейменування. Звідси повідомлення Unable to create '.git/refs/heads/main.lock': File exists - залишок після аварійно завершеного процесу Git; якщо інших процесів Git немає, файл можна видалити.

Reftable - новий бінарний формат зберігання ref-ів (з Git 2.45), створений у JGit для серверів з мільйонами ref-ів:

  • атомарні транзакції для багатьох ref-ів одночасно;
  • швидкий пошук і перелік, без проблем з регістром імен;
  • reflog у тому самому сховищі.
git init --ref-format=reftable
git refs migrate --ref-format=reftable    # перетворити наявний репозиторій

Для звичайного проєкту різниця непомітна, але у великих монорепозиторіях і на серверах reftable прибирає вузьке місце з тисячами ref-ів.

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

Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 35 Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії