Всем привет!
Почти каждый, кто работал с Git больше пары недель, хоть раз сталкивался с одной и той же путаницей: почему git add — это не сохранение, зачем нужен какой‑то отдельный индекс, если есть коммит, откуда вообще берутся конфликты при мерже, если мы просто оба поправили один файл. И почему иногда git status показывает файл как изменённый, хотя вы точно ничего не трогали.
Если вы так же знакомы с этими проблемами, а также с другими проблемами при работе с Git — эта статья для вас.
В этой статье cначала покажем, как Git на самом деле хранит файлы и их изменения, что такое индекс и почему он существует отдельно от рабочей директории и коммитов. Далее — как устроены ветки на уровне данных, а не только как указатель текущей линии разработки. После этого разберём, как Git определяет, какие именно строки поменялись, и как из этого механизма следует слияние т.е merge и, самое главное, конфликты при слиянии.
Изучив модель, пройдёмся по основным командам работы с файлами: add, rm, mv, restore, diff, log, stash, работу с .gitignore и с большими файлами.
А в конце мы вам покажем, как это всё применяется на практике.
Файл в Git
Сперва нужно понять: Git не хранит историю файлов в привычном смысле: версия 1 файла X, версия 2 файла X. Git — это система хранения данных, адресуемого по содержимому (content‑addressable storage). Каждый раз, когда вы делаете коммит, Git не запоминает что изменилось в файле, а сохраняет слепок (snapshot) всего проекта на этот момент, однако делает это с экономией, через использование неизменившихся частей.
Внутри .git лежат объекты трёх типов, которые касаются файлов и структуры проекта:
blob — содержимое одного файла. Если один и тот же контент встречается в десяти файлах или десяти коммитах, для него будет создан ровно один blob‑объект;
tree — список файлов и папок в определённом состоянии: имя файла, права доступа (в т. ч. исполняемый бит), и ссылка на blob или на вложенный tree;
commit — ссылка на конкретный tree (то есть на состояние всего дерева проекта целиком), ссылка на родительский коммит (или несколько, если это коммит слияния), автор и дата, сообщение.
Каждый объект обладает идентификатором — это хэш от своего содержимого (в современных репозиториях это SHA-1, в части других — SHA-256). Отсюда следует что если вы переименовали файл, не поменяв ни строчки внутри, blob останется прежним, поменяется только запись в tree. А если в двух разных файлах проекта содержится одинаковый текст — для них будет использован один и тот же blob. Т.е. он будет сохранён один раз.

Четыре состояния файла
Когда вы создаёте файл в рабочей директории репозитория, он не сразу становится частью Git. Файлы проходят через несколько состояний, путаница в которых создает половину проблем.
Untracked (неотслеживаемый) — Git видит файл в папке, но еще не знает о нем. Это новосозданный файл, который отсутствует в предыдущих коммитах и в индексе.
Tracked, unmodified — файл уже был закоммичен хотя бы раз, и с последнего коммита не менялся. Git знает про него и сверяет с сохранённой версией.
Modified (изменён) — файл отслеживается, но его содержимое в рабочей директории отличается от того, что лежит в последнем коммите.
Staged (проиндексирован) — изменения добавлены в индекс через команду
git addи теперь готовы к следующему коммиту.

