## Git Basics ## Предварительные требования * Установленный Git: [https://git-scm.com/downloads](https://git-scm.com/downloads) * Пройден любой базовый курс по Git или изучение следующих курсов на LinkedIn Learning: * [Git Essential Training: The Basics](https://www.linkedin.com/learning/git-essential-training-the-basics/) * [Git: Branches, Merges, and Remotes](https://www.linkedin.com/learning/git-branches-merges-and-remotes/) * Ознакомление с официальной документацией по Git. ## Чего ожидать от этого курса Если вы инженер в области компьютерных наук, знание инструментов контроля версий становится практически обязательным. Хотя на сегодняшний день существует множество систем контроля версий, таких как SVN, Mercurial и другие, Git остаётся самой популярной, и именно с ним мы будем работать в рамках этого курса. Этот курс не начинается с основ Git (Git 101) и предполагает базовые знания Git как предварительное требование. Однако курс поможет вам освежить ваши знания, углубившись в то, как работают команды Git "под капотом". Так что в следующий раз, вводя команду Git, вы сможете нажимать Enter с гораздо большей уверенностью! *** ## Что не рассматривается в этом курсе * Продвинутое использование Git * Специфика и подробности внутренней реализации Git ## Содержание курса * Основы Git * Работа с ветками * Работа с Git и Github * Хуки в Git ## Основы Git Хотя вы, возможно, уже знакомы с этим, давайте ещё раз вспомним, зачем нам нужна система контроля версий. По мере роста проекта и подключения к нему нескольких разработчиков требуется эффективный способ совместной работы. Git помогает команде легко сотрудничать и одновременно сохраняет историю всех изменений в кодовой базе. ### Создание репозитория Git Любую папку можно превратить в репозиторий Git. После выполнения следующей команды в папке появится скрытая папка `.git`, которая и делает её репозиторием Git. Всё "волшебство" Git работает именно благодаря этой папке. ``` # создаём пустую папку и переходим в неё $ cd /tmp $ mkdir school-of-sre $ cd school-of-sre/ # инициализируем репозиторий Git $ git init Initialized empty Git repository in /private/tmp/school-of-sre/.git/ ``` Как видно из вывода, в нашей папке был инициализирован пустой репозиторий Git. Давайте посмотрим, что находится внутри. ``` $ ls .git/ HEAD config description hooks info objects refs ``` В папке `.git` мы видим несколько файлов и папок. Как уже упоминалось, именно они позволяют Git выполнять все свои функции. Позже мы подробнее рассмотрим содержимое некоторых из этих файлов и папок. А пока у нас есть просто пустой репозиторий Git. ### Отслеживание файла Как вы, возможно, уже знаете, давайте создадим новый файл в нашем репозитории (теперь будем называть папку — репозиторием) и посмотрим статус Git: ``` $ echo "I am file 1" > file1.txt $ git status On branch master No commits yet Untracked files: (use "git add ..." to include in what will be committed) file1.txt nothing added to commit but untracked files present (use "git add" to track) ``` Текущий статус Git показывает: **No commits yet** (ещё нет коммитов) и наличие одного **неотслеживаемого файла**. Поскольку мы только что создали файл, Git пока не отслеживает его. Нам нужно **явно** сказать Git, что нужно начать отслеживать этот файл. (Также стоит обратить внимание на файл `.gitignore`, о нём позже.) Как и подсказывает вывод команды, для отслеживания файлов используется команда `git add`. После этого мы можем сделать коммит: ``` $ git add file1.txt $ git status On branch master No commits yet Changes to be committed: (use "git rm --cached ..." to unstage) new file: file1.txt ``` Заметили, что после добавления файла статус изменился на **Changes to be committed**? Это значит, что все перечисленные файлы будут включены в следующий коммит. Теперь создадим коммит, добавив к нему сообщение с помощью флага `-m`: ``` $ git commit -m "adding file 1" [master (root-commit) df2fb7a] adding file 1 1 file changed, 1 insertion(+) create mode 100644 file1.txt ``` ### Подробнее о коммите **Коммит** — это снимок состояния репозитория в определённый момент времени. Каждый раз, когда создаётся коммит, фиксируется текущее состояние репозитория (папки) и сохраняется. У каждого коммита есть уникальный идентификатор (**ID**) (например, `df2fb7a` — это ID коммита, который мы создали на предыдущем шаге). По мере того как мы добавляем новые файлы или вносим изменения и создаём новые коммиты, Git сохраняет все эти снимки. И снова, всё это "волшебство" происходит внутри папки **.git**. Именно там Git хранит все версии и снимки в оптимизированной форме. ### Добавление новых изменений Давайте создадим ещё один файл и зафиксируем изменения. Процесс будет таким же, как и при предыдущем коммите: ``` $ echo "I am file 2" > file2.txt $ git add file2.txt $ git commit -m "adding file 2" [master 7f3b00e] adding file 2 1 file changed, 1 insertion(+) create mode 100644 file2.txt ``` Создан новый коммит с ID `7f3b00e`. Вы можете в любой момент использовать команду `git status`, чтобы посмотреть текущее состояние репозитория. **ВАЖНО:** ``` ВАЖНО: Обратите внимание, что идентификаторы коммитов (commit IDs) представляют собой длинные строки (SHA-хеши). Однако можно ссылаться на коммит, используя только первые несколько символов (обычно 8 или больше). В этом курсе мы будем использовать как полные, так и укороченные версии идентификаторов коммитов. ``` Теперь, когда у нас есть два коммита, давайте визуализируем их: ``` $ git log --oneline --graph * 7f3b00e (HEAD -> master) adding file 2 * df2fb7a adding file 1 ``` Как видно из названия, команда `git log` выводит журнал всех коммитов Git. Здесь используются два дополнительных аргумента: * `-oneline` — печатает коммиты в кратком формате (только сообщение коммита без информации о пользователе и времени), * `-graph` — рисует древовидную структуру истории коммитов. На данный момент коммиты выглядят как простая линейная последовательность — один коммит за другим. Но на самом деле Git хранит коммиты в виде древовидной структуры. Это значит, что у одного коммита может быть несколько потомков, а не только одна "прямая линия". Мы подробно рассмотрим это позже, в разделе о ветках. На данный момент наша история коммитов выглядит так: ``` df2fb7a ===> 7f3b00e ``` ### Действительно ли коммиты связаны между собой? Как я только что упоминал, два наших коммита связаны между собой через древовидную структуру. Мы уже видели, как они логически связаны, но давайте это ещё и подтвердим. В Git всё является объектом. Созданные файлы сохраняются как объекты. Изменения файлов сохраняются как объекты. И даже сами коммиты тоже являются объектами. Чтобы посмотреть содержимое объекта, мы можем использовать следующую команду с ID объекта. Давайте изучим содержимое второго коммита: ``` $ git cat-file -p 7f3b00e tree ebf3af44d253e5328340026e45a9fa9ae3ea1982 parent df2fb7a61f5d40c1191e0fdeb0fc5d6e7969685a author Sanket Patel 1603273316 -0700 committer Sanket Patel 1603273316 -0700 adding file 2 ``` Обратите внимание на строку `parent` в этом выводе. Она указывает на ID первого коммита, который мы сделали. Это подтверждает, что коммиты действительно связаны между собой! Дополнительно вы можете увидеть сообщение второго коммита внутри этого объекта. Как я уже говорил, всё это «волшебство» работает благодаря папке **.git**, и сам объект, который мы сейчас рассматриваем, тоже хранится там. Проверим наличие объекта: ``` $ ls .git/objects/7f/3b00eaa957815884198e2fdfec29361108d6a9 .git/objects/7f/3b00eaa957815884198e2fdfec29361108d6a9 ``` Он действительно хранится в папке **.git/objects/.** Все файлы и изменения к ним в Git сохраняются именно в этой папке. ### Часть о системе контроля версий в Git У нас уже есть два коммита (версии) в журнале Git. Одно из главных преимуществ системы контроля версий — это возможность перемещаться назад и вперёд по истории изменений. Например: Пользователи сообщают об ошибке, которая возникла в старой версии кода. В текущем репозитории у вас лежит самая последняя версия, а для отладки проблемы вам нужен именно старый код. Представим, что сейчас вы работаете с коммитом `7f3b00e` (второй коммит), а ошибку нашли в состоянии репозитория на момент коммита `df2fb7a` (первый коммит). Вот как вы можете получить доступ к коду на тот момент: ``` # Смотрим текущие файлы — у нас два файла $ ls file1.txt file2.txt # Переходим на (старый) коммит $ git checkout df2fb7a ``` В результате выполнения команды вы получите сообщение: ``` Note: checking out 'df2fb7a'. You are in 'detached HEAD' state... ... HEAD is now at df2fb7a adding file 1 ``` **Что это значит:** * Вы перешли в так называемое состояние **detached HEAD** — это значит, что вы смотрите на старую версию кода, не находясь на какой-либо ветке. * Вы можете спокойно изучать, вносить экспериментальные изменения, но чтобы сохранить их, нужно будет создать новую ветку (`git checkout -b <название-ветки>`). Теперь снова проверим содержимое папки: ``` # Проверяем файлы — видим только старое содержимое $ ls file1.txt ``` Таким образом, мы получили доступ к старой версии (снимку состояния). Git просто использует данные из папки **.git**, определяет, какие файлы и в каком состоянии были на момент нужного коммита, и временно заменяет содержимое рабочей директории. **Важно:** Файлы, которые были в более поздней версии (`file2.txt`), физически исчезнут из текущей папки, но они никуда не денутся из истории Git — вы всегда сможете к ним вернуться, потому что все коммиты хранятся в папке **.git**. ### Ссылки (References) в Git В предыдущем разделе я упоминал, что для перехода к нужной версии нам нужна **ссылка** на неё. По умолчанию репозиторий Git представляет собой **дерево коммитов**, и у каждого коммита есть уникальный идентификатор (ID). Но уникальный ID — **не единственный способ** ссылаться на коммит. В Git существует несколько способов обращаться к коммитам: * **HEAD** — это ссылка на **текущий коммит**. На какой бы коммит ни был сейчас "поставлен" ваш репозиторий, `HEAD` будет указывать на него.

