Контроль версий и Git
Системы контроля версий (VCS) – это инструменты для отслеживания изменений в исходном коде (или в других наборах файлов и папок). Как следует из названия, эти инструменты помогают вести историю изменений; кроме того, они упрощают совместную работу. Логически VCS отслеживают изменения папки и её содержимого в виде последовательности снимков, где каждый снимок фиксирует полное состояние файлов и папок внутри каталога верхнего уровня. А ещё VCS хранят метаданные: кто создал каждый снимок, сообщения, связанные с каждым снимком, и так далее.
Чем полезен контроль версий? Даже когда вы работаете в одиночку, он позволяет заглянуть в старые снимки проекта, вести журнал того, почему были сделаны те или иные изменения, работать в параллельных ветках разработки и многое другое. А при работе с другими людьми это незаменимый инструмент, чтобы видеть, что изменили другие, и разрешать конфликты при одновременной разработке.
Современные VCS также позволяют легко (и часто автоматически) отвечать на вопросы вроде:
- Кто написал этот модуль?
- Когда редактировали вот эту конкретную строку вот этого конкретного файла? Кто? Зачем?
- В какой момент за последние 1000 ревизий (и почему) перестал работать конкретный модульный тест?
Хотя существуют и другие VCS, Git – де-факто стандарт контроля версий. Этот комикс XKCD отлично передаёт репутацию Git:

