Многие начинающие разработчики не понимают внутренней работы системы Git и не используют ее возможности за пределами знакомого им рабочего процесса. Они полагаются на заученные действия и не могут адаптироваться к новым ситуациям. Это приводит к проблемам при взаимодействии с другими пользователями Git, при попытке внести вклад в open source проект и при возникновении ошибок.
Чтобы использовать Git по-настоящему эффективно, важно понимать, как работает система и как она отслеживает изменения: это позволит легко адаптироваться к любым рабочим процессам, быстро исправлять ошибки и экономить долгие часы работы при помощи продвинутых функций.
Что такое Git
Git – это система контроля версий, которая позволяет разработчикам сохранять разные версии кода и переключаться между ними. Чтобы понять принцип работы Git, нужно представить себе точную мысленную модель его базовых концепций.
Как Git сохраняет изменения
Прежде чем мы перейдем к разбору составляющих этой модели, важно понять, что Git на самом базовом уровне сохраняет не изменения, а «снимки» нашего кода (по крайней мере концептуально). Он использует пакфайлы для эффективного сохранения данных и в некоторых случаях фактически сохраняет изменения (diffs, диффы). Кроме того, он создает диффы по требованию. Иногда диффы проявляются в результате выполнения некоторых команд: например, некоторые команды могут показать, что один файл был удален, а второй добавлен, в то время как другие показывают, что файл был переименован.
А теперь давайте обсудим основные концепции Git и их взаимосвязь в системе контроля версий.
Что такое коммит
Коммит — это снимок изменений в коде, основная единица измерения в системе контроля версий Git. Каждый коммит представляет собой набор изменений в файловой системе, который затем сохраняется и становится доступным для просмотра и восстановления. Коммиты позволяют разработчикам сохранять историю изменений проекта, чтобы в случае необходимости можно было вернуться к определенной версии кода. Коммит в Git включает в себя:
- один или несколько родительских коммитов (у самого первого, корневого коммита, родителей нет);
- сообщение коммита;
- информацию об авторе и дате авторства (фактической временной метке со смещением часового пояса);
- информацию о коммитере и дате коммита;
- и, наконец, наши файлы – путь к ним относительно корня репозитория, права доступа к файловой системе (UNIX), и их содержимое.
Каждый коммит получает уникальный идентификатор, вычисляемый с помощью SHA1-функции хэширования на основе всей перечисленной выше информации. Изменение любой части этой информации приведет к изменению хэша и, следовательно, идентификатора коммита.
Хранилище Git использует адресацию по содержимому, то есть каждый объект в нем имеет имя, которое напрямую следует из его содержимого (в виде его SHA-1 хэша).
Раньше Git хранил все данные в файлах и мы до сих пор можем рассматривать его в качестве своеобразного файлового хранилища. Содержимое файла сохраняется в виде «блоба» (blob –массив двоичных данных), каталог сохраняется в виде «дерева» (текстового файла, в котором перечисляются файлы в каталоге с их именами, режимами и SHA-1 хэшами, представляющими их содержимое, также как и их подкаталоги с их именами и SHA-1 их «деревьев»).
Родительские коммиты в Git создают структуру в форме направленного ациклического графа, которая отражает историю изменений в проекте. Направленный ациклический граф состоит из вершин (коммитов) и направленных ребер (связей между родительскими и дочерними коммитами). Такая структура позволяет проследить историю изменений, поскольку каждый коммит связан только с одним или несколькими родительскими коммитами, и не имеет циклов, так как не может быть собственным предком.
Ссылки, ветки и теги
Хотя Git позволяет использовать уникальные SHA1-префиксы вместо полных SHA1-хешей, запоминать их сложно, а использовать – неудобно. Поэтому в системе используются ссылки – уникальные метки, названия которых разработчики могут выбирать по собственному усмотрению. Ссылки позволяют легко обращаться к определенным коммитам в нашей истории. В Git есть несколько типов ссылок:
- Ветки (Branches) – это ссылки, которые могут перемещаться. Они обычно используются для разработки новых функций или исправления ошибок в отдельной ветке, которая затем может быть объединена с основной веткой. Названия ветвей (main или master) не имеют особого значения и важности.
- Теги (Tags) – это неизменяемые ссылки, которые обычно применяются для обозначения определенных версий (релизов) проекта. В отличие от веток, теги не перемещаются, они всегда указывают на один и тот же коммит.
- HEAD – это специальная ссылка, которая указывает на текущий коммит. Причем обычно она указывает на ветку, а не на коммит напрямую – это связано с тем, что ветки в Git являются перемещаемыми ссылками, которые указывают на последний коммит в этой ветке. Когда вы переключаетесь между ветками, HEAD автоматически обновляется, чтобы указывать на последний коммит в текущей ветке.
- Другие специальные ссылки (например, FETCH_HEAD и ORIG_HEAD) Git создает во время некоторых операций. FETCH_HEAD обычно указывает на последний коммит, который был получен из удаленного репозитория. ORIG_HEAD используется для хранения ссылки на коммит перед выполнением операций, которые могут изменить историю (
git reset
илиgit rebase
).
Состояния файлов в Git
Файл в Git может находиться в одном из трех основных состояний:
- Измененный (modified): Это состояние означает, что вы изменили файл, но еще не зафиксировали эти изменения в Git. Файлы в этом состоянии находятся в вашей рабочей директории и представляют текущее состояние вашего проекта, которое еще не было зафиксировано в Git.
- Подготовленный (или индексированный, staged): Когда вы готовы зафиксировать изменения, вы перемещаете файл из состояния «измененный» в состояние «индексированный». Файлы в этом состоянии готовы к коммиту и будут включены в следующий коммит.
- Зафиксированный (committed): Когда вы выполняете коммит с помощью команды
git commit
, файлы в состоянии «индексированные» перемещаются в состояние «зафиксированные».
В Git-проекте существуют три разные секции:
- Рабочая директория (Working Directory) – это директория в локальной файловой системе, где находятся файлы вашего проекта – место, где вы можете изменять файлы.
- Область индексированных файлов (Staging Area) – это область между рабочей директорией и директорией Git. Все файлы, которые готовы к коммиту, хранятся здесь.
- Директория Git (репозиторий) (Git Directory) – это место, где Git хранит метаданные и объектную базу данных для вашего проекта. Это наиболее важная часть Git – именно она копируется, когда вы клонируете репозиторий.
Рабочая директория инициализируется из конкретного коммита из вашей истории. Это означает, что при каждом клонировании репозитория или переключении между ветками, Git создает рабочую директорию, которая отражает состояние проекта в выбранном коммите.
По-другому связь между этими концепциями можно представить так:
На заметку: игнорирование файлов
Git предоставляет возможность игнорировать определенные файлы или каталоги, которые не нужно отслеживать. Так можно исключить перенесение в репозиторий ненужных вспомогательных файлов, которые генерируются вашей системой сборки, редактором кода или операционной системой. Для игнорирования файлов нужно поместить их названия/типы (можно использовать glob-шаблоны) в специальные файлы:
- Файл .gitignore в любом месте вашего репозитория определяет шаблоны игнорирования для директории, в которой находится. Здесь перечисляют файлы, генерируемые вашей системой сборки или фреймворком (например, build/ для проектов Gradle, _pycache_ для проектов Django и т.д.)
- Файл .git/info/exclude – файл игнорирования для локального репозитория на вашей машине. Он не будет включен в копии репозитория, которые клонируются или скачиваются другими пользователями.
- И, наконец, ~/.config/git/ignore глобален для вашего компьютера (точнее, вашей учетной записи). Здесь можно перечислять игнорируемые файлы, специфичные для вашей машины (например, .DS_Store на macOS или Thumbs.db на Windows).
Основные команды
Большинство операций в Git можно выполнить с помощью этих команд:
git init
– инициализирует новый репозиторий. Это первая команда, которую вы обычно выполняете, когда создаете новый проект.git status
– показывает состояние ваших файлов: какие файлы были изменены, но еще не добавлены в индекс (staged), и какие файлы уже добавлены в индекс.git diff
– показывает различия между любыми двумя состояниями в вашем рабочем каталоге, индексе, HEAD или между любыми коммитами.git log
– выводит полную историю коммитов.git add
– добавляет файлы в индекс. Это делается перед коммитом, чтобы включить изменения в следующий коммит.git commit
– переносит индексированные файлы в коммит, добавляя сообщение. Эта операция сохраняет изменения в истории Git.git add -p
– добавляет файлы в индекс интерактивно. Это позволяет выбирать, какие изменения добавить и какие оставить только в рабочем каталоге.git branch
– показывает ветки или создает новую ветку.git switch
(илиgit checkout
) – переключает ветку (или любой коммит, любое дерево) в ваш рабочий каталог.git grep
– поиск в рабочем каталоге, индексе или любом коммите. Это улучшенная версия командыgrep -R
.git blame
– показывает последний коммит, который изменил каждую строку данного файла: поможет узнать, кто виновен в баге.git stash
– откладывает неподтвержденные изменения в сторону (сюда могут входить индексированные файлы, а также отслеживаемые файлы из рабочего каталога), и позже вынимает их для дальнейших действий.
Переключение между ветками и ветвление
Когда вы создаете коммит с помощью git commit
, Git не только
создает объект коммита, но и перемещает указатель HEAD на него. Если HEAD
указывает на ветку, как это обычно и бывает, то эта ветка также сдвигается на
новый коммит.
Если вы переключаетесь на другую ветку с помощью git switch
или git checkout
, указатель HEAD перемещается на эту новую текущую ветку.
Рабочий каталог и индекс настраиваются в соответствии с состоянием этого
коммита.
В случае, если текущая ветка является предком другой ветки, и вы делаете коммит в текущей ветке, истории этих двух веток начнут разветвляться, то есть развиваться в разные стороны: каждая из них будет иметь свои уникальные коммиты, которые не присутствуют в другой ветке. При необходимости эти ветки можно объединить с помощью команд branch, checkout и commit.
На заметку: Git помнит все
Git сохраняет историю изменений в репозитории, и даже если
вы измените коммит (например, с помощью команды git commit --amend
), это не
удалит старый коммит немедленно. Вместо этого, Git создаст новый коммит с
другим идентификатором SHA1. Со временем Git удаляет старые коммиты из репозитория, если они больше не
доступны из любой ссылки – этот процесс называется сборкой мусора.
Временное хранение удаленных коммитов полезно – например,
если вы случайно удалили коммит, его можно восстановить, если вы
знаете его SHA1-идентификатор. Здесь пригодится команда git reflog
,
которая позволяет посмотреть историю ваших действий в репозитории и найти SHA1-идентификатор нужного коммита. Также можно использовать нотацию
<branch-name>@{<n>}
для ссылки на коммиты, на которые ветка
указывала в прошлом. Например, main@{1}
будет ссылаться на последний коммит, на
который указывала ветка main перед изменением.
Работа с ветками
Основные операции с ветками в Git выглядят так:
Слияние. Ветвление в Git позволяет создавать отдельные линии
разработки. Такие линии можно впоследствии объединить с помощью команды git
merge
. Это особенно полезно, когда одна ветка является предком другой – в этом случае
Git может выполнить быстрое слияние (fast-forward merge), что значительно
упрощает процесс.
Отслеживание ветвей. Git позволяет настроить одну ветку для
отслеживания другой – отслеживающая будет называться восходящей (upstream) веткой.
Команда git status
покажет, насколько две ветки отклонились друг от друга, и
поможет определить, нужно ли обновлять текущую ветку, или ее можно быстро
переместить вперед (fast-forward).
Cherry-pick (выборка). Если вы хотите интегрировать
изменения из одной ветки в другую, но не хотите сливать ветки целиком, можно
использовать команду git cherry-pick
. Эта команда позволяет выбрать конкретный
коммит из одной ветки и применить его к другой ветке. Таким образом можно
включить определенные изменения из одной ветки в другую, не включая все
остальные изменения.
Rebase (перенос). Команда git rebase
– мощный инструмент для
переноса («перебазирования») набора коммитов на новую базу. Используют, когда нужно
изменить историю коммитов, например, чтобы убрать ненужные коммиты или объединить
несколько коммитов в один.
Команда rebase принимает диапазон коммитов и целевую ветку. Коммиты из диапазона «перебазируются» на целевую ветку, а ветка, используемая в качестве конца диапазона, обновляется, чтобы указывать на новые коммиты:
git rebase --onto=<target> <start> <end>
,
где <target> – это целевая ветка, <start> – начальная точка
диапазона коммитов, а <end> – конечная точка диапазона. Например, команда
git rebase upstream HEAD
перебазирует все коммиты с последнего общего предка восходящей
и текущей ветки на восходящую, а затем обновит текущую ветку, чтобы указывать
на новые коммиты.
Поскольку rebase изменяет историю коммитов (история коммитов текущей ветки становится линейной и не содержит информации о том, откуда пришли эти коммиты), команду нужно использовать с осторожностью. Кроме того, использование команды может привести к проблемам, если другие разработчики уже получили изменения из ветки, которую вы пытаетесь перебазировать, выполнили какую-то работу на их основе и отправили коммиты в общедоступный репозиторий. Во многих случаях вместо rebase лучше использовать merge.
Интерактивный rebase. Команда git rebase -i
позволяет редактировать коммиты
перед их применением. Используют, когда нужно объединить несколько коммитов в
один, пропустить некоторые коммиты, изменить их порядок или выбрать те, которые
изначально были пропущены. Еще можно остановить ребейз на определенном коммите,
отредактировать его (например, с помощью git commit --amend
) и создать новые
коммиты перед продолжением перебазирования. Другая суперполезная возможность – с
помощью опции --exec
можно задать команду, которая будет выполняться между
каждым переносимым коммитом, чтобы проверить, что проект не сломался. Опция
--exec
работает и в обычном rebase.
Совместная работа
Все рассмотренные выше примеры относились к работе в одиночку, в вашем собственном репозитории. Но Git предназначен для совместной работы над проектами, а умение работать в команде необходимо каждому профессиональному разработчику. Рассмотрим основные концепции, которые нужно знать.
Удаленные репозитории
Когда вы клонируете репозиторий, он становится удаленным репозиторием (remote) вашего локального репозитория, называемым origin. Как и master, это просто значение по умолчанию, и само по себе оно не имеет особого значения, за исключением случаев, когда соответствующий аргумент команды не указан, и origin используется по умолчанию. Вы начинаете работать, создавая локальные коммиты и ветки (то есть форки от удаленного), a удаленный репозиторий в это время, скорее всего, получит новые коммиты и ветки от своего автора. Если вы захотите синхронизировать эти удаленные изменения в ваш локальный репозиторий и быстро узнать, какие изменения вы сделали локально по сравнению с удаленным, поможет refs/remote. Это специальное пространство имен, в котором Git хранит удаленные отслеживающие ветки, записывающие состояние удаленного репозитория. Важно отметить, что удаленные репозитории не обязательно находятся в облаке, они могут находиться на той же машине, и доступ к ним осуществляется непосредственно из файловой системы. Локальные ветки хранятся в пространстве имен refs/heads/, а теги в refs/tags/ (теги из удаленных репозиториев обычно импортируются прямо в refs/tags/, поэтому, например, вы теряете информацию о том, откуда они пришли).
Получение данных
Когда вы выполняете команды git fetch, git pull или git remote update, Git устанавливает связь с удаленным репозиторием, чтобы загрузить коммиты, которые еще отсутствуют в вашем локальном репозитории. При этом обновляются соответствующие удаленные отслеживающие ветки. Точный набор ссылок, которые будут загружены, и место назначения определяется командой git fetch (в виде refspecs) и значениями, установленными по умолчанию в файле .git/config вашего репозитория. Эти значения, в свою очередь, определяются командами git clone или git remote add по умолчанию для загрузки всех веток (всех элементов из каталога refs/heads/ в удаленном репозитории) и размещения их в каталоге refs/remote/<remote> (например, refs/remote/origin/ для удаленного репозитория origin). При этом сохраняются имена веток (например, ветка refs/heads/main в удаленном репозитории становится локальной веткой refs/remote/origin/main).
После того как вы загрузили изменения из удаленного репозитория с помощью команды git fetch, можно объединить эти изменения с вашей локальной веткой с помощью команды git merge или перебазировать вашу локальную ветку на вершину удаленной отслеживающей ветки с помощью команды git rebase. Команда git pull лучше подходит для выполнения этих действий, поскольку она выполняет сначала git fetch, а затем автоматически вызывает git merge или git rebase в зависимости от вашей конфигурации.
Кстати, во многих случаях Git автоматически настраивает удаленную отслеживающую ветку как восходящую для локальной ветки при ее создании (Git сообщит вам об этом, когда это произойдет). Это означает, что вы можете использовать команду git pull для обновления локальной ветки без необходимости вручную объединять или перебазировать ее.
Публикация изменений
Если другим разработчикам нужно получить изменения из вашего локального репозитория, они могут либо добавить ваш репозиторий как удаленный и извлечь нужные коммиты (это предполагает сетевой доступ к вашему компьютеру), либо вы можете сами опубликовать изменения в общем удаленном репозитории. Когда вы просите кого-то извлечь изменения из вашего удаленного репозитория и внести их в общий проект, это называется запросом на вытягивание (pull request) – процесс, с которым вы уже наверняка сталкивались на GitHub и аналогичных платформах.
Публикация (push)
похожа на извлечение (pull)
наоборот: вы отправляете свои коммиты в удаленный репозиторий и обновляете его
ветку, чтобы она указывала на новые коммиты. В качестве меры безопасности Git
разрешает только быстрое перемещение удаленных веток; если вы хотите
опубликовать изменения, которые обновят удаленную ветку небыстрым способом, вам
придется принудительно выполнить их, используя git push --force-with-lease
(или
git push --force
, но будьте осторожны: --force-with-lease
сначала проверит, что
ваша удаленная отслеживающая ветка соответствует ветке удаленного репозитория,
чтобы убедиться, что никто не опубликовал изменения в веткe с тех пор, как вы
последний раз извлекали ее; --force
не будет выполнять эту проверку, делая то,
что вы ему приказываете, на ваш страх и риск).
Как и с git fetch, вы передаете ветки для обновления в команду git push, причем Git обеспечивает настройки по умолчанию, если вы этого не сделаете. Если вы ничего не укажете, Git выведет удаленный репозиторий из соответствующей восходящей ветки, поэтому в большинстве случаев git push эквивалентно git push origin. На самом деле это сокращение для git push origin main (при условии, что текущая ветка — main), которое само по себе является сокращением для git push origin main:main (а это краткая версия git push origin refs/heads/main:refs/heads/main), что означает, что нужно опубликовать локальный refs/heads/main в refs/heads/main удаленного репозитория origin.
Лучшие практики
Эти рекомендации предназначены для начинающих разработчиков.
При совместной работе над проектом необходимо помнить следующее:
- Разумно используйте слияние.
- Пишите четкие и информативные сообщения коммитов.
- Создавайте атомарные коммиты – каждый коммит должен компилироваться и запускаться независимо от последующих коммитов в истории.
- Не работайте напрямую на ветке main (или master, или любой другой ветке, которая не принадлежит исключительно вам). Вместо этого создавайте локальные ветки. Это помогает разделить работу над разными задачами. Хотите начать работать над другой ошибкой или функцией, ожидая дополнительных деталей по инструкциям к текущей? Переключитесь на другую ветку, вы вернетесь к ней позже, переключившись обратно. Это также упрощает обновление из удаленного репозитория – если ваши локальные ветки просто являются копиями удаленных веток с тем же именем без каких-либо локальных изменений, не возникнет никаких конфликтов (за исключением случаев, когда вы хотите отправить изменения в эти ветки).
- Не стесняйтесь переписывать историю коммитов (
git commit --amend
и/илиgit rebase -i
), но не делайте это слишком часто. Совершенно нормально складывать много мелких коммитов во время работы, и переписывать/очищать историю только перед тем, как поделиться ею. - Аналогично, не стесняйтесь перебазировать свои локальные ветки, чтобы интегрировать изменения в восходящую ветку (до тех пор, пока вы не поделились этой веткой, после чего нужно следовать рабочему процессу ветвления проекта).
- В случае возникновения каких-либо проблем – используйте
gitk
,gitk HEAD @{1}
илиgitk --all
, чтобы визуализировать вашу историю и попытаться понять, что произошло. Из этого вы можете вернуться к предыдущему состоянию (git reset @{1}
) или попытаться исправить ситуацию (с помощью извлечения нужных коммитов и т. д.) А если неприятность случилась посреди перебазирования или, возможно, неудачного слияния, можно вернуться к предыдущему состоянию с помощьюgit rebase --abort
илиgit merge --abort
. - Для подстраховки не стесняйтесь создавать ветку или тег в качестве «закладки» перед выполнением «опасных» команд типа git rebase. К этой версии можно легко вернуться, если что-то пойдет не так.
- После выполнения команд типа git rebase не забудьте просмотреть историю и файлы, чтобы убедиться, что результат соответствует вашим ожиданиям.
Продвинутые концепции
В Git, разумеется, гораздо больше сложных концепций, но в первую очередь стоит знать об этих:
Состояние «оторванной головы» (detached HEAD). Когда HEAD указывает на локальную ветку, каждый новый коммит будет перемещать метку ветки на новый коммит. Однако, если HEAD указывает на что-либо другое, кроме локальной ветки, Git не сможет переместить ссылку на новый коммит. В этом случае репозиторий окажется в состоянии detached HEAD. Если вы создадите новый коммит в этом состоянии, только HEAD будет ссылаться на него, и ничто другое: если вы переключитесь на другую ветку, у вас больше не будет ссылки на этот коммит.
Обычно в это состояние можно попасть так:
- Выполнив команду git checkout <commit-hash>.
- Выполнив команду git checkout <remote>/<branch> для проверки удаленной ветки.
- Переключившись на ветку, которая была удалена.
Выйти из detached HEAD поможет git checkout
<branch-name>
.
Хуки. Это исполняемые файлы (чаще всего shell-скрипты), которые Git запускает в ответ на операции с репозиторием. Разработчики используют их для проверки кода перед каждым коммитом (отменяя коммит в случае неудачи), создания или обработки сообщений коммитов, а также для запуска действий на сервере после того, как кто-то отправил изменения в репозиторий (запуск сборки и/или развертывания).
Редко используемые команды, которые могут сэкономить вам несколько часов работы:
- git bisect – поможет определить, какой коммит внес ошибку, путем тестирования нескольких коммитов (вручную или с помощью сценариев). При линейной истории такую проверку можно выполнить вручную, но после нескольких слияний это становится намного сложнее, и как раз тут на помощь приходит git bisect.
- git filter-repo – заменяет команду filter-branch. Позволяет переписать всю историю репозитория для удаления ошибочно добавленного файла и помогает извлечь часть репозитория в другой.
Заключение
Если вникнуть во взаимодействие описанных выше концепций, будет гораздо проще:
- Сопоставить любую команду Git с тем, как она изменит направленный ациклический граф коммитов.
- Понять, как исправить ошибки (запустили merge в неправильной ветке? сделали rebase на неправильной ветке?)
Конечно, осмысление этих концепций требует некоторых усилий, но оно того стоит: доскональное знание Git обеспечивает эффективное управление своими репозиториями и успешную коллаборацию в совместных проектах.
При подготовке статьи использовалась публикация "How I teach Git".
Комментарии