Проверить текущее состояние файлов репозитория можно через git status. Если вы видите файл сразу в двух списках: staged и modified одновременно — это нормально. Значит, вы сделали git add, а потом ещё раз поменяли файл. В индексе лежит одна версия, в рабочей директории другая.
Индекс (staging area)
Проблемы вокруг файлов git и их состояний зачастую находятся именно здесь. Кажется, логично что должно быть два состояния — было и стало, однако в Git существует третье состояние — staging area т.е область подготовки (он же index).
Индекс дает возможность собрать коммит только из части изменений в рабочей директории, а не из всех изменений. Представим, что вы одновременно правите баг в одном файле и рефакторите другой, не связанный с багом, файл. Без индекса пришлось бы коммитить всё сразу, смешивая два несвязанных изменения в одну запись истории. С индексом вы делаете git add только на файл с багом, соответственно, не затрагиваете несвязанный файл.
Де‑факто индекс — это файл .git/index, в котором для каждого проиндексированного пути хранится ссылка на конкретный blob. Когда вы выполняете git add файл, Git делает две вещи: во‑первых, сразу вычисляет blob‑объект от текущего содержимого файла и кладёт его в базу объектов (даже если вы ещё не коммитили — объект уже физически существует в .git/objects), во‑вторых, обновляет запись в индексе, чтобы она указывала на этот blob. Коммит же просто берёт текущее состояние индекса целиком и превращает его в новый tree‑объект.
Отсюда, кстати, следует практическое правило: если вы git add файл, а потом ещё раз его поменяли и снова хотите закоммитить актуальную версию — нужен повторный git add, иначе в коммит уйдёт та версия, что была проиндексирована раньше.
Основные команды работы с файлами через состояния
Если держать в голове модель «untracked — staged — committed» и структуру blob/tree/commit, команды перестают быть набором заклинаний и превращаются в логичные переходы между состояниями.
Добавление файлов в индекс
git add name.py git add abc/ git add . git add "*.py"
git add . добавляет в индекс все изменённые и новые файлы в текущей директории и во вложенных папках. Если нужно добавить несколько конкретных файлов, а не всё подряд, их можно перечислить через пробел:
git add name1.py name2.py name3.py
Отдельно стоит команда git add -p (patch mode).Она разбивает изменения внутри файла на отдельные куски (hunks) и спрашивает по каждому, добавлять ли его в индекс. Это полезно, когда вы правили один файл сразу в нескольких несвязанных местах и хотите закоммитить их отдельными коммитами.
Просмотр состояния и изменений
git status git status -s
Короткий формат -s показывает статус каждого файла двумя буквами: первая — это состояние в индексе, вторая — в рабочей директории (например, MM значит «файл изменён, часть изменений в индексе»).
Чтобы посмотреть список файлов, которые Git отслеживает в текущем состоянии индекса:
git ls-files
А чтобы увидеть список файлов, входящих в конкретный коммит:
git show --stat <commit> git diff-tree --no-commit-id --name-only -r <commit>
Первая команда покажет ещё и краткую статистику по количеству добавленных/удалённых строк на файл. Это также может быть удобно, когда нужно быстро понять, какие файлы в коммите и насколько сильно они менялись.
Просмотр самих изменений через git diff
Есть три варианты команды, их надо четко понимать чтобы на этом месте не запутаться:
git diff git diff --staged git diff HEAD
git diff сравнивает директорию и индекс (то есть незастейдженные изменения)
git diff --staged показывает что попадет в следущий коммит (сравнение индекса и последнего коммита)
git diff HEAD показывает разницу между рабочей директорией и последним коммитом
Если нужно посмотреть изменения в конкретном файле используйте следующее:
git diff name.py git diff --staged name.py
Сравнение файла между двумя произвольными коммитами:
git diff commit1 commit2 -- name.py
Удаление файлов
Тут стоит различать два принципиально разных сценария, потому что зависит от того, какую именно задачу вам нужно выполнить.
Если файл удалить требуется и из рабочей директории, и из будущего коммита:
git rm name.py git commit -m "Удалён name.py"
Если файл должен остаться на диске, но перестать отслеживаться Git (типично для случая когда вы случайно закоммитили файл с конфигурацией или секретами):
git rm --cached name.py
После этого файл поменяет статус на untracked, и его сразу нужно добавить в .gitignore, иначе git status продолжит предлагать его добавить заново.
Для массового удаления неотслеживаемых файлов. Например, случайно сгенерированных при сборке, которые не нужны в репозитории есть отдельная команда:
git clean -n git clean -f git clean -fd
git clean -n покажет что будет удалено (так называемый dry run)
git clean -f — удалить
git clean -fd — удалить вместе с пустыми директориями
ВАЖНО! git clean действует только на untracked‑файлы и никогда не трогает файлы, которые Git уже отслеживает.
Переименование и перемещение
git mv old_name.py new_name.py
Формально git mv просто сокращение для mv + git rm старого пути + git add нового. Git не хранит переименования как отдельную операцию, вместо этого при построении истории он распознаёт переименование эвристически: если в двух соседних снепшотах старого файла нет, зато появился новый файл с высоким процентом совпадения содержимого (по умолчанию порог около 50%), Git показывает это как rename в выводе git log и git diff, а не как удаление и создание с ноля.
Восстановление файла к прежнему состоянию
Здесь для понимания также полезно разделять по состояниям:
git restore name.py --- используем когда надо вернуть рабочую копию к версии из индекса или последнего коммита git restore --staged name.py --- убираем файл из индекса, оставляем лишь изменения в директории (если кратко то отмена git add) git restore --source=<commit> name.py --- используем если требуется вернуть файл к состоянию какого-то конкретного коммита
В более старых материалах и скриптах вместо restore часто встречается git checkout -- файл.py. По сути он выполняет тот же самый функционал, restore появился позже как более понятная по названию замена, но checkout по‑прежнему работает и никуда не делся.
История изменений конкретного файла
В работе случается когда нужно узнать в каких коммитах менялся конкретный файл, это мы можем узнать через следующие команды:
git log name.py git log -p name.py git log --follow name.py
Отличие этих команд в том, что git log -p выводит полную ифнормацию о содержимом измненений (то есть diff) в каждом коммите, а git log --follow учитывает кроме всего прочего еще и историю до переименования файла.
Уточнение: флаг --follow важен если вы переименовывали файл, чистый git log покажет историю только с момента переименования.
Исполняемые файлы и права доступа
Git хранит в tree не только имя файла и ссылку на blob, но и основные права доступа: обычный файл, исполняемый файл или символическая ссылка. Если вы сделали скрипт исполняемым локально (с помощью команды chmod +x script.sh), то само это изменение прав является модификацией, которую нужно зафиксировать в Git.
chmod +x script.sh git add script.sh git commit -m "script.sh теперь исполняемый"
При этом стоит помнить, что на Windows понятие «исполняемый бит» в файловой системе отсутствует в том виде, в котором его понимает Git, поэтому такие изменения иногда неожиданно всплывают при кросс‑платформенной разработке. Файл может каждый раз показывается как изменённый, хотя содержимое то же самое, единственное отличие лишь в том что поменялся только бит прав.
gitignore
Файл .gitignore в корне репозитория задаёт шаблоны путей, которые Git должен игнорировать при git status и git add .. Я думаю каждый работал с.gitignore и в знакомстве не нуждается.
__pycache__/ *.pyc .env venv/ *.log
Важный нюанс: .gitignore не действует на файлы, которые уже отслеживаются. Если файл однажды попал в индекс и был закоммичен, добавление его пути в .gitignore не уберёт его из репозитория. Сперва исключите его командой git rm --cached, как было показано выше, и только после этого игнор начнёт работать.
Ветки
Существует заблуждение что ветка это отдельная копия проекта и отдельный набор файлов.
Ветка — это просто именованный указатель на конкретный коммит. Файл .git/refs/heads/main физически содержит один‑единственный хэш коммита. Ничего тяжелого в этом нет, однако из‑за непонимания люди сталкиваются с проблемами, которые мы с вами предупреждаем.
История коммитов в Git образует направленный граф (DAG). Это означает что каждый коммит ссылается на своего родителя (или родителей, если это коммит слияния). Ветка может указывать на один и тот же коммит сколько угодно, а сам коммит ничего не знает, к какой ветке он принадлежит.
git branch --- список локальных веток git branch dev --- создать ветку dev, указывающую на текущий коммит git switch dev --- переключиться на неё git switch -c dev --- создать и сразу переключиться
Когда вы делаете новый коммит, находясь на ветке dev, происходит то, что создаётся новый commit‑объект с родителем — текущим коммитом ветки, а указатель dev просто переставляется на новый коммит. Ветка main при этом остаётся в прежнем месте.
HEAD. По‑простому это указатель на указатель. Хэд обычно ссылается на текущую ветку (refs/heads/main), а уже та ссылается на коммит. Поэтому когда вы коммитите, HEAD постоянно вместе с веткой.
Однако если же вы переключаетесь на конкретный коммит по хэшу, а не на ветку, Git переходит в состояние «detached HEAD». Он меняет свой указатель напрямую на коммит, и новые коммиты в этом состоянии не привязаны ни к одной ветке. Обратите внимание: их очень легко потерять при следующем переключении, поэтому вам важно заранее создать для них ветку.
Если требуется посмотреть какие файлы отличаются между двумя ветками, это также можно сделать через diff:
git diff main dev git diff main dev -- name.py
Чтобы узнать в какой ветке (или ветках) присутствует x файл в y состоянии, используем:
git log --all --oneline -- файл.py
Эта команда покажет коммиты из всех веток, где менялся указанный файл, что удобно, если файл потерялся из текущей ветки.
Как Git на самом деле понимает, что изменилось
Я еще раз подчеркну механику, о которой уже упоминалось выше. Однако теперь уже в контексте того, зачем она нужна для мержа. Git не хранит дифф между коммитами как самостоятельную запись.
Каждый коммит это полный снапшот дерева файлов. Diff, который вы видите в git diff или git show, вычисляется каждый раз заново, сравнением содержимого двух snapshot’ов.
Сравнение происходит в два уровня:
Уровень файлов. Git берёт tree двух коммитов (или tree и индекс, или tree и рабочую директорию) и построчно по путям сравнивает хэши blob. Если у файла по конкретному пути хэш blob не изменился, то Git даже не открывает содержимое, так как файл считается идентичным.
Уровень строк внутри файла. Если хэши от blob на одном и том же пути отличаются, только тогда Git запускает алгоритм построчного сравнения содержимого.
Результат такого построчного сравнения — это набор hunks, кусков диффа: непрерывных блоков строк, которые были удалены и/или добавлены, с привязкой к номерам строк в обоих файлах. Именно hunks вы видите как блоки с @@ -12,6 +12,8 @@ в выводе git diff.
Как работает three‑way merge или слияние на уровне строк
Теперь наконец то, ради чего всё это разбиралось.
Когда вы выполняете git merge, Git никак не сравнивает две ветки напрямую (то есть не берёт просто diff между main и dev). Вместо этого используется трёхстороннее слияние (three‑way merge), в котором участвуют три версии каждого файла:
base — коммит‑предок обеих веток, то есть последняя точка, где ветки были идентичны;
ours — версия файла в вашей текущей ветке;
theirs — версия файла в ветке, которую вы вливаете.
Дальше для каждого файла, который отличается хотя бы в одной из веток от base, Git строит два диффа: base - ours и base - theirs.Тут и кроется суть механики слияния на уровне строк:
если конкретный участок файла (hunk) изменился только в
ours, а вtheirsостался как в base — Git берёт версиюours;если участок изменился только в
theirs, а вoursостался как в base — Git берёт версиюtheirs;если один и тот же участок независимо изменился и в
ours, и вtheirsто и появляется КОНФЛИКТ.
Перейдем к конкретным примерам. Пусть в base файл содержал такую строку:
timeout = 30
В ветке
oursеё поменяли наtimeout = 60, а в файле больше ничего не трогали. В веткеtheirsэту же строку никто не трогал, зато в другом месте файла добавили новую функцию. Git видит: изменённый hunk сtimeoutесть только уours, изменённый hunk с новой функцией есть только уtheirs, эти hunks не пересекаются по номерам строк базовой версии. Это значит, что оба изменения применяются сразу, и конфликта никакого не будет. Поэтому итоговый файл получит иtimeout = 60, и новую функцию.А теперь другой сценарий: и в
ours, и вtheirsнезависимо поменяли ту же самую строкуtimeout = 30. В одной ветке, допустим, на60, в другой на45. Оба изменения затрагивают один и тот же hunk относительно base, и оба они разошлись. В этом случае Git не может решить сам, какую версию оставить, и из этого появляется конфликт.
Сценарии конфликтов
Два человека поменяли одну и ту же строку по‑разному. Самый очевидный случай, мы его уже разобрали выше.
Один удалил строку, второй её же изменил. C точки зрения
oursстрока исчезла, с точки зренияtheirsона изменилась — для Git становится непонятно, должна ли она в итоге существовать и в каком виде.Изменения находятся слишком близко друг к другу. Даже если это разные строки, но они попадают в один и тот же или соседний hunk диффа (обычно с запасом в 1–3 строки), Git может посчитать их пересекающимися и потребовать ручного решения.
Файл переименован в одной ветке и одновременно изменён в другой. Такие ситуации нередко превращаются в конфликт по пути файла, а не только по содержимому.
Файл удалён в одной ветке, а изменён в другой. Git не знает, должен ли файл вообще остаться в проекте.
Важно, что все эти сравнения идут построчно и по конкретным hunks. Именно поэтому два человека вполне могут одновременно и активно редактировать один и тот же файл без единого конфликта в том случае если их правки физически находятся в разных, не соседствующих частях файла. И так работает наоборот, поэтому конфликт может возникнуть даже из‑за одной строки в маленьком файле, если оба разработчика её тронули.
Как распознать конфликт и что с ним делать
Когда Git не может разрешить hunk автоматически он записывает файл в рабочую директорию с обеими версиями конфликтующего участка, размеченными маркерами:
<<<<<<< HEAD timeout = 60 ======= timeout = 45 >>>>>>> feature-branch

