Питання на співбесіді: Внутрішня будова Git
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
Каталог .git - це і є репозиторій. Робочий каталог лише показує одну з версій. Скопіюйте .git - і отримаєте всю історію; видаліть - і залишаться тільки поточні файли без історії.
.git/
├── HEAD ← на що зараз вказує HEAD (зазвичай ref: refs/heads/main)
├── config ← налаштування цього репозиторію, remotes, гілки
├── index ← індекс (staging area), бінарний файл
├── objects/ ← база об'єктів: blob, tree, commit, tag
│ ├── 1a/2485... ← окремі стиснені об'єкти (loose objects)
│ └── pack/ ← упаковані об'єкти (packfiles)
├── refs/
│ ├── heads/ ← локальні гілки
│ ├── remotes/ ← віддалені гілки (origin/main)
│ └── tags/ ← теги
├── packed-refs ← багато ref-ів одним файлом
├── logs/ ← reflog: історія переміщень HEAD і гілок
├── hooks/ ← скрипти хуків (*.sample - приклади)
└── info/exclude ← персональні ігноровані шаблони
Найважливіше:
objects/- увесь вміст: кожна версія кожного файлу, кожен каталог, кожен коміт. Імена файлів - хеші вмісту, перші два символи хешу - назва підкаталогу;refs/іpacked-refs- імена: гілки й теги - це лише файли з хешем коміту;HEAD- де ви зараз;index- що піде в наступний коміт;logs/- страховка: звідсиgit reflogвідновлює «втрачені» коміти.
Що можна побачити самостійно:
cat .git/HEAD
cat .git/refs/heads/main # хеш останнього коміту (якщо не упаковано)
find .git/objects -type f | head
git count-objects -vH # скільки об'єктів і скільки вони займають
Чого не робити:
- не редагувати файли в
.gitвручну без розуміння - для цього є команди (git update-ref,git config); - не комітити
.gitіншого репозиторію в свій (вкладені репозиторії - через підмодулі); - не синхронізувати
.gitчерез Dropbox чи iCloud - одночасний запис з двох машин псує репозиторій.
git worktree і підмодулі використовують файл .git замість каталогу - у ньому лише шлях до справжнього каталогу репозиторію.
Перемикання гілки (git switch чи git checkout) - це три кроки:
- порівняти дерева: Git бере tree поточного коміту й tree цільового коміту і визначає, які файли відрізняються;
- оновити індекс і робочий каталог: змінені файли переписуються, відсутні в цільовій гілці - видаляються, нові - створюються. Однакові файли не чіпаються взагалі - саме тому перемикання між схожими гілками миттєве навіть у великому проєкті;
- оновити
HEAD: записатиref: refs/heads/<гілка>.
Незакомічені зміни переносяться разом з вами. Якщо ви змінили файл, а в цільовій гілці цей файл такий самий, як у поточній, - Git просто залишить вашу зміну. Тому «я почав роботу не в тій гілці» виправляється простим git switch -c правильна-гілка - зміни перейдуть.
Коли Git відмовляється:
error: Your local changes to the following files would be overwritten by checkout:
config/services.php
Please commit your changes or stash them before you switch branches.
Aborting
Це трапляється, коли у вас є незакомічені зміни у файлі, який у цільовій гілці відрізняється. Git не може записати нову версію, не знищивши вашу роботу, - тому зупиняється, нічого не змінивши.
Те саме для невідстежуваних файлів:
error: The following untracked working tree files would be overwritten by checkout:
app/Actions/Export.php
У цільовій гілці є файл з таким шляхом, а у вас лежить невідстежуваний файл з тим самим іменем.
Варіанти:
- закомітити зміни (можна тимчасовий коміт в поточній гілці);
git stash(з-uдля невідстежуваних), перемкнутися, потімgit stash popтам, де потрібно;git switch -m гілка- спробувати злити ваші зміни з версією цільової гілки (можуть виникнути конфлікти);- відкинути зміни -
git restore, якщо вони не потрібні.
Чого не варто робити - git checkout -f / git switch --discard-changes без розуміння: це перемикання з безповоротним знищенням незакомічених змін.
Невідстежувані й проігноровані файли (vendor/, .env) переживають перемикання - Git їх не чіпає. Тому після переходу на стару гілку vendor/ може не відповідати її composer.lock: потрібен composer install.
Git відстежує вміст файлів, а не каталоги. Каталог існує в репозиторії лише як tree-об'єкт, і tree створюється тільки тоді, коли в ньому є хоча б один файл. Порожній каталог нічого не додає до дерева - тому git add empty/ нічого не робить, а після клонування його немає.
Як зберегти порожній каталог - покласти в нього файл:
touch storage/logs/.gitkeep
.gitkeep - лише угода, а не особливий файл для Git. Laravel використовує інший прийом - .gitignore всередині каталогу:
# storage/logs/.gitignore
*
!.gitignore
Каталог існує в репозиторії (завдяки цьому файлу), а його вміст ігнорується.
Права файлів - лише кілька режимів. Tree зберігає для кожного запису режим, ім'я і хеш:
git ls-tree HEAD
# 100644 blob 7898192... a.txt ← звичайний файл
# 100755 blob 1a24852... run.sh ← виконуваний файл
# 120000 blob 8d14cbf... link ← символьне посилання
# 040000 tree 3c4e9cd... app ← каталог
# 160000 commit 9f2b1e0... vendor/pkg ← підмодуль (gitlink)
| Режим | Значення |
|---|---|
100644 |
звичайний файл |
100755 |
виконуваний файл |
120000 |
symlink - blob містить шлях, на який він вказує |
040000 |
каталог (tree) |
160000 |
підмодуль - посилання на коміт іншого репозиторію |
Що Git не зберігає: власника, групу, права на запис для групи чи інших, дату зміни. Після клонування файли отримують власника й поточну дату, а права - за umask системи.
Типова проблема - «змінений» файл без змін у вмісті:
old mode 100644
new mode 100755
Хтось виконав chmod +x, або файлова система (Windows, змонтований диск) не підтримує біт виконання. Якщо зміни прав не мають значення для проєкту:
git config core.fileMode false
А щоб справді зробити скрипт виконуваним у репозиторії на системі без підтримки прав - git update-index --chmod=+x deploy.sh.
Хеш коміту обчислюється з усього вмісту коміту:
git cat-file -p HEAD
# tree 3c4e9cd789d88d8d89c1073707c3585e41b0e614
# parent 84ba0356e6199a1cb8a555fef6d09aebe1c203f1
# author Olena <olena@example.com> 1759500000 +0300
# committer Olena <olena@example.com> 1759500000 +0300
#
# Add invoice export
У нього входять хеш дерева (знімок усіх файлів) і хеш батьківського коміту. А хеш батька, у свою чергу, залежить від його батька - і так до першого коміту.
Наслідок - ланцюжок, як у блокчейні: зміна будь-чого в старому коміті (файлу, повідомлення, автора, навіть дати) дає новий хеш цього коміту, отже нові хеші всіх його нащадків. Тому:
- один хеш гарантує всю історію: якщо у вас і в колеги
mainвказує на той самий хеш, у вас ідентичні всі файли й уся історія до цього моменту; - непомітно підмінити старий коміт чи файл неможливо - зміниться хеш, і це видно;
- переписування історії (
rebase,commit --amend) завжди створює нові коміти з новими хешами - звідси конфлікти з колегами, які вже мають старі.
Чому коміти з однаковим вмістом мають різні хеші: у коміті є час і автор. Два однакові git commit з різницею в секунду - різні коміти. Тому cherry-pick одного коміту у дві гілки дає два різні хеші.
Перевірка цілісності - git fsck:
git fsck
git fsck --full --strict
Перевіряє, що кожен об'єкт відповідає своєму хешу, всі посилання (з коміту на tree, з tree на blob-и) ведуть на існуючі об'єкти, і повідомляє про пошкоджені, відсутні й недосяжні об'єкти.
Де це корисно:
- після збою диска чи синхронізації
.gitчерез хмарні сервіси; fetchіcloneперевіряють об'єкти автоматично (transfer.fsckObjectsвмикає повнішу перевірку), тож пошкоджені дані з сервера не потраплять у ваш репозиторій непомітно.
Обмеження: хеш захищає від випадкових пошкоджень і непомітної підміни, але не доводить авторство - ім'я й пошту в коміті може вписати будь-хто. Для цього існують підписані коміти.
Кожен коміт зберігає посилання на батьківські коміти. Разом вони утворюють спрямований ациклічний граф (DAG):
- спрямований - посилання йдуть лише від нащадка до предка, коміт не знає своїх дітей;
- ациклічний - неможливо, рухаючись по батьках, повернутися до того самого коміту (батька створено раніше за нащадка, а хеш нащадка містить хеш батька).
Скільки батьків може мати коміт:
| Батьків | Що це |
|---|---|
| 0 | кореневий коміт - перший у репозиторії |
| 1 | звичайний коміт |
| 2 | merge-коміт - результат злиття двох гілок |
| 3+ | octopus merge - злиття кількох гілок одночасно (рідко) |
git cat-file -p HEAD # merge-коміт
# tree ...
# parent 99fe6a1... ← перший батько: гілка, в яку зливали (main)
# parent 5b8c2d0... ← другий батько: гілка, яку зливали (feature)
Порядок батьків має значення: перший батько - це «основна лінія». git log --first-parent показує історію main лише з merge-комітів, без внутрішніх комітів кожної гілки - зручно бачити, які фічі й коли потрапили в основну гілку.
Подивитися граф:
git log --graph --oneline --all
# * 23933de Merge branch 'feature'
# |\
# | * 5b8c2d0 Add export
# * | 99fe6a1 Fix typo
# |/
# * 84ba035 Init
Що випливає з графової моделі:
- гілка - лише вказівник на одну вершину графа; «вміст гілки» - усі коміти, досяжні з неї по батьках;
- спільний предок (merge base) двох гілок - найближча вершина, досяжна з обох: від неї Git рахує зміни при злитті (
git merge-base main feature); - «чи злито гілку» = чи досяжний її коміт з
main(git branch --merged); - merge-коміт не містить «різниці» - як і будь-який коміт, він зберігає повний знімок, а зміни Git показує, порівнюючи з батьками.
Команди 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 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/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:
- якщо розмір і час зміни файлу збігаються з тими, що в індексі, - файл вважається незмінним без читання вмісту;
- лише для файлів з іншими
statGit читає вміст, хешує й порівнює з blob-ом в індексі; - якщо вміст той самий (наприклад, файл перезбережено без змін), 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, зберігши файли робочого каталогу.
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 (лише потрібна гілка) суттєво зменшує обсяг завантаження.
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-ів.
Логічно кожна версія файлу - окремий 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.
Ідентифікатори всіх об'єктів Git - хеші SHA-1 (160 біт, 40 шістнадцяткових символів). У 2017 році атака SHAttered показала практичну колізію SHA-1 - два різні PDF-файли з однаковим хешем. Згодом атаки з вибраним префіксом стали ще дешевшими.
Чим це загрожує Git: зловмисник теоретично може створити два об'єкти з однаковим хешем - безпечний для рецензії і шкідливий - і підмінити один іншим. Уся модель цілісності Git тримається на тому, що хеш однозначно визначає вміст.
Що зроблено вже зараз:
- Git використовує SHA-1 з виявленням колізій (sha1collisiondetection) - об'єкти, побудовані відомими методами атаки, розпізнаються й відхиляються;
- підтримка SHA-256 у Git з версії 2.29 (ще позначена як експериментальна, хоча вже стабільна для використання):
git init --object-format=sha256
git rev-parse --show-object-format # sha1 або sha256
Хеші в такому репозиторії - 64 символи.
Чому перехід повільний:
- SHA-1 і SHA-256 репозиторії несумісні: не можна напряму зробити
fetchчиpushміж ними. План переходу передбачає режим сумісності з таблицею відповідності хешів, але він ще не завершений; - хостинги: підтримка SHA-256 у GitHub, GitLab та інших з'являється поступово - перед створенням такого репозиторію треба перевірити, чи його приймає ваш сервер, CI і інструменти;
- екосистема: інструменти, що вважають хеш 40-символьним рядком (регулярні вирази, колонки в базах, парсери логів), ламаються;
- конвертація наявного репозиторію змінює всі хеші - посилання на коміти в задачах, changelog, документації стають недійсними.
Практичні висновки:
- для звичайних проєктів SHA-1 з виявленням колізій зараз достатній - реальна загроза потребує атаки на конкретний репозиторій і значних ресурсів;
- не покладайтеся на довжину хешу у власних інструментах: використовуйте
git rev-parse --show-object-formatі не обрізайте хеші жорстко до 40 символів; - для перевірки походження важливіші підписи комітів і тегів - їх теж стосується проблема SHA-1, тому сучасні підписи (SSH, GPG) хешують вміст власними алгоритмами;
- нові репозиторії на SHA-256 - розумний вибір для внутрішніх систем, де ви контролюєте всі інструменти; для відкритих проєктів поки що краще дочекатися повної підтримки хостингів.
Коміт неможливо змінити: будь-яка зміна, навіть у повідомленні, дає новий хеш. Але інколи до вже опублікованого коміту треба «дописати» інформацію: результат CI, посилання на рецензію, час деплою, заміток для розслідування.
git notes зберігає примітки окремо від комітів:
git notes add -m "Deployed to production 2026-10-04 14:20" a1b2c3d
git notes append -m "Rolled back: memory leak" a1b2c3d
git log -1 a1b2c3d
# commit a1b2c3d...
# ...
# Notes:
# Deployed to production 2026-10-04 14:20
# Rolled back: memory leak
Як це влаштовано: примітки - звичайні об'єкти Git в окремій гілці refs/notes/commits. Ця гілка містить коміти з деревом, де ім'я файлу - хеш коміту, до якого примітка, а вміст - текст. Тобто це історія приміток з власними комітами, а сам коміт-ціль не змінюється.
Простори імен - різні види метаданих окремо:
git notes --ref=ci add -m "build #4812: passed" HEAD
git notes --ref=deploy add -m "prod 2026-10-04" HEAD
git log --notes=ci --notes=deploy
Головна незручність - примітки не передаються за замовчуванням. git push і git fetch працюють лише з гілками й тегами, тож refs/notes/* треба вказати явно:
git push origin 'refs/notes/*'
git fetch origin 'refs/notes/*:refs/notes/*'
Або додати refspec у конфігурацію remote. GitHub давно не показує примітки в інтерфейсі, тому вони корисні передусім для внутрішніх інструментів.
Ще деталі:
- rebase і amend створюють нові коміти - примітки старих не переносяться, якщо не налаштувати
notes.rewriteRef; - злиття приміток з різних джерел - окрема команда
git notes mergeзі стратегіямиunion,cat_sort_uniq; - примітки не входять у хеш і не захищені підписом коміту - не використовуйте їх для чогось, що має бути незмінним.
Де notes доречні:
- CI записує результат збирання чи покриття до коміту;
- інструмент деплою позначає, які коміти й коли потрапили в продакшен;
- проєкти, що приймають патчі поштою, додають посилання на обговорення;
- особисті нотатки при розслідуванні історії.
Альтернативи: trailer-рядки в повідомленні (Reviewed-by:, Refs: #142) - якщо інформація відома до коміту; теги - для позначення конкретних точок; зовнішні системи (CI, трекер) - якщо метадані не мають жити разом з репозиторієм.