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

Питання на співбесіді: Внутрішня будова 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 замість каталогу - у ньому лише шлях до справжнього каталогу репозиторію.

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

Перемикання гілки (git switch чи git checkout) - це три кроки:

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

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 ls-tree

Хеш коміту обчислюється з усього вмісту коміту:

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 вмикає повнішу перевірку), тож пошкоджені дані з сервера не потраплять у ваш репозиторій непомітно.

Обмеження: хеш захищає від випадкових пошкоджень і непомітної підміни, але не доводить авторство - ім'я й пошту в коміті може вписати будь-хто. Для цього існують підписані коміти.

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

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

Докладніше в документації: Pro 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 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

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

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

У коміті немає запису «файл перейменовано». Tree лише містить ім'я й хеш: у старому дереві є big.txt, у новому - big2.txt. Перейменування Git обчислює заново щоразу, коли показує diff, log чи виконує злиття.

Алгоритм:

  1. знайти пари «видалений файл» і «доданий файл»;
  2. точні збіги - однаковий хеш blob-а: це перейменування зі 100% схожістю (R100). Перевіряється дешево;
  3. для решти - оцінити схожість вмісту кожної пари і вважати перейменуванням, якщо вона вища за поріг. Поріг за замовчуванням - 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.

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

Ідентифікатори всіх об'єктів 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, трекер) - якщо метадані не мають жити разом з репозиторієм.

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