Поскольку интерфейс Git – «дырявая» абстракция (leaky abstraction), изучение Git сверху вниз (начиная с его интерфейса / интерфейса командной строки) может привести к изрядной путанице. Можно вызубрить горстку команд, воспринимать их как магические заклинания и всякий раз, когда что-то идёт не так, действовать по рецепту из комикса выше.
Хотя интерфейс у Git, надо признать, некрасивый, лежащие в его основе замысел и идеи прекрасны. Некрасивый интерфейс приходится заучивать, а красивый замысел можно понять. Поэтому мы объясняем Git снизу вверх: начинаем с его модели данных, а уже потом переходим к интерфейсу командной строки. Когда модель данных понятна, становятся понятнее и команды – видно, как они манипулируют этой моделью.
Модель данных Git
Гениальность Git – в его тщательно продуманной модели данных, которая и обеспечивает все приятные возможности контроля версий: ведение истории, поддержку веток, совместную работу.
Снимки
Git моделирует историю набора файлов и папок внутри некоторого каталога верхнего уровня как последовательность снимков. В терминологии Git файл называется «blob» – это просто набор байтов. Каталог называется «tree» (дерево): он отображает имена в blob’ы или деревья (то есть каталоги могут содержать другие каталоги). Снимок – это отслеживаемое дерево верхнего уровня. Например, у нас может быть такое дерево:
<root> (tree)
|
+- foo (tree)
| |
| + bar.txt (blob, contents = "hello world")
|
+- baz.txt (blob, contents = "git is wonderful")
Дерево верхнего уровня содержит два элемента: дерево «foo» (которое, в свою очередь, содержит один элемент – blob «bar.txt») и blob «baz.txt».
Моделирование истории: связываем снимки
Как системе контроля версий связывать снимки между собой? Одной простой моделью была бы линейная история: список снимков, упорядоченных по времени. По многим причинам Git не использует такую простую модель.
В Git история – это направленный ациклический граф (DAG) снимков. Звучит как мудрёный математический термин, но пусть он вас не пугает. Означает это всего лишь то, что каждый снимок в Git ссылается на множество «родителей» – снимков, которые ему предшествовали. Это именно множество родителей, а не один родитель (как было бы в линейной истории), потому что снимок может происходить от нескольких родителей – например, из-за объединения (слияния) двух параллельных веток разработки.
Такие снимки Git называет «коммитами» (commit). Визуализация истории коммитов может выглядеть примерно так:
o <-- o <-- o <-- o
^
\
--- o <-- o
На ASCII-рисунке выше символы o соответствуют отдельным коммитам
(снимкам). Стрелки указывают на родителя каждого коммита (это отношение
«предшествует», а не «следует за»). После третьего коммита история
разветвляется на две отдельные ветки. Это может соответствовать,
например, двум разным функциям, которые разрабатываются параллельно и
независимо друг от друга. В будущем эти ветки могут быть слиты – так
возникнет новый снимок, объединяющий обе функции, и получится новая
история, показанная ниже, где только что созданный коммит слияния
выделен жирным:
o <-- o <-- o <-- o <---- o
^ /
\ v
--- o <-- o
Коммиты в Git неизменяемы. Это, впрочем, не значит, что ошибки нельзя исправить; просто «правки» истории коммитов на самом деле создают совершенно новые коммиты, а ссылки (см. ниже) обновляются так, чтобы указывать на новые.
Модель данных в виде псевдокода
Полезно взглянуть на модель данных Git, записанную в виде псевдокода:
// a file is a bunch of bytes
type blob = array<byte>
// a directory contains named files and directories
type tree = map<string, tree | blob>
// a commit has parents, metadata, and the top-level tree
type commit = struct {
parents: array<commit>
author: string
message: string
snapshot: tree
}
Это чистая и простая модель истории.
Объекты и адресация по содержимому
«Объект» – это blob, дерево или коммит:
type object = blob | tree | commit
В хранилище данных Git все объекты адресуются по содержимому: адресом служит их SHA-1-хеш.
objects = map<string, object>
def store(object):
id = sha1(object)
objects[id] = object
def load(id):
return objects[id]
Таким образом, blob’ы, деревья и коммиты унифицированы: все они – объекты. Когда они ссылаются на другие объекты, они не содержат их в своём представлении на диске, а хранят ссылку на них по их хешу.
Например, дерево для структуры каталогов из примера выше
(визуализированное с помощью
git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d) выглядит так:
100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85 baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87 foo
Само дерево содержит указатели на своё содержимое: baz.txt (blob) и
foo (дерево). Если мы посмотрим на содержимое, адресуемое хешем,
соответствующим baz.txt, с помощью
git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85, то получим
следующее:
git is wonderful
Ссылки
Итак, любой снимок можно идентифицировать по его SHA-1-хешу. Это неудобно: люди плохо запоминают строки из 40 шестнадцатеричных символов.
Git решает эту проблему с помощью человекочитаемых имён для SHA-1-хешей,
которые называются «ссылками» (references). Ссылки – это указатели на
коммиты. В отличие от объектов, которые неизменяемы, ссылки изменяемы (их
можно обновить, чтобы они указывали на новый коммит). Например, ссылка
master обычно указывает на последний коммит в основной ветке разработки.
references = map<string, string>
def update_reference(name, id):
references[name] = id
def read_reference(name):
return references[name]
def load_reference(name_or_id):
if name_or_id in references:
return load(references[name_or_id])
else:
return load(name_or_id)
Благодаря этому Git может использовать человекочитаемые имена вроде «master», чтобы ссылаться на конкретный снимок в истории, вместо длинной шестнадцатеричной строки.
Одна деталь: нам часто нужно понятие «где мы сейчас находимся» в истории –
чтобы, делая новый снимок, мы знали, относительно чего он сделан (как
заполнить поле parents коммита). В Git это «где мы сейчас находимся» –
специальная ссылка с именем «HEAD».
Репозитории
Наконец, мы можем (приблизительно) определить, что такое репозиторий
Git: это данные objects и references.
На диске Git хранит только объекты и ссылки: вот и вся модель данных Git.
Каждая команда git соответствует какой-то манипуляции над DAG коммитов –
добавлению объектов и добавлению/обновлению ссылок.
Всякий раз, набирая какую-нибудь команду, задумывайтесь о том, какую
манипуляцию она выполняет над лежащей в основе графовой структурой данных.
И наоборот: если вы хотите внести в DAG коммитов какое-то конкретное
изменение, например «отбросить незакоммиченные изменения и сделать так,
чтобы ссылка „master“ указывала на коммит 5d83f9e», для этого, скорее
всего, найдётся команда (в данном случае – git checkout master; git reset
--hard 5d83f9e).
Индекс (staging area)
Это ещё одно понятие, ортогональное модели данных, но оно – часть интерфейса для создания коммитов.
Можно было бы представить, что снимки, описанные выше, реализованы так: есть команда «создать снимок», которая делает новый снимок на основе текущего состояния рабочего каталога. Некоторые системы контроля версий так и работают, но не Git. Нам нужны чистые снимки, а делать снимок из текущего состояния не всегда удобно. Представьте, например, что вы реализовали две отдельные функции и хотите создать два отдельных коммита: первый добавляет первую функцию, а следующий – вторую. Или представьте, что по всему коду у вас разбросаны отладочные print-выражения, а рядом – исправление бага; вы хотите закоммитить исправление, отбросив при этом все print-выражения.
Git поддерживает такие сценарии: через механизм под названием «индекс» (staging area) он позволяет указать, какие изменения должны попасть в следующий снимок.
Интерфейс командной строки Git
Чтобы не дублировать информацию, мы не будем подробно разбирать команды ниже в этом конспекте лекции. За подробностями смотрите настоятельно рекомендуемую книгу Pro Git или посмотрите видео лекции.
Основы
git help <command>: получить справку по команде gitgit init: создаёт новый репозиторий git, данные хранятся в каталоге.gitgit status: рассказывает, что происходитgit add <filename>: добавляет файлы в индексgit commit: создаёт новый коммит- Пишите хорошие сообщения коммитов!
- Ещё больше причин писать хорошие сообщения коммитов!
git log: показывает плоский лог историиgit log --all --graph --decorate: визуализирует историю в виде DAGgit diff <filename>: показать ваши изменения относительно индексаgit diff <revision> <filename>: показывает различия в файле между снимкамиgit checkout <revision>: обновляет HEAD (и текущую ветку, если переключаетесь на ветку)
Ветвление и слияние
git branch: показывает веткиgit branch <name>: создаёт веткуgit switch <name>: переключается на веткуgit checkout -b <name>: создаёт ветку и переключается на неё- то же самое, что
git branch <name>; git switch <name>
- то же самое, что
git merge <revision>: выполняет слияние в текущую веткуgit mergetool: использовать продвинутый инструмент для разрешения конфликтов слиянияgit rebase: выполнить rebase набора патчей на новую основу
Удалённые репозитории
git remote: выводит список удалённых репозиториевgit remote add <name> <url>: добавить удалённый репозиторийgit push <remote> <local branch>:<remote branch>: отправить объекты в удалённый репозиторий и обновить удалённую ссылкуgit branch --set-upstream-to=<remote>/<remote branch>: настроить соответствие между локальной и удалённой веткамиgit fetch: получить объекты/ссылки из удалённого репозиторияgit pull: то же самое, чтоgit fetch; git mergegit clone: скачать репозиторий с удалённого сервера
Отмена изменений
git commit --amend: отредактировать содержимое/сообщение коммитаgit reset <file>: убрать файл из индексаgit restore: отбросить изменения
Продвинутый Git
git config: Git гибко настраиваетсяgit clone --depth=1: поверхностное клонирование (shallow clone), без полной истории версийgit add -p: интерактивное добавление в индексgit rebase -i: интерактивный rebasegit blame: показать, кто последним редактировал какую строкуgit stash: временно убрать изменения в рабочем каталогеgit bisect: бинарный поиск по истории (например, чтобы найти регрессию)git revert: создать новый коммит, отменяющий эффект более раннего коммитаgit worktree: извлечь несколько веток одновременно.gitignore: указать намеренно неотслеживаемые файлы, которые нужно игнорировать
Разное
- GUI: для Git существует множество GUI-клиентов. Мы сами ими не пользуемся и работаем через интерфейс командной строки.
- Интеграция с оболочкой: очень удобно видеть статус Git прямо в приглашении командной строки (zsh, bash). Такое часто уже встроено во фреймворки вроде Oh My Zsh.
- Интеграция с редактором: как и в предыдущем пункте – удобные интеграции со множеством возможностей. Стандартной для Vim считается fugitive.vim.
- Рабочие процессы: мы объяснили вам модель данных и показали базовые команды, но не рассказали, каких практик придерживаться при работе над большими проектами (а тут есть много разных подходов).
- GitHub: Git – это не GitHub. У GitHub есть свой особый способ вносить код в чужие проекты – пул-реквесты.
- Другие Git-хостинги: GitHub не уникален – есть много других хостингов Git-репозиториев, например GitLab и BitBucket.
Ресурсы
- Pro Git – настоятельно рекомендуемое чтение. Прочитав главы 1–5, вы узнаете почти всё, что нужно, чтобы уверенно пользоваться Git, – теперь, когда вы понимаете модель данных. В последующих главах есть интересный продвинутый материал.
- Oh Shit, Git!?! – короткое руководство о том, как восстановиться после типичных ошибок в Git.
- Git for Computer Scientists – краткое объяснение модели данных Git: меньше псевдокода и больше красивых диаграмм, чем в этом конспекте лекции.
- Git from the Bottom Up – подробный рассказ о деталях реализации Git, выходящий за рамки модели данных, – для любознательных.
- Как объяснить git простыми словами
- Learn Git Branching – браузерная игра, которая учит вас Git.
Упражнения
- Если у вас нет опыта работы с Git, попробуйте прочитать первые пару глав книги Pro Git или пройдите туториал вроде Learn Git Branching. По ходу дела соотносите команды Git с моделью данных.
- Склонируйте репозиторий сайта
курса.
- Изучите историю версий, визуализировав её в виде графа.
- Кто последним изменял
README.md? (Подсказка: используйтеgit logс аргументом). - Каким было сообщение коммита, связанного с последним изменением
строки
collections:в файле_config.yml? (Подсказка: используйтеgit blameиgit show).
- Одна из типичных ошибок при изучении Git – закоммитить большие файлы, которые не должны находиться под управлением Git, или добавить конфиденциальную информацию. Попробуйте добавить файл в репозиторий, сделать несколько коммитов, а затем удалить этот файл из истории (а не только из последнего коммита). Возможно, вам стоит заглянуть сюда.
- Склонируйте какой-нибудь репозиторий с GitHub и измените один из
существующих в нём файлов. Что происходит, когда вы выполняете
git stash? Что вы видите при запускеgit log --all --oneline? Выполнитеgit stash pop, чтобы отменить сделанное командойgit stash. В каком сценарии это может пригодиться? - Как и многие инструменты командной строки, Git предоставляет файл
конфигурации (dotfile) под названием
~/.gitconfig. Создайте алиас в~/.gitconfig, чтобы при запускеgit graphвы получали выводgit log --all --graph --decorate --oneline. Это можно сделать, напрямую отредактировав файл~/.gitconfig, а можно добавить алиас командойgit config. Информацию об алиасах Git можно найти здесь. - Вы можете задать глобальные шаблоны игнорирования в
~/.gitignore_global, выполнивgit config --global core.excludesfile ~/.gitignore_global. Эта команда задаёт расположение глобального файла игнорирования, который будет использовать Git, но сам файл по этому пути всё равно нужно создать вручную. Настройте свой глобальный файл gitignore так, чтобы он игнорировал специфичные для ОС или редактора временные файлы, например.DS_Store. - Сделайте форк репозитория сайта курса, найдите опечатку или какое-то другое улучшение, которое вы можете внести, и отправьте пул-реквест на GitHub (возможно, вам поможет это). Пожалуйста, отправляйте только полезные PR (не спамьте нас, пожалуйста!). Если не найдёте, что улучшить, это упражнение можно пропустить.
- Потренируйтесь разрешать конфликты слияния, смоделировав сценарий
совместной работы:
- Создайте новый репозиторий с помощью
git initи создайте файлrecipe.txtс несколькими строками (например, простым рецептом). - Закоммитьте его, а затем создайте две ветки:
git branch saltyиgit branch sweet. - В ветке
saltyизмените одну строку (например, замените “1 cup sugar” на “1 cup salt”) и закоммитьте. - В ветке
sweetизмените ту же строку иначе (например, замените “1 cup sugar” на “2 cups sugar”) и закоммитьте. - Теперь переключитесь на
masterи попробуйтеgit merge salty, а затемgit merge sweet. Что происходит? Загляните в содержимоеrecipe.txt– что означают маркеры<<<<<<<,=======и>>>>>>>? - Разрешите конфликт: отредактируйте файл, оставив нужное
содержимое, удалите маркеры конфликта и завершите слияние
командами
git addиgit commit(илиgit merge --continue). Или же попробуйте использоватьgit mergetool, чтобы разрешить конфликт с помощью графического или терминального инструмента слияния. - Используйте
git log --graph --oneline, чтобы визуализировать историю слияний, которую вы только что создали.
- Создайте новый репозиторий с помощью
Лицензия CC BY-NC-SA.