Таким образом между <<<<<<< HEAD и ======= находится версия из текущей ветки, а между ======= и >>>>>>> feature-branch версия из вливаемой ветки. Остальная часть файла, где конфликта нет, уже автоматически смержена и никаких маркеров не содержит.
Разрешение конфликта на самом деле простое ручное редактирование файла: вам нужно оставить нужный вариант (или написать третий, объединяющий оба), полностью убрав служебные маркеры, после чего файл добавляется в индекс и мердж завершается коммитом:
git add name.py git commit
Обратите внимание: при разрешении конфликта git commit не требует сообщения через -m так как. Git уже подготовил стандартное сообщение о слиянии, достаточно просто подтвердить его в редакторе.
Посмотреть, какие именно файлы находятся в состоянии конфликта прямо сейчас можно через:
git status git diff --name-only --diff-filter=U
Если хочется сразу принять одну из версий целиком, не разбираясь построчно:
git checkout --ours файл.py --- оставить свою версию git checkout --theirs файл.py --- оставить версию из вливаемой ветки git add файл.py
Однако эта команда отбрасывает вообще все изменения проигравшей стороны в этом файле, а не только конфликтующий hunk, что вообще не очень удобно.
Если слияние оказалось слишком запутанным и вам проще начать заново, то мердж можно полностью отменить:
git merge --abort
Она вернёт репозиторий ровно в то состояние, в котором он был до начала git merge.

