Контроль версий и Git

Системы контроля версий (VCS) – это инструменты для отслеживания изменений в исходном коде (или в других наборах файлов и папок). Как следует из названия, эти инструменты помогают вести историю изменений; кроме того, они упрощают совместную работу. Логически VCS отслеживают изменения папки и её содержимого в виде последовательности снимков, где каждый снимок фиксирует полное состояние файлов и папок внутри каталога верхнего уровня. А ещё VCS хранят метаданные: кто создал каждый снимок, сообщения, связанные с каждым снимком, и так далее.

Чем полезен контроль версий? Даже когда вы работаете в одиночку, он позволяет заглянуть в старые снимки проекта, вести журнал того, почему были сделаны те или иные изменения, работать в параллельных ветках разработки и многое другое. А при работе с другими людьми это незаменимый инструмент, чтобы видеть, что изменили другие, и разрешать конфликты при одновременной разработке.

Современные VCS также позволяют легко (и часто автоматически) отвечать на вопросы вроде:

Хотя существуют и другие VCS, Git – де-факто стандарт контроля версий. Этот комикс XKCD отлично передаёт репутацию Git:

xkcd 1597

Поскольку интерфейс 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

Разное

Ресурсы

Упражнения

  1. Если у вас нет опыта работы с Git, попробуйте прочитать первые пару глав книги Pro Git или пройдите туториал вроде Learn Git Branching. По ходу дела соотносите команды Git с моделью данных.
  2. Склонируйте репозиторий сайта курса.
    1. Изучите историю версий, визуализировав её в виде графа.
    2. Кто последним изменял README.md? (Подсказка: используйте git log с аргументом).
    3. Каким было сообщение коммита, связанного с последним изменением строки collections: в файле _config.yml? (Подсказка: используйте git blame и git show).
  3. Одна из типичных ошибок при изучении Git – закоммитить большие файлы, которые не должны находиться под управлением Git, или добавить конфиденциальную информацию. Попробуйте добавить файл в репозиторий, сделать несколько коммитов, а затем удалить этот файл из истории (а не только из последнего коммита). Возможно, вам стоит заглянуть сюда.
  4. Склонируйте какой-нибудь репозиторий с GitHub и измените один из существующих в нём файлов. Что происходит, когда вы выполняете git stash? Что вы видите при запуске git log --all --oneline? Выполните git stash pop, чтобы отменить сделанное командой git stash. В каком сценарии это может пригодиться?
  5. Как и многие инструменты командной строки, Git предоставляет файл конфигурации (dotfile) под названием ~/.gitconfig. Создайте алиас в ~/.gitconfig, чтобы при запуске git graph вы получали вывод git log --all --graph --decorate --oneline. Это можно сделать, напрямую отредактировав файл ~/.gitconfig, а можно добавить алиас командой git config. Информацию об алиасах Git можно найти здесь.
  6. Вы можете задать глобальные шаблоны игнорирования в ~/.gitignore_global, выполнив git config --global core.excludesfile ~/.gitignore_global. Эта команда задаёт расположение глобального файла игнорирования, который будет использовать Git, но сам файл по этому пути всё равно нужно создать вручную. Настройте свой глобальный файл gitignore так, чтобы он игнорировал специфичные для ОС или редактора временные файлы, например .DS_Store.
  7. Сделайте форк репозитория сайта курса, найдите опечатку или какое-то другое улучшение, которое вы можете внести, и отправьте пул-реквест на GitHub (возможно, вам поможет это). Пожалуйста, отправляйте только полезные PR (не спамьте нас, пожалуйста!). Если не найдёте, что улучшить, это упражнение можно пропустить.
  8. Потренируйтесь разрешать конфликты слияния, смоделировав сценарий совместной работы:
    1. Создайте новый репозиторий с помощью git init и создайте файл recipe.txt с несколькими строками (например, простым рецептом).
    2. Закоммитьте его, а затем создайте две ветки: git branch salty и git branch sweet.
    3. В ветке salty измените одну строку (например, замените “1 cup sugar” на “1 cup salt”) и закоммитьте.
    4. В ветке sweet измените ту же строку иначе (например, замените “1 cup sugar” на “2 cups sugar”) и закоммитьте.
    5. Теперь переключитесь на master и попробуйте git merge salty, а затем git merge sweet. Что происходит? Загляните в содержимое recipe.txt – что означают маркеры <<<<<<<, ======= и >>>>>>>?
    6. Разрешите конфликт: отредактируйте файл, оставив нужное содержимое, удалите маркеры конфликта и завершите слияние командами git add и git commit (или git merge --continue). Или же попробуйте использовать git mergetool, чтобы разрешить конфликт с помощью графического или терминального инструмента слияния.
    7. Используйте git log --graph --oneline, чтобы визуализировать историю слияний, которую вы только что создали.

Редактировать страницу.

Лицензия CC BY-NC-SA.