* **HEAD\~1** — это ссылка на **предыдущий** коммит относительно текущего. То есть, чтобы перейти на предыдущую версию, мы могли бы выполнить:

``` $ git checkout HEAD~1 ``` (вместо указания полного ID коммита, как мы делали раньше). Аналогично, **master** — это тоже ссылка, но не на конкретный коммит, а на **ветку**. Поскольку Git хранит коммиты в виде дерева, появляются ветки. Стандартная ветка в репозитории называется **master** (в новых проектах часто используется **main**). **Master** (или любая другая ветка) всегда указывает на **последний коммит** в этой ветке. Даже если мы сейчас перешли к предыдущему коммиту (`df2fb7a`), ссылка `master` всё ещё указывает на последний коммит (`7f3b00e`). Чтобы вернуться к последней версии проекта, можно просто сделать checkout на `master`: ``` $ git checkout master Previous HEAD position was df2fb7a adding file 1 Switched to branch 'master' ``` Теперь, если проверить содержимое папки: ``` $ ls file1.txt file2.txt ``` Мы снова видим оба файла — текущую, актуальную версию проекта. Примечание: Вместо ссылки `master` мы также могли бы использовать напрямую ID нужного коммита. ### Ссылки и «магия» Git Давайте посмотрим на текущее состояние репозитория: У нас есть два коммита, и ссылки **master** и **HEAD** указывают на последний коммит: ``` $ git log --oneline --graph * 7f3b00e (HEAD -> master) adding file 2 * df2fb7a adding file 1 ``` **В чём «магия»?** Давайте заглянем в некоторые файлы: ``` $ cat .git/refs/heads/master 7f3b00eaa957815884198e2fdfec29361108d6a9 ``` Вуаля! Оказывается, куда указывает ссылка `master`, хранится в обычном текстовом файле. Когда Git нужно узнать, на какой коммит ссылается `master` или обновить эту ссылку (например, после нового коммита), он просто считывает или перезаписывает содержимое этого файла. **Что происходит при создании нового коммита:** * Git создаёт новый коммит поверх текущего; * потом он обновляет файл `refs/heads/master`, записывая туда ID нового коммита. Аналогично работает и ссылка **HEAD**: ``` $ cat .git/HEAD ref: refs/heads/master ``` Мы видим, что `HEAD` — это просто ссылка на `refs/heads/master`. То есть `HEAD` всегда указывает туда, куда указывает `master`. ### Небольшое приключение Мы уже обсуждали, что Git обновляет файлы внутри папки **.git** при выполнении команд. Но давайте попробуем сделать это вручную — и посмотрим, что произойдёт. Сначала проверим текущее состояние репозитория: ``` $ git log --oneline --graph * 7f3b00e (HEAD -> master) adding file 2 * df2fb7a adding file 1 ``` *** Теперь изменим ссылку **master**, чтобы она указывала на предыдущий (первый) коммит: ``` $ echo df2fb7a61f5d40c1191e0fdeb0fc5d6e7969685a > .git/refs/heads/master $ git log --oneline --graph * df2fb7a (HEAD -> master) adding file 1 ``` Мы вручную переписали содержимое файла `.git/refs/heads/master`, и теперь `git log` показывает только первый коммит! `HEAD` по-прежнему указывает на `master`, поэтому он "поехал" вместе с ним. Возвращаем всё обратно: ``` $ echo 7f3b00eaa957815884198e2fdfec29361108d6a9 > .git/refs/heads/master $ git log --oneline --graph * 7f3b00e (HEAD -> master) adding file 2 * df2fb7a adding file 1 ``` И вот всё снова на своих местах. Как видно, "магии" на самом деле нет: Git просто обновляет маленькие текстовые файлы внутри **.git**. Вся структура репозитория управляется через простые ссылки и сохранённые объекты.