Временное сохранение незакоммиченых изменений: git stash
Отдельно стоит упомянуть ситуацию, когда изменения в файле ещё не готовы к коммиту, но нужно срочно переключиться на другую ветку (например, забрать чужие изменения через git pull), а Git отказывается, потому что в рабочей директории есть незакоммиченные правки, которые могут быть перезаписаны.
git stash --- отложить все незакоммиченные изменения git stash push файл.py --- отложить изменения только в одном файле git stash list --- посмотреть список отложенных наборов изменений git stash pop --- вернуть последний набор и удалить его из списка git stash apply --- вернуть, но оставить в списке
git stash фактически создаёт временный коммит (точнее, пару специальных коммитов) вне обычной истории веток, откладывает его в сторону и возвращает рабочую директорию к чистому состоянию последнего коммита. Это удобный способ временно убрать со стола незавершённую работу, не создавая мусорных коммитов в основной истории.
Итог
Мораль сей басни такова: Git на самом деле не хранит изменения как что‑то отдельное. Вместо этого он сохраняет полные версии (снимки) файлов. А разница между версиями, ветки и слияния — все это рассчитывается поверх этих снимков. Файл проходит через этапы: не отслеживается, подготовлен к добавлению, закоммичен. Индекс — это черновик следующего снимка. Ветка — просто метка, указывающая на определённый коммит. Слияние же работает так: Git сравнивает три версии файла (две из текущих веток и общую для них) построчно. Именно поэтому возникают конфликты: не потому, что два человека редактировали один файл, а потому, что один и тот же участок строк изменился в обеих ветках, и Git не может сам решить, какой вариант правильный.
Если понимать эту модель, многие команды Git, такие как add, restore, diff, log --follow, stash, а также процесс разрешения конфликтов превращаются в логичные действия с понятными состояниями.
Комментарии (6)

Mabu
02.09.2026 10:30Интересно, а кто‐нибудь вводит эти команды вручную? В то время как любая IDE в 2026 году позволяет манипулировать системой контроля версий щелчками мыши и в удобном виде.

EasyGame
02.09.2026 10:30Еще как вводят. В жизни почти каждого программиста есть момент упарывания по терминалу (а у многих еще и по vim), большинство со временем приходит в норму, но отдельные личности продолжают пополнять отряды несгибаемых адептов командной строки.

ashumkin
02.09.2026 10:30Я вот клаводрочер, а не мышекликер… И это давний холивар… Не будем )) Так что, да, “кто-нибудь вводит вручную”. Кроме того, есть алиасы + хуки, которые позволяют делать больше, чем IDE умеет

pfemidi
02.09.2026 10:30Фиг знает что там IDE сделает, а досконально разбираться что именно делает конкретный IDE уж очень муторно и лениво, к тому же помнить все это... Данунафиг! Поэтому да, только и только руками вводить эти команды. В этом случае хотя бы знаешь что делаешь и уверен что произойдёт, а не полагаешься на невесть что в пузе IDE.
Sazonov
«Ещё один туториал по гиту»… который уступает документации в интернете.
Почему этот текст оценен как «сложный»?
Не рассмотрена тема merge vs rebase. Не рассмотрена такая фича как worktree. Про .gitignore очень поверхностно, и да, он может быть расположен иерархически (в любом каталоге), а не только в текущем. По LFS, сабмодули почти ничего не написано. И тп.